First load: - Draw the Overview as soon as the data arrives instead of on its next 2-second timer tick (about 2.5 s to 0.6 s on a typical connection). - Start on DOMContentLoaded instead of window.onload. - Load the ~900 KB PDF library only when someone exports a PDF. - Fetch the startup files side by side, and build the docs search index after the dashboard has drawn (unless the visit starts on /docs). - Show a loading placeholder on the Overview until the data arrives. Demo: the date-batch tags sit on fixed working-day offsets from today. The seed records them in Demo-Dates.json and the worker relabels them when the day changes, so a long-running container never looks stale.
4609 lines
285 KiB
HTML
Executable File
4609 lines
285 KiB
HTML
Executable File
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>USMT Dash</title>
|
|
<base href="/">
|
|
<link rel="icon" href="/favicon.png" type="image/png">
|
|
|
|
<script src="/vendor/marked/marked.min.js"></script>
|
|
<link rel="stylesheet" href="/vendor/highlight/styles/atom-one-dark.min.css">
|
|
<script src="/vendor/highlight/highlight.min.js"></script>
|
|
<script src="/vendor/highlight/languages/powershell.min.js"></script>
|
|
|
|
<!-- Terminal panel + demo-mode sandbox. In Live mode demo.js passes every request straight through. -->
|
|
<link rel="stylesheet" href="/demo/demo.css">
|
|
<script src="/demo/demo.js"></script>
|
|
<style>
|
|
/* --- THEME VARIABLES --- */
|
|
:root {
|
|
--bg-body: #f0f2f5;
|
|
--bg-card: #ffffff;
|
|
--text-main: #333333;
|
|
--text-muted: #7f8c8d;
|
|
--border-color: #dddddd;
|
|
--border-light: #eeeeee;
|
|
--table-header-bg: #0078d4;
|
|
--table-header-text: #ffffff;
|
|
--group-header-bg: #e7e7e7;
|
|
--input-bg: #ffffff;
|
|
--input-focus: #f1c40f;
|
|
--nav-bg: #2c3e50;
|
|
--remove-bg: #fdf2f0;
|
|
--remove-border: #e74c3c;
|
|
--hover-bg: #f0f2f5;
|
|
}
|
|
|
|
[data-theme="dark"] {
|
|
--bg-body: #121212;
|
|
--bg-card: #1e1e1e;
|
|
--text-main: #e0e0e0;
|
|
--text-muted: #9ca3af;
|
|
--border-color: #333333;
|
|
--border-light: #2c2c2c;
|
|
--table-header-bg: #0d3b66;
|
|
--table-header-text: #ffffff;
|
|
--group-header-bg: #2a2a2a;
|
|
--input-bg: #2c2c2c;
|
|
--input-focus: #3498db;
|
|
--nav-bg: #1a1a1a;
|
|
--remove-bg: #3b1c1c;
|
|
--remove-border: #e74c3c;
|
|
--hover-bg: #2c2c2c;
|
|
}
|
|
|
|
/* Markdown Image Scaling */
|
|
#docContent img {
|
|
max-width: 100%;
|
|
max-height: 450px;
|
|
object-fit: contain;
|
|
border-radius: 6px;
|
|
border: 1px solid var(--border-color);
|
|
margin: 15px 0;
|
|
background: rgba(0,0,0,0.1);
|
|
}
|
|
|
|
/* Dynamic Scrollbars */
|
|
::-webkit-scrollbar { width: 14px; height: 14px; }
|
|
::-webkit-scrollbar-track { background: var(--bg-card); }
|
|
::-webkit-scrollbar-thumb { background-color: var(--border-color); border-radius: 10px; border: 3px solid var(--bg-card); }
|
|
::-webkit-scrollbar-thumb:hover { background-color: var(--text-muted); }
|
|
* { scrollbar-color: var(--border-color) var(--bg-card); scrollbar-width: thin; }
|
|
|
|
/* --- GITHUB STYLE MARKDOWN & BADGES --- */
|
|
#docContent { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; line-height: 1.6; font-size: 1.05em; }
|
|
#docContent p { margin-top: 0; margin-bottom: 12px; }
|
|
#docContent h1, #docContent h2 { border-bottom: 1px solid var(--border-light); padding-bottom: 0.3em; margin-top: 0.5em; margin-bottom: 12px; }
|
|
#docContent h3, #docContent h4, #docContent h5 { margin-top: 0.5em; margin-bottom: 10px; }
|
|
#docContent hr { margin: 15px 0; border: none; border-bottom: 2px solid var(--border-light); }
|
|
#docContent blockquote { margin: 0 0 16px 0; padding: 0.5em 1em; color: var(--text-muted); border-left: 0.25em solid var(--border-color); background: rgba(0,0,0,0.03); border-radius: 0 4px 4px 0; }
|
|
[data-theme="dark"] #docContent blockquote { background: rgba(255,255,255,0.03); }
|
|
#docContent table { border-spacing: 0; border-collapse: collapse; margin-bottom: 16px; width: 100%; max-width: 100%; }
|
|
#docContent table th, #docContent table td { padding: 6px 13px; border: 1px solid var(--border-color); }
|
|
#docContent table tr:nth-child(2n) { background-color: rgba(0,0,0,0.04); }
|
|
[data-theme="dark"] #docContent table tr:nth-child(2n) { background-color: rgba(255,255,255,0.04); }
|
|
|
|
/* Custom Tag Badges */
|
|
.tag-badge { display: inline-block; padding: 2px 10px; font-size: 0.85em; font-weight: 600; line-height: 1.5; border-radius: 2em; margin-right: 5px; vertical-align: middle; }
|
|
.tag-red { background-color: #da3633; color: white; border: 1px solid #f85149; }
|
|
.tag-green { background-color: #238636; color: white; border: 1px solid #2ea043; }
|
|
.tag-yellow { background-color: #9e6a03; color: white; border: 1px solid #d29922; }
|
|
.tag-blue { background-color: #1f6feb; color: white; border: 1px solid #388bfd; }
|
|
.tag-gray { background-color: #6e7681; color: white; border: 1px solid #8b949e; }
|
|
|
|
/* Nicer Blockquotes */
|
|
#docContent blockquote {
|
|
margin: 0 0 16px 0;
|
|
padding: 10px 20px;
|
|
color: var(--text-main);
|
|
border-left: 4px solid var(--table-header-bg);
|
|
background: var(--group-header-bg);
|
|
border-radius: 0 4px 4px 0;
|
|
display: flex;
|
|
align-items: center;
|
|
min-height: 40px;
|
|
gap: 0.25em; /* This forces the space between the text and the link */
|
|
}
|
|
|
|
#docContent blockquote p {
|
|
margin: 0;
|
|
padding: 0;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.25em; /* Ensures consistency within the paragraph */
|
|
}
|
|
|
|
body { font-family: 'Segoe UI', sans-serif; background: var(--bg-body); color: var(--text-main); margin: 0; padding-top: 60px; transition: background-color 0.3s, color 0.3s; }
|
|
|
|
/* --- NAVBAR & RESPONSIVE FIXES --- */
|
|
.top-nav { position: fixed; top: 0; left: 0; width: 100%; height: 60px; background: var(--nav-bg); color: white; display: flex; align-items: center; padding: 0 15px; box-sizing: border-box; z-index: 2000; box-shadow: 0 2px 5px rgba(0,0,0,0.2); transition: background-color 0.3s; gap: 8px; }
|
|
|
|
.nav-brand { display: flex; align-items: center; flex-shrink: 0; transition: 0.3s; }
|
|
.nav-logo { height: 32px; width: 32px; margin-right: 10px; object-fit: contain; flex-shrink: 0; }
|
|
.top-nav h2 { margin: 0; margin-right: 20px; font-size: 1.4em; letter-spacing: 1px; color: white; white-space: nowrap; flex-shrink: 0; }
|
|
|
|
/* The container takes up remaining space, but items inside don't blow it out */
|
|
.nav-links { display: flex; flex: 1; align-items: center; white-space: nowrap; min-width: 0; gap: 4px; }
|
|
|
|
.nav-btn { background: transparent; border: none; color: #bdc3c7; padding: 8px 10px; cursor: pointer; font-size: 0.95em; border-radius: 4px; transition: 0.2s; font-weight: 500; white-space: nowrap; flex-shrink: 0; }
|
|
.nav-btn:hover { color: white; background: rgba(255,255,255,0.1); }
|
|
.nav-btn.active { background: #3498db; color: white; font-weight: bold; box-shadow: inset 0 -3px 0 rgba(0,0,0,0.2); }
|
|
|
|
/* Pushes the two right buttons to the edge */
|
|
.nav-right-controls { display: flex; align-items: center; gap: 8px; margin-left: auto; flex-shrink: 1; min-width: 0; }
|
|
|
|
.theme-toggle { font-size: 0.9em; padding: 6px 12px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2); display: flex; align-items: center; justify-content: center; width: auto; white-space: nowrap; flex-shrink: 0; cursor: pointer; border-radius: 4px; color: white; transition: 0.2s; }
|
|
.theme-toggle:hover { background: rgba(255,255,255,0.2); }
|
|
.theme-toggle svg { margin-right: 6px; flex-shrink: 0; }
|
|
|
|
/* We removed max-width and set flex-shrink to 0 so the date never squishes */
|
|
.status-btn { background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.2); color: white; padding: 8px 12px; border-radius: 4px; font-size: 0.9em; cursor: pointer; transition: 0.2s; text-align: right; white-space: nowrap; flex-shrink: 0; }
|
|
.status-btn:hover { background: rgba(0,0,0,0.4); }
|
|
|
|
.hamburger-btn { display: none; background: transparent; border: none; color: white; cursor: pointer; padding: 5px; flex-shrink: 0; margin-right: 15px; }
|
|
.hamburger-btn:hover { color: #3498db; }
|
|
|
|
.progress-container { width: 100%; background-color: var(--border-color); border-radius: 4px; margin-top: 15px; overflow: hidden; }
|
|
.progress-bar { height: 20px; background-color: #3498db; width: 0%; transition: width 0.3s ease; display: flex; align-items: center; justify-content: center; color: white; font-size: 0.8em; font-weight: bold; min-width: 40px; }
|
|
.sync-details { font-size: 0.9em; margin-top: 10px; color: var(--text-muted); }
|
|
.force-btn { background: #e67e22; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-weight: bold; width: 100%; margin-top: 20px; font-size: 1em; transition: 0.2s; }
|
|
.force-btn:hover { background: #d35400; }
|
|
.force-btn:disabled { background: var(--border-color); color: var(--text-muted); cursor: not-allowed; }
|
|
|
|
.main { padding: 25px; }
|
|
.header-row { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
|
|
.add-btn { background: #27ae60; color: white; border: none; padding: 10px 20px; border-radius: 4px; cursor: pointer; font-weight: bold; }
|
|
|
|
.stack-container { display: flex; flex-direction: column; gap: 20px; align-items: stretch; }
|
|
.stack-box, .roster-box, .tags-box { background: var(--bg-card); border-radius: 8px; width: 100%; box-sizing: border-box; box-shadow: 0 4px 6px rgba(0,0,0,0.3); position: relative; transition: background-color 0.3s; overflow: hidden; }
|
|
.roster-box { flex: 1; min-width: 300px; max-width: 600px; padding: 20px; border-top: 5px solid var(--table-header-bg); }
|
|
.tags-box { flex: 1; min-width: 250px; max-width: 400px; padding: 20px; border-top: 5px solid var(--table-header-bg); }
|
|
|
|
.del-stack { background: none; border: none; color: #e74c3c; cursor: pointer; font-size: 0.8em; font-weight: bold; padding: 4px 8px; border-radius: 4px; }
|
|
.del-stack:hover { background: rgba(231, 76, 60, 0.1); }
|
|
|
|
textarea, input[type="text"], select { background: var(--input-bg); color: var(--text-main); border: 1px solid var(--border-color); border-radius: 4px; padding: 8px; box-sizing: border-box; font-family: monospace; transition: background-color 0.3s, color 0.3s, border-color 0.3s; }
|
|
textarea:focus, input[type="text"]:focus, select:focus { outline: none; border-color: var(--input-focus); box-shadow: 0 0 5px rgba(52, 152, 219, 0.5); }
|
|
|
|
textarea.auto-resize { width: 100%; min-height: 60px; margin-bottom: 10px; resize: none; overflow: hidden; border: none; border-bottom: 1px solid var(--border-color); border-radius: 0; }
|
|
textarea.static-box { height: 250px; font-size: 1em; width: 100%; resize: none; }
|
|
textarea.remove-box { height: 120px; font-size: 1em; width: 100%; resize: vertical; overflow-y: auto; margin-bottom: 8px; }
|
|
|
|
.input-group { margin-bottom: 20px; }
|
|
.input-group label { display: block; font-weight: bold; margin-bottom: 5px; color: var(--text-main); }
|
|
.input-group input[type="text"] { width: 100%; padding: 10px; font-size: 1em; }
|
|
|
|
.remove-container { background: var(--remove-bg); padding: 10px; border-radius: 4px; border-left: 4px solid var(--remove-border); margin-bottom: 20px; transition: background-color 0.3s; }
|
|
|
|
.del-btn { background: #e74c3c; color: white; border: none; padding: 6px 15px; border-radius: 4px; cursor: pointer; font-weight: bold; font-size: 0.9em; transition: 0.2s; }
|
|
.del-btn:hover { background: #c0392b; }
|
|
|
|
.tag-btn { background: var(--input-bg); color: var(--text-main); border: 1px solid var(--border-color); padding: 8px 15px; border-radius: 20px; cursor: pointer; font-size: 0.9em; font-weight: bold; transition: 0.2s; }
|
|
.tag-btn:hover { background: #3498db; color: white; border-color: #3498db; }
|
|
|
|
table { width: 100%; border-collapse: collapse; font-size: 0.8em; table-layout: fixed; }
|
|
th { text-align: left; border-bottom: 2px solid var(--border-light); padding: 6px; background: var(--table-header-bg); color: var(--table-header-text); transition: background-color 0.3s; }
|
|
td { padding: 6px; border-bottom: 1px solid var(--border-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-main); }
|
|
|
|
.icon-svg { width: 14px; height: 14px; fill: currentColor; vertical-align: middle; }
|
|
.check-svg { fill: #2ecc71; }
|
|
|
|
.sortable-th { cursor: pointer; position: relative; padding-right: 40px !important; user-select: none; transition: background-color 0.2s; }
|
|
.sortable-th:hover { filter: brightness(1.1); }
|
|
|
|
.sort-icon { position: absolute; right: 22px; top: 50%; transform: translateY(-50%); color: rgba(255,255,255,0.5); font-size: 1.15em; font-family: monospace; }
|
|
.sort-icon.active { color: white; font-weight: bold; }
|
|
|
|
/* ===== Custom View responsive table ===== */
|
|
.custom-scroll-area { overflow: auto; }
|
|
#custom { min-width: 0; max-width: 100%; width: 100%; overflow-x: hidden; box-sizing: border-box; }
|
|
#custom * { min-width: 0; }
|
|
#wrapper-custom { min-width: 0; max-width: 100%; width: 100%; overflow: hidden; box-sizing: border-box; }
|
|
#scroll-custom { max-width: 100%; overflow: auto; max-height: calc(100vh - 60px - 50px - 210px); }
|
|
#custom-table-host { max-width: none; }
|
|
#wrapper-custom.has-extra .custom-scroll-area thead th { position: sticky; top: 0; z-index: 20; box-shadow: inset 0 -2px 0 var(--border-light); }
|
|
/* Header: bordered drag-grip strip on top; label left; icons grouped far right */
|
|
#table-custom .custom-th { padding: 0 !important; position: relative; box-sizing: border-box; vertical-align: top; overflow: hidden; }
|
|
#table-custom .custom-th .col-grip-row { display: flex; align-items: center; justify-content: center; height: 20px; border-bottom: 1px solid rgba(255,255,255,0.25); background: rgba(0,0,0,0.18); }
|
|
#table-custom .custom-th .col-grip { display: flex; align-items: center; justify-content: center; width: 100%; height: 100%; cursor: grab; color: rgba(255,255,255,0.65); }
|
|
#table-custom .custom-th .col-grip:active { cursor: grabbing; }
|
|
#table-custom .custom-th .col-grip svg { width: 20px; height: 14px; fill: currentColor; pointer-events: none; }
|
|
#table-custom .custom-th.dragging { opacity: 0.4; }
|
|
#table-custom .custom-th.drag-over { box-shadow: inset 3px 0 0 var(--accent, #3498db); }
|
|
#table-custom .custom-th .th-icons { display: flex; align-items: center; justify-content: flex-start; gap: 8px; padding: 5px 10px 2px; overflow: hidden; }
|
|
#table-custom .custom-th .th-icons > span { display: inline-flex; align-items: center; justify-content: center; line-height: 1; height: 16px; width: 16px; flex: 0 0 auto; }
|
|
#table-custom .custom-th .col-label-row { cursor: pointer; padding: 2px 10px 8px; min-width: 0; overflow: hidden; }
|
|
#table-custom .custom-th .col-label-row .label-text { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
#table-custom .custom-th .sort-icon-inline { color: rgba(255,255,255,0.6); font-size: 1.1em; font-family: monospace; cursor: pointer; }
|
|
#table-custom .custom-th .sort-icon-inline.active { color: #fff; font-weight: bold; }
|
|
#table-custom .custom-th .col-filter-btn { cursor: pointer; color: rgba(255,255,255,0.7); }
|
|
#table-custom .custom-th .col-filter-btn.active { color: var(--accent, #3498db); }
|
|
#table-custom .custom-th .col-filter-btn svg { width: 13px; height: 13px; fill: currentColor; display: block; }
|
|
#table-custom .custom-th .col-copy { cursor: pointer; position: static !important; right: auto !important; top: auto !important; transform: none !important; opacity: 0.7; }
|
|
#table-custom .custom-th .col-copy:hover { opacity: 1; transform: none !important; }
|
|
#table-custom .custom-th .col-copy svg { width: 13px; height: 13px; display: block; }
|
|
#table-custom .custom-th .col-remove { color: var(--danger, #e74c3c); font-weight: bold; cursor: pointer; opacity: 0; transition: opacity 0.15s; font-size: 1em; }
|
|
#table-custom .custom-th:hover .col-remove { opacity: 1; }
|
|
/* Full-height resize handles (overlay), active anywhere along the column border */
|
|
#custom-resize-overlay { z-index: 40; }
|
|
#custom-resize-overlay .rz-handle { position: absolute; top: 0; width: 9px; height: 100%; cursor: col-resize; pointer-events: auto; transform: translateX(-50%); z-index: 40; }
|
|
#custom-resize-overlay .rz-handle::after { content: ""; position: absolute; left: 50%; top: 0; width: 1px; height: 100%; background: transparent; transform: translateX(-50%); }
|
|
#table-custom .custom-spacer-th, #table-custom .custom-spacer-td { border-right: none !important; border-left: none !important; background: var(--bg-card); }
|
|
#table-custom thead th.no-copy:not(.custom-th) { border-left: none; }
|
|
#wrapper-custom.has-extra #table-custom tbody tr:hover { filter: brightness(1.08); }
|
|
#wrapper-custom.has-extra #table-custom td, #wrapper-custom.has-extra #table-custom th { border-right: 1px solid var(--border-light); }
|
|
#table-custom td { vertical-align: top; box-sizing: border-box; }
|
|
#wrapper-custom.has-extra #table-custom td { white-space: normal; word-break: break-word; }
|
|
/* Excel-style filter popup */
|
|
.col-filter-popup { position: fixed; z-index: 9999; width: 280px; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 6px; box-shadow: 0 6px 24px rgba(0,0,0,0.5); display: none; overflow: hidden; }
|
|
.col-filter-popup.show { display: block; }
|
|
.col-filter-popup .cfp-section { padding: 8px; border-bottom: 1px solid var(--border-light); }
|
|
.col-filter-popup input.cfp-text { width: 100%; padding: 7px 8px; box-sizing: border-box; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-main, var(--bg-card)); color: var(--text-main); outline: none; font-family: inherit; font-size: 0.9em; }
|
|
.col-filter-popup .cfp-hint { font-size: 0.72em; color: var(--text-muted); margin: 4px 2px 0; }
|
|
.col-filter-popup .cfp-list { max-height: 240px; overflow-y: auto; }
|
|
.col-filter-popup .cfp-opt { display: flex; align-items: center; gap: 8px; padding: 5px 10px; cursor: pointer; font-size: 0.88em; color: var(--text-main); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
.col-filter-popup .cfp-opt:hover { background: rgba(127,127,127,0.12); }
|
|
.col-filter-popup .cfp-opt input { flex: 0 0 auto; }
|
|
.col-filter-popup .cfp-actions { display: flex; gap: 8px; padding: 8px; }
|
|
.col-filter-popup .cfp-actions button { flex: 1; padding: 6px; border: 1px solid var(--border-color); background: transparent; color: var(--text-main); border-radius: 4px; cursor: pointer; font-size: 0.82em; }
|
|
.col-filter-popup .cfp-actions button:hover { filter: brightness(1.2); }
|
|
|
|
.copy-col { position: absolute; right: 4px; top: 50%; transform: translateY(-50%); cursor: pointer; opacity: 0.5; transition: 0.2s; display: inline-flex; align-items: center; justify-content: center; width: 16px; height: 16px; }
|
|
.copy-col:hover { opacity: 1; transform: translateY(-50%) scale(1.2); color: white; }
|
|
|
|
.copy-row-btn { cursor: pointer; opacity: 0.4; display: inline-flex; align-items: center; justify-content: center; transition: 0.2s; width: 16px; height: 16px; color: var(--text-main); }
|
|
.copy-row-btn:hover { opacity: 1; transform: scale(1.2); }
|
|
|
|
.copy-table-btn { background: transparent; color: var(--text-main); border: 1px solid var(--border-color); padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 0.85em; transition: 0.2s; display: flex; align-items: center; gap: 6px; }
|
|
.copy-table-btn:hover { background: var(--hover-bg); }
|
|
|
|
.group-header { padding: 10px 15px; font-weight: bold; font-size: 1.2em; background: var(--group-header-bg); border-left: 5px solid var(--table-header-bg); display: flex; justify-content: space-between; align-items: center; transition: background-color 0.3s; }
|
|
|
|
.multi-search { width: 100%; padding: 8px; box-sizing: border-box; border: none; border-bottom: 1px solid var(--border-light); background: var(--bg-card); color: var(--text-main); position: sticky; top: 0; z-index: 10; outline: none; font-family: inherit; }
|
|
|
|
#searchInput, #searchSoftwareInput { width: 100%; padding: 12px; margin-bottom: 20px; border: 1px solid var(--border-color); background: var(--input-bg); color: var(--text-main); border-radius: 4px; font-size: 16px; font-family: 'Segoe UI', sans-serif; box-sizing: border-box; transition: background-color 0.3s, color 0.3s; }
|
|
|
|
.filter-row th { background: rgba(0,0,0,0.2); padding: 4px 6px; border-bottom: 2px solid var(--border-color); overflow: visible; }
|
|
.col-filter, .col-filter-sw, .select-box { width: 100%; height: 32px; padding: 4px 6px; border: 1px solid var(--border-color); background: var(--input-bg); color: var(--text-main); border-radius: 3px; font-size: 0.9em; box-sizing: border-box; font-family: inherit; }
|
|
.col-filter:focus, .col-filter-sw:focus { outline: none; border-color: var(--input-focus); box-shadow: 0 0 5px rgba(241, 196, 15, 0.5); }
|
|
|
|
.multi-select { position: relative; width: 100%; font-size: 0.9em; font-weight: normal; color: var(--text-main); }
|
|
.select-box { display: flex; align-items: center; cursor: pointer; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1; }
|
|
.options-container { display: none; position: absolute; top: 100%; left: 0; width: 100%; min-width: 250px; background: var(--bg-card); border: 1px solid var(--border-color); max-height: 250px; overflow-y: auto; overflow-x: hidden; z-index: 100; box-shadow: 0 4px 6px rgba(0,0,0,0.5); border-radius: 4px; margin-top: 2px; }
|
|
.option-label { display: flex; align-items: flex-start; padding: 6px 10px; cursor: pointer; border-bottom: 1px solid var(--border-light); white-space: normal; line-height: 1.3; word-break: break-word; }
|
|
.option-label:hover { background: var(--hover-bg); }
|
|
.option-label input { margin-right: 8px; margin-top: 2px; cursor: pointer; flex-shrink: 0; }
|
|
|
|
.soft-btn { background: #34495e; color: white; border: none; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 0.9em; width: 100%; box-sizing: border-box; }
|
|
.soft-btn.inline-btn { width: auto; padding: 3px 8px; font-size: 0.85em; margin-left: 10px; }
|
|
.soft-btn:hover { background: #2c3e50; }
|
|
|
|
.modal-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.7); z-index: 1000; }
|
|
#modal { z-index: 1010; }
|
|
#detailsModal { z-index: 1000; }
|
|
#syncModal { z-index: 1020; }
|
|
|
|
.modal-content { background: var(--bg-card); width: 85%; max-height: 80%; margin: 5% auto; border-radius: 8px; overflow-y: auto; box-shadow: 0 4px 25px rgba(0,0,0,0.5); transition: background-color 0.3s; }
|
|
.modal-header { position: sticky; top: 0; background: var(--bg-card); padding: 20px 20px 10px 20px; border-bottom: 1px solid var(--border-light); z-index: 10; transition: background-color 0.3s; }
|
|
.close-modal { float: right; cursor: pointer; font-size: 1.5em; font-weight: bold; color: #e74c3c; line-height: 1; margin-top: -5px; }
|
|
|
|
.details-table th { background: var(--group-header-bg); color: var(--text-main); width: 35%; border: 1px solid var(--border-color); padding: 10px; }
|
|
.details-table td { border: 1px solid var(--border-color); padding: 10px; color: var(--text-main); }
|
|
#docContent a { color: #3498db; text-decoration: none; font-weight: bold; }
|
|
#docContent a:hover { text-decoration: underline; }
|
|
#docContent > *:first-child { margin-top: 0; }
|
|
|
|
.clickable-row:hover { filter: brightness(0.85); }
|
|
[data-theme="dark"] .clickable-row:hover { filter: brightness(1.2); }
|
|
|
|
table[data-sel-col="0"] td:not(:nth-child(1)), table[data-sel-col="0"] th:not(:nth-child(1)) { user-select: none !important; }
|
|
table[data-sel-col="1"] td:not(:nth-child(2)), table[data-sel-col="1"] th:not(:nth-child(2)) { user-select: none !important; }
|
|
table[data-sel-col="2"] td:not(:nth-child(3)), table[data-sel-col="2"] th:not(:nth-child(3)) { user-select: none !important; }
|
|
table[data-sel-col="3"] td:not(:nth-child(4)), table[data-sel-col="3"] th:not(:nth-child(4)) { user-select: none !important; }
|
|
table[data-sel-col="4"] td:not(:nth-child(5)), table[data-sel-col="4"] th:not(:nth-child(5)) { user-select: none !important; }
|
|
table[data-sel-col="5"] td:not(:nth-child(6)), table[data-sel-col="5"] th:not(:nth-child(6)) { user-select: none !important; }
|
|
table[data-sel-col="6"] td:not(:nth-child(7)), table[data-sel-col="6"] th:not(:nth-child(7)) { user-select: none !important; }
|
|
table[data-sel-col="7"] td:not(:nth-child(8)), table[data-sel-col="7"] th:not(:nth-child(8)) { user-select: none !important; }
|
|
table[data-sel-col="8"] td:not(:nth-child(9)), table[data-sel-col="8"] th:not(:nth-child(9)) { user-select: none !important; }
|
|
table[data-sel-col="9"] td:not(:nth-child(10)), table[data-sel-col="9"] th:not(:nth-child(10)) { user-select: none !important; }
|
|
table[data-sel-col="10"] td:not(:nth-child(11)), table[data-sel-col="10"] th:not(:nth-child(11)) { user-select: none !important; }
|
|
table[data-sel-col="11"] td:not(:nth-child(12)), table[data-sel-col="11"] th:not(:nth-child(12)) { user-select: none !important; }
|
|
|
|
.soft-btn, .copy-col, .sort-icon, .copy-row-btn, .copy-table-btn, .icon-svg, button, select { user-select: none !important; }
|
|
|
|
#toast { visibility: hidden; min-width: 250px; background-color: var(--nav-bg); color: white; border: 1px solid var(--border-color); text-align: center; border-radius: 4px; padding: 12px; position: fixed; z-index: 3000; left: 50%; bottom: 30px; font-size: 1em; transform: translateX(-50%); box-shadow: 0 4px 12px rgba(0,0,0,0.5); }
|
|
#toast.show { visibility: visible; animation: fadein 0.3s, fadeout 0.3s 2.5s; }
|
|
@keyframes fadein { from {bottom: 0; opacity: 0;} to {bottom: 30px; opacity: 1;} }
|
|
@keyframes fadeout { from {bottom: 30px; opacity: 1;} to {bottom: 0; opacity: 0;} }
|
|
|
|
/* --- RESPONSIVE MOBILE TWEAKS --- */
|
|
|
|
/* Step 1: Hide theme button text early to give the date more room (1400px) */
|
|
@media (max-width: 1400px) {
|
|
.theme-text { display: none; }
|
|
.theme-toggle { width: 36px; padding: 6px; }
|
|
.theme-toggle svg { margin-right: 0; }
|
|
}
|
|
|
|
/* Step 2: Hamburger Menu (1100px) */
|
|
@media (max-width: 1100px) {
|
|
.hamburger-btn { display: block; }
|
|
|
|
.nav-brand { position: absolute; left: 50%; transform: translateX(-50%); margin: 0; }
|
|
.top-nav h2 { margin-right: 0; font-size: 1.25em; }
|
|
|
|
.nav-links { display: none; position: absolute; top: 60px; left: 0; width: 100%; background: var(--nav-bg); flex-direction: column; padding: 0; box-shadow: 0 8px 16px rgba(0,0,0,0.5); border-top: 1px solid var(--border-color); align-items: stretch; }
|
|
.nav-links.show-menu { display: flex; }
|
|
|
|
.nav-links .nav-btn { border-radius: 0; padding: 15px 25px; text-align: left; width: 100%; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 1.05em; }
|
|
.nav-links .nav-btn.active { box-shadow: inset 4px 0 0 #3498db; background: rgba(255,255,255,0.05); }
|
|
|
|
.nav-right-controls { flex-direction: column; margin: 0; width: 100%; gap: 0; align-items: stretch; }
|
|
|
|
/* Bring the text back and style for vertical menu */
|
|
.theme-text { display: inline; }
|
|
.theme-toggle svg { margin-right: 8px; }
|
|
.theme-toggle { width: 100%; margin: 0; padding: 15px 25px; border-radius: 0; border: none; border-bottom: 1px solid rgba(255,255,255,0.05); background: transparent; justify-content: flex-start; font-size: 1.05em; color: #bdc3c7; }
|
|
.theme-toggle:hover { color: white; background: rgba(255,255,255,0.1); }
|
|
|
|
.status-btn { max-width: none; border-radius: 0; border: none; padding: 15px 25px; text-align: left; background: transparent; color: #bdc3c7; font-size: 1.05em; }
|
|
.status-btn:hover { color: white; background: rgba(255,255,255,0.1); }
|
|
}
|
|
</style>
|
|
<link rel="stylesheet" href="/layout.css">
|
|
<link rel="stylesheet" href="/overview.css">
|
|
<link rel="stylesheet" href="/docs.css">
|
|
<script src="/layout.js"></script>
|
|
<script src="/device-info.js"></script>
|
|
<script src="/overview.js"></script>
|
|
<script src="/docs.js"></script>
|
|
<script src="/router.js"></script>
|
|
<script src="/jump.js"></script>
|
|
<script src="/excel-filter.js" defer></script>
|
|
</head>
|
|
<body>
|
|
|
|
<div class="top-nav">
|
|
<button class="hamburger-btn" onclick="toggleMobileMenu()">
|
|
<svg viewBox="0 0 100 80" width="24" height="24" fill="currentColor">
|
|
<rect width="100" height="15" rx="8"></rect>
|
|
<rect y="30" width="100" height="15" rx="8"></rect>
|
|
<rect y="60" width="100" height="15" rx="8"></rect>
|
|
</svg>
|
|
</button>
|
|
|
|
<a class="nav-brand" href="/" title="Home (Overview)" onclick="if (!event.ctrlKey && !event.metaKey && !event.shiftKey) { event.preventDefault(); switchPage('overview'); }">
|
|
<img src="/favicon.png" alt="USMT Logo" class="nav-logo">
|
|
<h2>USMT Dash</h2>
|
|
</a>
|
|
|
|
<div class="nav-links" id="nav-links-menu">
|
|
<button class="nav-btn active" id="btn-overview" onclick="switchPage('overview')">Overview</button>
|
|
<button class="nav-btn" id="btn-tracked" onclick="switchPage('tracked-devices')">Tracked Devices</button>
|
|
<button class="nav-btn" id="btn-workbench" onclick="switchPage('workbench')">My Workbench</button>
|
|
<button class="nav-btn" id="btn-all" onclick="switchPage('all-devices')">USMT Status</button>
|
|
<button class="nav-btn" id="btn-software" onclick="switchPage('software')">All Devices</button>
|
|
<button class="nav-btn" id="btn-custom" onclick="switchPage('custom')">Custom View</button>
|
|
<button class="nav-btn" id="btn-docs" onclick="switchPage('docs')">Documentation</button>
|
|
|
|
<div class="nav-right-controls">
|
|
<button class="nav-btn theme-toggle" id="theme-btn" onclick="toggleTheme()"></button>
|
|
<button class="status-btn" id="status-btn" onclick="openSyncModal()">Connecting to Server...</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="main">
|
|
|
|
<div id="overview" class="page">
|
|
<div id="overview-root"></div>
|
|
</div>
|
|
|
|
<div id="tracked-devices" class="page" style="display:none;">
|
|
<h1 style="margin-top: 0;">Tracked Devices</h1>
|
|
<p style="color: var(--text-muted); margin-top:-15px;">Add devices here to register them with the server for active USMT tracking.</p>
|
|
|
|
<div style="display: flex; gap: 25px; align-items: flex-start; flex-wrap: wrap; justify-content: center;">
|
|
<div class="roster-box">
|
|
<div class="input-group">
|
|
<label>Tech Identifier (Name or Tag):</label>
|
|
<input type="text" id="techId" placeholder="e.g. 4/30, Accounting, Broken WMI, etc...">
|
|
</div>
|
|
|
|
<div class="remove-container">
|
|
<label style="color: #e74c3c;">Remove Devices from Tag:</label>
|
|
<textarea id="removeDevices" class="remove-box" placeholder="Paste devices to remove..."></textarea>
|
|
<button class="del-btn" onclick="removeTrackedDevices()">Remove Devices</button>
|
|
</div>
|
|
|
|
<div class="input-group">
|
|
<label>Devices to Track (Paste list here):</label>
|
|
<textarea id="techDevices" class="static-box" placeholder="PRVDT-4K7Q2M9B PUBLT-8XW3N1CD"></textarea>
|
|
</div>
|
|
<button class="add-btn" onclick="saveTechRoster()">Save & Register Devices</button>
|
|
<span id="save-status" style="margin-left: 15px; color: #27ae60; font-weight: bold; display: none;">Saved!</span>
|
|
</div>
|
|
|
|
<div class="tags-box">
|
|
<h3 style="margin-top: 0;">Active Tags</h3>
|
|
<p style="font-size: 0.9em; color: var(--text-muted); margin-top: -10px;">Click a tag to load its devices.</p>
|
|
<div id="skipUSMTRow" style="display: none; align-items: center; gap: 10px; margin-bottom: 12px; padding: 8px 12px; background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 6px;">
|
|
<input type="checkbox" id="skipUSMTCheckbox" onchange="saveSkipUSMT()">
|
|
<label for="skipUSMTCheckbox" style="font-size: 0.9em; cursor: pointer; user-select: none;">
|
|
<strong>Skip USMT</strong> — Devices in this tag will skip USMT association check.
|
|
</label>
|
|
</div>
|
|
<div id="tagContainer" style="display: flex; flex-wrap: wrap; gap: 10px;">
|
|
<span style="color: var(--text-muted); font-size: 0.9em;">Loading...</span>
|
|
</div>
|
|
|
|
<div class="remove-container" style="margin-top: 30px; margin-bottom: 0;">
|
|
<label style="color: #e74c3c;">Global Device Delete (All Tags):</label>
|
|
<textarea id="globalRemoveDevices" class="remove-box" placeholder="Paste devices to globally remove..."></textarea>
|
|
<button class="del-btn" id="global-del-btn" onclick="globalRemoveDevices()">Remove Everywhere</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="workbench" class="page" style="display:none;">
|
|
<div class="header-row">
|
|
<h1 style="margin-top: 0;">Workbench Stacks</h1>
|
|
<button class="add-btn" onclick="addNewStack()">+ Add New Stack</button>
|
|
</div>
|
|
<p style="color: var(--text-muted); margin-top:-15px; margin-bottom: 25px;">Organize devices into temporary visual groupings. (Note: Ensure devices are registered in 'Tracked Devices' first).</p>
|
|
<div id="stackGrid" class="stack-container"></div>
|
|
</div>
|
|
|
|
<div id="all-devices" class="page" style="display:none;">
|
|
<h1 style="margin-top: 0;">USMT Status <span id="allDevicesCount" style="font-size: 0.7em; color: var(--text-muted);"></span></h1>
|
|
<p style="color: var(--text-muted); margin-top:-15px;">Displays all actively tracked devices across the organization.</p>
|
|
<input type='text' id='searchInput' onkeyup='filterMasterList()' placeholder='Global Search (Searches all groups)...'>
|
|
<div id="masterList">Loading...</div>
|
|
</div>
|
|
|
|
<div id="software" class="page" style="display:none;">
|
|
<div class="header-row">
|
|
<h1 style="margin-top: 0;">All Devices <span id="softwareCount" style="font-size: 0.7em; color: var(--text-muted);"></span></h1>
|
|
</div>
|
|
<p style="color: var(--text-muted); margin-top:-15px;">Automatically compares Source vs Destination machines to find missing specialty apps.</p>
|
|
<input type='text' id='searchSoftwareInput' onkeyup='filterSoftwareTab()' placeholder='Global Search Software Table...'>
|
|
|
|
<div class="table-wrapper" style="overflow-x: auto; background: var(--bg-card); border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); transition: padding-bottom 0.2s;">
|
|
<div class="group-header" style="margin-top: 0; border-radius: 8px 8px 0 0;">
|
|
<span>All Devices <span id="softwareGroupCount"></span></span>
|
|
<div style="display: flex; gap: 8px;">
|
|
<button class="copy-table-btn no-copy" onclick="copyLocationInfo('software', this)">
|
|
<svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M215.7 499.2C267 435 384 279.4 384 192C384 86 298 0 192 0S0 86 0 192c0 87.4 117 243 168.3 307.2c12.3 15.3 35.1 15.3 47.4 0zM192 128a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/></svg> Copy Location Info
|
|
</button>
|
|
<button class="copy-table-btn no-copy" onclick="copyTableRich('software', this)">
|
|
<svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M288 64C252.7 64 224 92.7 224 128L224 384C224 419.3 252.7 448 288 448L480 448C515.3 448 544 419.3 544 384L544 183.4C544 166 536.9 149.3 524.3 137.2L466.6 81.8C454.7 70.4 438.8 64 422.3 64L288 64zM160 192C124.7 192 96 220.7 96 256L96 512C96 547.3 124.7 576 160 576L352 576C387.3 576 416 547.3 416 512L416 496L352 496L352 512L160 512L160 256L176 256L176 192L160 192z"/></svg> Copy Table Data
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<table class='master-table' id="table-software">
|
|
<thead>
|
|
<tr>
|
|
<th class="sortable-th" width='10%' onclick="toggleSort('software', 0)">Source Device <span class="sort-icon" id="icon-software-0">↕</span> <span class="copy-col no-copy" onclick="copyColData('software', 0, this, event)" title="Copy Column"><svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M288 64C252.7 64 224 92.7 224 128L224 384C224 419.3 252.7 448 288 448L480 448C515.3 448 544 419.3 544 384L544 183.4C544 166 536.9 149.3 524.3 137.2L466.6 81.8C454.7 70.4 438.8 64 422.3 64L288 64zM160 192C124.7 192 96 220.7 96 256L96 512C96 547.3 124.7 576 160 576L352 576C387.3 576 416 547.3 416 512L416 496L352 496L352 512L160 512L160 256L176 256L176 192L160 192z"/></svg></span></th>
|
|
<th width='8%' class="no-copy">Src Apps</th>
|
|
<th class="sortable-th" width='10%' onclick="toggleSort('software', 2)">Dest Device <span class="sort-icon" id="icon-software-2">↕</span> <span class="copy-col no-copy" onclick="copyColData('software', 2, this, event)" title="Copy Column"><svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M288 64C252.7 64 224 92.7 224 128L224 384C224 419.3 252.7 448 288 448L480 448C515.3 448 544 419.3 544 384L544 183.4C544 166 536.9 149.3 524.3 137.2L466.6 81.8C454.7 70.4 438.8 64 422.3 64L288 64zM160 192C124.7 192 96 220.7 96 256L96 512C96 547.3 124.7 576 160 576L352 576C387.3 576 416 547.3 416 512L416 496L352 496L352 512L160 512L160 256L176 256L176 192L160 192z"/></svg></span></th>
|
|
<th width='8%' class="no-copy">Dest Apps</th>
|
|
<th class="sortable-th" width='12%' onclick="toggleSort('software', 4)">Backup Status <span class="sort-icon" id="icon-software-4">↕</span> <span class="copy-col no-copy" onclick="copyColData('software', 4, this, event)" title="Copy Column"><svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M288 64C252.7 64 224 92.7 224 128L224 384C224 419.3 252.7 448 288 448L480 448C515.3 448 544 419.3 544 384L544 183.4C544 166 536.9 149.3 524.3 137.2L466.6 81.8C454.7 70.4 438.8 64 422.3 64L288 64zM160 192C124.7 192 96 220.7 96 256L96 512C96 547.3 124.7 576 160 576L352 576C387.3 576 416 547.3 416 512L416 496L352 496L352 512L160 512L160 256L176 256L176 192L160 192z"/></svg></span></th>
|
|
<th class="sortable-th" width='35%' onclick="toggleSort('software', 5)">Missing Software <span class="sort-icon" id="icon-software-5">↕</span> <span class="copy-col no-copy" onclick="copyColData('software', 5, this, event)" title="Copy Column"><svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M288 64C252.7 64 224 92.7 224 128L224 384C224 419.3 252.7 448 288 448L480 448C515.3 448 544 419.3 544 384L544 183.4C544 166 536.9 149.3 524.3 137.2L466.6 81.8C454.7 70.4 438.8 64 422.3 64L288 64zM160 192C124.7 192 96 220.7 96 256L96 512C96 547.3 124.7 576 160 576L352 576C387.3 576 416 547.3 416 512L416 496L352 496L352 512L160 512L160 256L176 256L176 192L160 192z"/></svg></span></th>
|
|
<th class="sortable-th" width='12%' onclick="toggleSort('software', 6)">Tags <span class="sort-icon" id="icon-software-6">↕</span> <span class="copy-col no-copy" onclick="copyColData('software', 6, this, event)" title="Copy Column"><svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M288 64C252.7 64 224 92.7 224 128L224 384C224 419.3 252.7 448 288 448L480 448C515.3 448 544 419.3 544 384L544 183.4C544 166 536.9 149.3 524.3 137.2L466.6 81.8C454.7 70.4 438.8 64 422.3 64L288 64zM160 192C124.7 192 96 220.7 96 256L96 512C96 547.3 124.7 576 160 576L352 576C387.3 576 416 547.3 416 512L416 496L352 496L352 512L160 512L160 256L176 256L176 192L160 192z"/></svg></span></th>
|
|
<th width='5%' style="text-align:center; cursor:pointer;" title="Copy Headers" onclick="copyTableHeaders('software', this, event)"><span class="copy-row-btn"><svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M288 64C252.7 64 224 92.7 224 128L224 384C224 419.3 252.7 448 288 448L480 448C515.3 448 544 419.3 544 384L544 183.4C544 166 536.9 149.3 524.3 137.2L466.6 81.8C454.7 70.4 438.8 64 422.3 64L288 64zM160 192C124.7 192 96 220.7 96 256L96 512C96 547.3 124.7 576 160 576L352 576C387.3 576 416 547.3 416 512L416 496L352 496L352 512L160 512L160 256L176 256L176 192L160 192z"/></svg></span></th>
|
|
</tr>
|
|
<tr class="filter-row">
|
|
<th><input type="text" class="col-filter-sw" data-col="0" oninput="filterSoftwareTab()" placeholder="Filter..."></th>
|
|
<th class="no-copy"></th>
|
|
<th><input type="text" class="col-filter-sw" data-col="2" oninput="filterSoftwareTab()" placeholder="Filter..."></th>
|
|
<th class="no-copy"></th>
|
|
<th>
|
|
<div class="multi-select" id="status-filter-software">
|
|
<div class="select-box" onclick="toggleMultiSelect('status')">Select Status...</div>
|
|
<div class="options-container" id="status-options-software">
|
|
<label class="option-label"><input type="checkbox" value="No Data" onchange="filterSoftwareTab()"> No Data</label>
|
|
<label class="option-label"><input type="checkbox" value="N/A" onchange="filterSoftwareTab()"> N/A</label>
|
|
<label class="option-label"><input type="checkbox" value="Not Started" onchange="filterSoftwareTab()"> Not Started</label>
|
|
<label class="option-label"><input type="checkbox" value="Backup In Progress" onchange="filterSoftwareTab()"> Backup In Progress</label>
|
|
<label class="option-label"><input type="checkbox" value="Backup Completed" onchange="filterSoftwareTab()"> Backup Completed</label>
|
|
<label class="option-label"><input type="checkbox" value="Restore In Progress" onchange="filterSoftwareTab()"> Restore In Progress</label>
|
|
<label class="option-label"><input type="checkbox" value="Completed" onchange="filterSoftwareTab()"> Completed</label>
|
|
<label class="option-label"><input type="checkbox" value="Not Associated" onchange="filterSoftwareTab()"> Not Associated</label>
|
|
<label class="option-label"><input type="checkbox" value="Skipped" onchange="filterSoftwareTab()"> Skipped</label>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
<th>
|
|
<div class="multi-select" id="missing-filter-software">
|
|
<div class="select-box" onclick="toggleMultiSelect('missing')">Select Missing Apps...</div>
|
|
<div class="options-container" id="missing-options-software"></div>
|
|
</div>
|
|
</th>
|
|
<th>
|
|
<div class="multi-select" id="tags-filter-software">
|
|
<div class="select-box" onclick="toggleMultiSelect('software')">Select Tags...</div>
|
|
<div class="options-container" id="tags-options-software"></div>
|
|
</div>
|
|
</th>
|
|
<th class="no-copy"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="tbody-software"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="custom" class="page" style="display:none;">
|
|
<div class="header-row">
|
|
<h1 style="margin-top: 0;">Custom View <span id="customCount" style="font-size: 0.7em; color: var(--text-muted);"></span></h1>
|
|
</div>
|
|
<p style="color: var(--text-muted); margin-top:-15px;">Prototype: add any data point from the advanced view as a column. Adding a column enables horizontal scroll and resizable columns.</p>
|
|
<div style="display:flex; gap:10px; align-items:center; margin-bottom:12px; flex-wrap:wrap;">
|
|
<input type='text' id='searchCustomInput' onkeyup='filterCustomTab()' placeholder='Global Search...' style="flex:1; min-width:220px; margin:0;">
|
|
<div class="multi-select" id="addcol-custom" style="min-width:220px;">
|
|
<div class="select-box" onclick="toggleAddColMenu(event)">+ Add Column...</div>
|
|
<div class="options-container" id="addcol-options-custom" style="max-height:320px; overflow-y:auto;"></div>
|
|
</div>
|
|
<button class="copy-table-btn no-copy" onclick="resetCustomColumns()" style="white-space:nowrap;">Reset Columns</button>
|
|
<button class="copy-table-btn no-copy" onclick="shrinkCustomColumns()" style="white-space:nowrap;">Shrink Widths</button>
|
|
</div>
|
|
|
|
<div class="table-wrapper" id="wrapper-custom" style="background: var(--bg-card); border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); overflow: hidden;">
|
|
<div class="group-header" style="margin-top: 0; border-radius: 8px 8px 0 0;">
|
|
<span>Devices</span>
|
|
<div style="display: flex; gap: 8px;">
|
|
<button class="copy-table-btn no-copy" onclick="copyLocationInfo('custom', this)">
|
|
<svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M215.7 499.2C267 435 384 279.4 384 192C384 86 298 0 192 0S0 86 0 192c0 87.4 117 243 168.3 307.2c12.3 15.3 35.1 15.3 47.4 0zM192 128a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/></svg> Copy Location Info
|
|
</button>
|
|
<button class="copy-table-btn no-copy" onclick="copyTableRich('custom', this)">
|
|
<svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M288 64C252.7 64 224 92.7 224 128L224 384C224 419.3 252.7 448 288 448L480 448C515.3 448 544 419.3 544 384L544 183.4C544 166 536.9 149.3 524.3 137.2L466.6 81.8C454.7 70.4 438.8 64 422.3 64L288 64zM160 192C124.7 192 96 220.7 96 256L96 512C96 547.3 124.7 576 160 576L352 576C387.3 576 416 547.3 416 512L416 496L352 496L352 512L160 512L160 256L176 256L176 192L160 192z"/></svg> Copy Table Data
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div id="scroll-custom" class="custom-scroll-area">
|
|
<div id="custom-table-host" style="position:relative; min-width:100%;">
|
|
<table class='master-table' id="table-custom">
|
|
<thead><tr id="custom-header-row"></tr></thead>
|
|
<tbody id="tbody-custom"></tbody>
|
|
</table>
|
|
<div id="custom-resize-overlay" style="position:absolute; top:0; left:0; height:100%; pointer-events:none;"></div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="docs" class="page" style="display:none;">
|
|
<div style="display: flex; flex-direction: column; height: calc(100vh - 110px);">
|
|
<div class="header-row" style="flex-shrink: 0;">
|
|
<h1 style="margin-top: 0;">Documentation</h1>
|
|
</div>
|
|
<p style="color: var(--text-muted); margin-top:-15px; flex-shrink: 0;">Installation guides and runbooks for Specialty Software.</p>
|
|
|
|
<div style="display: flex; gap: 20px; align-items: stretch; flex: 1; min-height: 0;">
|
|
|
|
<div style="width: 250px; min-width: 250px; flex-shrink: 0; background: var(--bg-card); padding: 15px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); display: flex; flex-direction: column; min-height: 0;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--border-light); padding-bottom: 10px; margin-bottom: 10px; flex-shrink: 0;">
|
|
<h3 style="margin: 0;">Articles</h3>
|
|
<div style="display: flex; gap: 5px;">
|
|
<button class="nav-btn" style="padding: 4px 8px; font-size: 0.8em; border: 1px solid var(--border-color); display: flex; align-items: center; gap: 5px;" onclick="openImageManager()" title="Manage Images">
|
|
<svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M160 96C124.7 96 96 124.7 96 160L96 480C96 515.3 124.7 544 160 544L480 544C515.3 544 544 515.3 544 480L544 160C544 124.7 515.3 96 480 96L160 96zM224 176C250.5 176 272 197.5 272 224C272 250.5 250.5 272 224 272C197.5 272 176 250.5 176 224C176 197.5 197.5 176 224 176zM368 288C376.4 288 384.1 292.4 388.5 299.5L476.5 443.5C481 450.9 481.2 460.2 477 467.8C472.8 475.4 464.7 480 456 480L184 480C175.1 480 166.8 475 162.7 467.1C158.6 459.2 159.2 449.6 164.3 442.3L220.3 362.3C224.8 355.9 232.1 352.1 240 352.1C247.9 352.1 255.2 355.9 259.7 362.3L286.1 400.1L347.5 299.6C351.9 292.5 359.6 288.1 368 288.1z"/></svg> Images
|
|
</button>
|
|
<button class="add-btn" style="padding: 4px 8px; font-size: 0.8em;" onclick="createNewDoc()">+ Add</button>
|
|
</div>
|
|
</div>
|
|
<div id="docList" style="display: flex; flex-direction: column; gap: 10px; overflow-y: auto; flex: 1; min-height: 0;">Loading...</div>
|
|
</div>
|
|
|
|
<div style="flex: 1; min-width: 0; background: var(--bg-card); padding: 25px; border-radius: 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); display: flex; flex-direction: column; min-height: 0;">
|
|
<div id="docToolbar" style="display: none; align-items: center; gap: 10px; border-bottom: 1px solid var(--border-light); padding-bottom: 15px; margin-bottom: 20px; flex-shrink: 0;">
|
|
<div id="docHeaderInfo" style="flex: 1;"></div>
|
|
<button class="del-btn" id="btn-delete-doc" onclick="deleteDoc()" title="Archive Document" style="display:none; padding: 6px 10px; align-items: center; gap: 6px;"><svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M135.2 17.7L128 32H32C14.3 32 0 46.3 0 64S14.3 96 32 96H416c17.7 0 32-14.3 32-32s-14.3-32-32-32H320l-7.2-14.3C307.4 6.8 296.3 0 284.2 0H163.8c-12.1 0-23.2 6.8-28.6 17.7zM416 128H32L53.2 467c1.6 25.3 22.6 45 47.9 45H346.9c25.3 0 46.3-19.7 47.9-45L416 128z"/></svg> Archive</button>
|
|
<button class="add-btn" id="btn-save-doc" onclick="saveDoc()" title="Save Document" style="display:none; padding: 6px 10px; align-items: center; gap: 6px;"></button>
|
|
<button class="nav-btn" id="btn-read-mode" onclick="setDocMode('read')" title="Switch to Read Mode" style="display:none; padding: 6px 10px; border: 1px solid var(--border-color);"></button>
|
|
<button class="nav-btn" id="btn-download-pdf" onclick="downloadDocAsPDF()" title="Download as PDF" style="padding: 6px 10px; border: 1px solid var(--border-color); display: flex; align-items: center; gap: 6px;"><svg style="width:14px;height:14px;fill:currentColor;" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M352 96C352 78.3 337.7 64 320 64C302.3 64 288 78.3 288 96L288 306.7L246.6 265.3C234.1 252.8 213.8 252.8 201.3 265.3C188.8 277.8 188.8 298.1 201.3 310.6L297.3 406.6C309.8 419.1 330.1 419.1 342.6 406.6L438.6 310.6C451.1 298.1 451.1 277.8 438.6 265.3C426.1 252.8 405.8 252.8 393.3 265.3L352 306.7L352 96zM160 384C124.7 384 96 412.7 96 448L96 480C96 515.3 124.7 544 160 544L480 544C515.3 544 544 515.3 544 480L544 448C544 412.7 515.3 384 480 384L433.1 384L376.5 440.6C345.3 471.8 294.6 471.8 263.4 440.6L206.9 384L160 384zM464 440C477.3 440 488 450.7 488 464C488 477.3 477.3 488 464 488C450.7 488 440 477.3 440 464C440 450.7 450.7 440 464 440z"/></svg> PDF</button>
|
|
<button class="nav-btn" id="btn-edit-mode" onclick="setDocMode('edit')" title="Switch to Edit Mode" style="padding: 6px 10px; border: 1px solid var(--border-color);"></button>
|
|
</div>
|
|
|
|
<div id="docContent" style="flex: 1; overflow-y: auto; line-height: 1.6; min-height: 0; padding-right: 10px;">Select an article from the list to read it.</div>
|
|
<textarea id="docEditor" style="display: none; flex: 1; width: 100%; background: var(--input-bg); color: var(--text-main); border: 1px solid var(--border-color); border-radius: 4px; padding: 15px; font-family: monospace; font-size: 1.05em; resize: none; min-height: 0; box-sizing: border-box;"></textarea>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<div id="modal" class="modal-overlay" onclick="if(event.target === this) closeModal('modal')">
|
|
<div class="modal-content" style="max-width: 95%; width: 95%;">
|
|
<div class="modal-header">
|
|
<span class="close-modal" onclick="closeModal('modal')">×</span>
|
|
<h2 id="modalTitle" style="margin: 0 0 10px 0;">Software Inventory</h2>
|
|
<input type='text' id='softSearch' onkeyup='filterSoftwareModal()' placeholder='Search apps...' style='width: 100%; box-sizing: border-box; background: var(--input-bg); color: var(--text-main); border: 1px solid var(--border-color); padding: 8px; border-radius: 4px;'>
|
|
</div>
|
|
<div style="padding: 20px;">
|
|
<h3 style="margin-top: 0; margin-bottom: 10px; color: var(--text-main);">Specialty Apps (<span id="specialtyCount">0</span>)</h3>
|
|
<table id="softTable" style="width: 100%; border-collapse: collapse; font-size: 0.95em; table-layout: fixed;">
|
|
<thead><tr><th width="35%">Name</th><th width="15%">Version</th><th width="15%">Publisher</th><th width="20%">Source</th><th width="15%">Install Date</th></tr></thead>
|
|
<tbody id="softBody"></tbody>
|
|
</table>
|
|
|
|
<details style="margin-top: 25px; background: var(--hover-bg); border-radius: 6px; padding: 15px; border: 1px solid var(--border-color);">
|
|
<summary style="cursor: pointer; font-weight: bold; font-size: 1.1em; color: var(--text-main); user-select: none;">View All Installed Apps (<span id="allCount">0</span>)</summary>
|
|
<div style="margin-top: 15px; overflow-x: auto;">
|
|
<table id="allSoftTable" style="width: 100%; border-collapse: collapse; font-size: 0.95em; table-layout: fixed;">
|
|
<thead><tr>
|
|
<th style="text-align: left; border-bottom: 2px solid var(--border-light); padding: 6px; background: var(--group-header-bg); color: var(--text-main);" width="35%">Name</th>
|
|
<th style="text-align: left; border-bottom: 2px solid var(--border-light); padding: 6px; background: var(--group-header-bg); color: var(--text-main);" width="15%">Version</th>
|
|
<th style="text-align: left; border-bottom: 2px solid var(--border-light); padding: 6px; background: var(--group-header-bg); color: var(--text-main);" width="15%">Publisher</th>
|
|
<th style="text-align: left; border-bottom: 2px solid var(--border-light); padding: 6px; background: var(--group-header-bg); color: var(--text-main);" width="20%">Source</th>
|
|
<th style="text-align: left; border-bottom: 2px solid var(--border-light); padding: 6px; background: var(--group-header-bg); color: var(--text-main);" width="15%">Install Date</th>
|
|
</tr></thead>
|
|
<tbody id="allSoftBody"></tbody>
|
|
</table>
|
|
</div>
|
|
</details>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="detailsModal" class="modal-overlay" onclick="if(event.target === this) closeModal('detailsModal')">
|
|
<div class="modal-content" style="max-width: 550px;">
|
|
<div class="modal-header">
|
|
<span class="close-modal" onclick="closeModal('detailsModal')">×</span>
|
|
<h2 id="detailsTitle" style="margin: 0;">Device Details</h2>
|
|
</div>
|
|
<div id="detailsBody" style="padding: 20px;"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="syncModal" class="modal-overlay" onclick="if(event.target === this) closeModal('syncModal')">
|
|
<div class="modal-content" style="max-width: 400px; padding: 25px;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
|
<h2 style="margin: 0;">Data Synchronization</h2>
|
|
<span class="close-modal" onclick="closeModal('syncModal')" style="margin-top: 0;">×</span>
|
|
</div>
|
|
|
|
<p id="syncStatusText" style="margin-top: 0; font-weight: bold; color: var(--text-main);">Checking status...</p>
|
|
|
|
<div class="progress-container">
|
|
<div class="progress-bar" id="syncProgressFill">0%</div>
|
|
</div>
|
|
|
|
<div class="sync-details" id="syncDetailsText">Waiting for server response...</div>
|
|
<div class="sync-details" id="syncDurationText" style="color: var(--text-main); font-style: italic; margin-top: 5px;">Last Cycle Duration: N/A</div>
|
|
<div id="syncBreakdown" class="sync-breakdown"></div>
|
|
|
|
<button class="force-btn" id="forceSyncBtn" onclick="forceUpdate()">Force Update Now</button>
|
|
<button class="force-btn" id="forceFullBtn" onclick="forceFullRefresh()" style="margin-top: 10px;">Full Software/Advanced Refresh</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="confirmModal" class="modal-overlay" onclick="if(event.target === this) document.getElementById('confirmModal').style.display='none'">
|
|
<div class="modal-content" style="max-width: 400px; padding: 25px; text-align: center;">
|
|
<h3 style="margin-top: 0; color: var(--text-main);">Confirm Archive</h3>
|
|
<p style="color: var(--text-muted); margin-bottom: 25px;" id="confirmMessage">Are you sure?</p>
|
|
<div style="display: flex; justify-content: center; gap: 15px;">
|
|
<button class="nav-btn" onclick="document.getElementById('confirmModal').style.display='none'" style="border: 1px solid var(--border-color); color: var(--text-main);">Cancel</button>
|
|
<button class="del-btn" id="confirmActionBtn">Archive Article</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="imageModal" class="modal-overlay" onclick="if(event.target === this) closeModal('imageModal')">
|
|
<div class="modal-content" style="max-width: 1000px; padding: 25px;">
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;">
|
|
<h2 style="margin: 0; color: var(--text-main);">Image Manager</h2>
|
|
<span class="close-modal" onclick="closeModal('imageModal')" style="margin-top: 0;">×</span>
|
|
</div>
|
|
|
|
<div style="display: flex; gap: 15px; margin-bottom: 15px; align-items: center; background: var(--hover-bg); padding: 15px; border-radius: 6px; border: 1px solid var(--border-color); flex-wrap: wrap;">
|
|
<label class="nav-btn" style="border: 1px solid var(--border-color); cursor: pointer; padding: 8px 15px; border-radius: 4px; background: var(--bg-card);">
|
|
<input type="file" id="imageUploadInput" accept="image/png, image/jpeg, image/gif, image/webp" multiple style="display: none;" onchange="document.getElementById('fileCountText').innerHTML = this.files.length + ' file(s) selected'">
|
|
<span id="fileCountText" style="display: flex; align-items: center; gap: 6px;"><svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M128 512L512 512C547.3 512 576 483.3 576 448L576 208C576 172.7 547.3 144 512 144L362.7 144C355.8 144 349 141.8 343.5 137.6L305.1 108.8C294 100.5 280.5 96 266.7 96L128 96C92.7 96 64 124.7 64 160L64 448C64 483.3 92.7 512 128 512z"/></svg> Choose Files...</span>
|
|
</label>
|
|
<button class="add-btn" onclick="uploadImage()" id="btn-upload-img">Upload</button>
|
|
|
|
<div style="width: 1px; height: 30px; background: var(--border-color); margin: 0 10px;"></div>
|
|
|
|
<input type="text" id="imgSearchInput" placeholder="Search images..." style="flex: 1; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--input-bg); color: var(--text-main);" oninput="renderImageTable()">
|
|
|
|
<select id="imgSortSelect" style="padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--input-bg); color: var(--text-main);" onchange="renderImageTable()">
|
|
<option value="dateDesc">Newest First</option>
|
|
<option value="dateAsc">Oldest First</option>
|
|
<option value="nameAsc">Name (A-Z)</option>
|
|
<option value="nameDesc">Name (Z-A)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div style="display: flex; gap: 10px; align-items: center; margin-bottom: 10px; padding: 0 5px;">
|
|
<span style="font-size: 0.9em; color: var(--text-muted); font-weight: bold; margin-right: 5px;">Bulk Actions:</span>
|
|
<button class="nav-btn" style="padding: 4px 12px; font-size: 0.85em; border: 1px solid var(--border-color);" onclick="bulkCopyImages()">Copy Markdown</button>
|
|
<button class="nav-btn" style="padding: 4px 12px; font-size: 0.85em; border: 1px solid var(--border-color); color: #f1c40f;" onclick="bulkRenameImages()">Rename Selected</button>
|
|
<button class="del-stack" style="padding: 4px 12px; font-size: 0.85em; border: 1px solid #e74c3c;" onclick="bulkDeleteImages()">Delete Selected</button>
|
|
</div>
|
|
|
|
<div style="max-height: 600px; overflow-y: auto; border: 1px solid var(--border-color); border-radius: 6px;">
|
|
<table style="width: 100%; border-collapse: collapse; text-align: left;">
|
|
<thead style="position: sticky; top: 0; z-index: 5; background: var(--group-header-bg);">
|
|
<tr>
|
|
<th style="padding: 12px; width: 40px; text-align: center;"><input type="checkbox" id="selectAllImagesCb" onchange="toggleAllImages(this)" style="cursor: pointer;"></th>
|
|
<th style="padding: 12px 15px;">Image Preview & Filename</th>
|
|
<th style="padding: 12px 15px; width: 150px;">Date Uploaded</th>
|
|
<th style="padding: 12px 15px; width: 220px; text-align: right;">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="imageListBody">
|
|
<tr><td colspan="4" style="text-align: center; padding: 15px; color: var(--text-muted);">Loading images...</td></tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="promptModal" class="modal-overlay">
|
|
<div class="modal-content" style="max-width: 400px; padding: 25px; text-align: center; margin-top: 15%;">
|
|
<h3 style="margin-top: 0; color: var(--text-main);" id="promptTitle">Action</h3>
|
|
<p style="color: var(--text-muted); margin-bottom: 15px; word-break: break-word;" id="promptMessage">Are you sure?</p>
|
|
<input type="text" id="promptInput" style="width: 100%; margin-bottom: 25px; padding: 10px; border-radius: 4px; border: 1px solid var(--border-color); background: var(--input-bg); color: var(--text-main); font-size: 1em; box-sizing: border-box;" autocomplete="off">
|
|
<div style="display: flex; justify-content: center; gap: 15px;">
|
|
<button class="nav-btn" id="promptCancelBtn" style="border: 1px solid var(--border-color); color: var(--text-main);">Cancel</button>
|
|
<button class="add-btn" id="promptActionBtn">Confirm</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="toast">Data Copied!</div>
|
|
|
|
<script>
|
|
const COPY_SVG = `<svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M288 64C252.7 64 224 92.7 224 128L224 384C224 419.3 252.7 448 288 448L480 448C515.3 448 544 419.3 544 384L544 183.4C544 166 536.9 149.3 524.3 137.2L466.6 81.8C454.7 70.4 438.8 64 422.3 64L288 64zM160 192C124.7 192 96 220.7 96 256L96 512C96 547.3 124.7 576 160 576L352 576C387.3 576 416 547.3 416 512L416 496L352 496L352 512L160 512L160 256L176 256L176 192L160 192z"/></svg>`;
|
|
const LOCATION_SVG = `<svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 384 512"><path d="M215.7 499.2C267 435 384 279.4 384 192C384 86 298 0 192 0S0 86 0 192c0 87.4 117 243 168.3 307.2c12.3 15.3 35.1 15.3 47.4 0zM192 128a64 64 0 1 1 0 128 64 64 0 1 1 0-128z"/></svg>`;
|
|
const CHECK_SVG = `<svg class="icon-svg check-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M530.8 134.1C545.1 144.5 548.3 164.5 537.9 178.8L281.9 530.8C276.4 538.4 267.9 543.1 258.5 543.9C249.1 544.7 240 541.2 233.4 534.6L105.4 406.6C92.9 394.1 92.9 373.8 105.4 361.3C117.9 348.8 138.2 348.8 150.7 361.3L252.2 462.8L486.2 141.1C496.6 126.8 516.6 123.6 530.9 134z"/></svg>`;
|
|
const SUN_SVG = `<svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M320 32C328.4 32 336.3 36.4 340.6 43.7L396.1 136.3L500.9 110C509.1 108 517.8 110.4 523.7 116.3C529.6 122.2 532 131 530 139.1L503.7 243.8L596.4 299.3C603.6 303.6 608.1 311.5 608.1 319.9C608.1 328.3 603.7 336.2 596.4 340.5L503.7 396.1L530 500.8C532 509 529.6 517.7 523.7 523.6C517.8 529.5 509 532 500.9 530L396.2 503.7L340.7 596.4C336.4 603.6 328.5 608.1 320.1 608.1C311.7 608.1 303.8 603.7 299.5 596.4L243.9 503.7L139.2 530C131 532 122.4 529.6 116.4 523.7C110.4 517.8 108 509 110 500.8L136.2 396.1L43.6 340.6C36.4 336.2 32 328.4 32 320C32 311.6 36.4 303.7 43.7 299.4L136.3 243.9L110 139.1C108 130.9 110.3 122.3 116.3 116.3C122.3 110.3 131 108 139.2 110L243.9 136.2L299.4 43.6L301.2 41C305.7 35.3 312.6 31.9 320 31.9zM320 176C240.5 176 176 240.5 176 320C176 399.5 240.5 464 320 464C399.5 464 464 399.5 464 320C464 240.5 399.5 176 320 176zM320 416C267 416 224 373 224 320C224 267 267 224 320 224C373 224 416 267 416 320C416 373 373 416 320 416z"/></svg>`;
|
|
const MOON_SVG = `<svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M320 64C178.6 64 64 178.6 64 320C64 461.4 178.6 576 320 576C388.8 576 451.3 548.8 497.3 504.6C504.6 497.6 506.7 486.7 502.6 477.5C498.5 468.3 488.9 462.6 478.8 463.4C473.9 463.8 469 464 464 464C362.4 464 280 381.6 280 280C280 207.9 321.5 145.4 382.1 115.2C391.2 110.7 396.4 100.9 395.2 90.8C394 80.7 386.6 72.5 376.7 70.3C358.4 66.2 339.4 64 320 64z"/></svg>`;
|
|
const READ_SVG = `<svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M320 205.3L320 514.6L320.5 514.4C375.1 491.7 433.7 480 492.8 480L512 480L512 160L492.8 160C450.6 160 408.7 168.4 369.7 184.6C352.9 191.6 336.3 198.5 320 205.3zM294.9 125.5L320 136L345.1 125.5C391.9 106 442.1 96 492.8 96L528 96C554.5 96 576 117.5 576 144L576 496C576 522.5 554.5 544 528 544L492.8 544C442.1 544 391.9 554 345.1 573.5L332.3 578.8C324.4 582.1 315.6 582.1 307.7 578.8L294.9 573.5C248.1 554 197.9 544 147.2 544L112 544C85.5 544 64 522.5 64 496L64 144C64 117.5 85.5 96 112 96L147.2 96C197.9 96 248.1 106 294.9 125.5z"/></svg>`;
|
|
const EDIT_SVG = `<svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M128.1 64C92.8 64 64.1 92.7 64.1 128L64.1 512C64.1 547.3 92.8 576 128.1 576L274.3 576L285.2 521.5C289.5 499.8 300.2 479.9 315.8 464.3L448 332.1L448 234.6C448 217.6 441.3 201.3 429.3 189.3L322.8 82.7C310.8 70.7 294.5 64 277.6 64L128.1 64zM389.6 240L296.1 240C282.8 240 272.1 229.3 272.1 216L272.1 122.5L389.6 240zM332.3 530.9L320.4 590.5C320.2 591.4 320.1 592.4 320.1 593.4C320.1 601.4 326.6 608 334.7 608C335.7 608 336.6 607.9 337.6 607.7L397.2 595.8C409.6 593.3 421 587.2 429.9 578.3L548.8 459.4L468.8 379.4L349.9 498.3C341 507.2 334.9 518.6 332.4 531zM600.1 407.9C622.2 385.8 622.2 350 600.1 327.9C578 305.8 542.2 305.8 520.1 327.9L491.3 356.7L571.3 436.7L600.1 407.9z"/></svg>`;
|
|
const SAVE_SVG = `<svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M160 96C124.7 96 96 124.7 96 160L96 480C96 515.3 124.7 544 160 544L480 544C515.3 544 544 515.3 544 480L544 237.3C544 220.3 537.3 204 525.3 192L448 114.7C436 102.7 419.7 96 402.7 96L160 96zM192 192C192 174.3 206.3 160 224 160L384 160C401.7 160 416 174.3 416 192L416 256C416 273.7 401.7 288 384 288L224 288C206.3 288 192 273.7 192 256L192 192zM320 352C355.3 352 384 380.7 384 416C384 451.3 355.3 480 320 480C284.7 480 256 451.3 256 416C256 380.7 284.7 352 320 352z"/></svg>`;
|
|
|
|
const themes = {
|
|
light: {
|
|
groupColors: { "No Data": "#f8f9fa", "Not Started": "#ffffff", "Backup In Progress": "#ffc7ce", "Backup Completed": "#ffeb9c", "Restore In Progress": "#ffffcc", "Completed": "#c6efce", "Not Associated": "#ecf0f1", "In Progress (Pending Logs)": "#ffeb9c", "N/A": "#cce2ff" },
|
|
textColors: { "No Data": "#7f8c8d", "Not Started": "#333333", "Backup In Progress": "#9c0000", "Backup Completed": "#9c6500", "Restore In Progress": "#9c6500", "Completed": "#006100", "Not Associated": "#333333", "In Progress (Pending Logs)": "#9c6500", "N/A": "#004085" },
|
|
pingOnline: "#27ae60",
|
|
pingTimeout: "#e74c3c",
|
|
pingNotFound: "#000000",
|
|
tableHeaderBg: "#0078d4",
|
|
tableHeaderText: "#ffffff"
|
|
},
|
|
dark: {
|
|
groupColors: { "No Data": "#1f1f1f", "Not Started": "#1e1e1e", "Backup In Progress": "#4a1c23", "Backup Completed": "#4a3c10", "Restore In Progress": "#4a4a1a", "Completed": "#1c3a23", "Not Associated": "#2a2a2a", "In Progress (Pending Logs)": "#4a3c10", "N/A": "#23344d" },
|
|
textColors: { "No Data": "#6b7280", "Not Started": "#e0e0e0", "Backup In Progress": "#ff9999", "Backup Completed": "#ffcc66", "Restore In Progress": "#ffcc66", "Completed": "#99ff99", "Not Associated": "#a0a0a0", "In Progress (Pending Logs)": "#ffcc66", "N/A": "#9fc3fb" },
|
|
pingOnline: "#4ade80",
|
|
pingTimeout: "#f87171",
|
|
pingNotFound: "#9ca3af",
|
|
tableHeaderBg: "#0d3b66",
|
|
tableHeaderText: "#ffffff"
|
|
}
|
|
};
|
|
|
|
let currentTheme = localStorage.getItem('usmt_theme') || 'dark';
|
|
document.documentElement.setAttribute('data-theme', currentTheme);
|
|
|
|
let myStacks = JSON.parse(localStorage.getItem('usmt_workbench_stacks')) || [{id: 1, name: "Stack 1", list: ""}];
|
|
let apiData = [];
|
|
let advancedDataCache = {};
|
|
let tablesInitialized = false;
|
|
let currentAvailableTags = [];
|
|
let sortStates = {};
|
|
|
|
let softwareCache = {};
|
|
let isSoftwareCacheLoaded = false;
|
|
let frontendBlacklist = [];
|
|
let frontendSuites = {};
|
|
let frontendDeviceRules = [];
|
|
let frontendComboRules = [];
|
|
let availableDocs = [];
|
|
|
|
const specialtyAppCache = {};
|
|
const displayNameCache = {};
|
|
|
|
function getAppDisplayName(appName) {
|
|
if (!appName) return appName;
|
|
if (displayNameCache[appName] !== undefined) return displayNameCache[appName];
|
|
|
|
for (const [suiteName, regexArray] of Object.entries(frontendSuites)) {
|
|
for (const regex of regexArray) {
|
|
if (regex.test(appName)) {
|
|
displayNameCache[appName] = suiteName;
|
|
return suiteName;
|
|
}
|
|
}
|
|
}
|
|
displayNameCache[appName] = appName;
|
|
return appName;
|
|
}
|
|
|
|
function getComboApps(deviceApps) {
|
|
let matched = [];
|
|
frontendComboRules.forEach(combo => {
|
|
// Every condition must be satisfied by at least one installed app
|
|
let allMet = combo.condRegexes.every(reg =>
|
|
deviceApps.some(a => a.Name && reg.test(a.Name))
|
|
);
|
|
if (allMet) {
|
|
matched.push({
|
|
Name: combo.label,
|
|
Version: "Combo Rule",
|
|
Publisher: "Detected Combination",
|
|
Source: "Combo Rule",
|
|
InstallDate: ""
|
|
});
|
|
}
|
|
});
|
|
return matched;
|
|
}
|
|
|
|
function getConsolidatedSpecialtyApps(deviceApps, deviceName) {
|
|
let specialty = deviceApps.filter(a => isSpecialtyApp(a.Name, deviceName));
|
|
let consolidated = [];
|
|
let seenSuites = new Set();
|
|
|
|
specialty.forEach(app => {
|
|
let suiteName = getAppDisplayName(app.Name);
|
|
if (suiteName !== app.Name) {
|
|
if (!seenSuites.has(suiteName)) {
|
|
seenSuites.add(suiteName);
|
|
consolidated.push({
|
|
Name: suiteName,
|
|
Version: "Multiple",
|
|
Publisher: "Suite Collection",
|
|
Source: "Various Component Apps",
|
|
InstallDate: ""
|
|
});
|
|
}
|
|
} else {
|
|
consolidated.push(app);
|
|
}
|
|
});
|
|
|
|
// Inject synthetic combo apps (dedupe by name)
|
|
getComboApps(deviceApps).forEach(c => {
|
|
if (!consolidated.some(x => x.Name === c.Name)) consolidated.push(c);
|
|
});
|
|
|
|
consolidated.sort((a, b) => a.Name.localeCompare(b.Name));
|
|
return consolidated;
|
|
}
|
|
|
|
function inlineCopyBtn(text) {
|
|
if(!text || text === "N/A" || text === "---") return "";
|
|
return ` <span class="copy-row-btn" style="width:14px; height:14px; margin-left: 5px; opacity:0.6;" onclick="fallbackCopyTextToClipboard('${text.replace(/'/g, "\\'")}'); showCopySuccess(this, false); event.stopPropagation();" title="Copy">${COPY_SVG}</span>`;
|
|
}
|
|
|
|
function updateTotalCounts() {
|
|
let allRows = document.querySelectorAll('.master-table tbody tr.clickable-row');
|
|
let allVis = Array.from(allRows).filter(tr => tr.style.display !== 'none' && tr.closest('#all-devices')).length;
|
|
let c = document.getElementById('allDevicesCount');
|
|
if(c) c.innerText = `(${allVis})`;
|
|
|
|
let swRows = document.querySelectorAll('#tbody-software tr');
|
|
let swVis = Array.from(swRows).filter(tr => tr.style.display !== 'none').length;
|
|
let sc = document.getElementById('softwareCount');
|
|
if(sc) sc.innerText = `(${swVis})`;
|
|
let sgc = document.getElementById('softwareGroupCount');
|
|
if(sgc) sgc.innerText = `(${swVis})`;
|
|
}
|
|
|
|
let globalDataLastUpdated = "Never";
|
|
let globalDataVersion = "";
|
|
let globalTrackedHash = "";
|
|
|
|
const groupsArray = ["No Data", "Not Started", "Backup In Progress", "Backup Completed", "Restore In Progress", "Completed", "Not Associated"];
|
|
|
|
function updateThemeBtn() {
|
|
document.getElementById('theme-btn').innerHTML = currentTheme === 'dark' ? `${SUN_SVG} <span class="theme-text">Light Mode</span>` : `${MOON_SVG} <span class="theme-text">Dark Mode</span>`;
|
|
}
|
|
|
|
function toggleTheme() {
|
|
currentTheme = currentTheme === 'dark' ? 'light' : 'dark';
|
|
document.documentElement.setAttribute('data-theme', currentTheme);
|
|
localStorage.setItem('usmt_theme', currentTheme);
|
|
updateThemeBtn();
|
|
renderMasterList();
|
|
renderSoftwareTable();
|
|
renderCustomTable();
|
|
myStacks.forEach(s => refreshStackTable(s.id));
|
|
}
|
|
|
|
function getPingColor(statusStr) {
|
|
if (!statusStr) return "";
|
|
if (statusStr.includes("Online")) return themes[currentTheme].pingOnline;
|
|
if (statusStr.includes("Timed Out")) return themes[currentTheme].pingTimeout;
|
|
if (statusStr.includes("Not Found")) return themes[currentTheme].pingNotFound;
|
|
return "";
|
|
}
|
|
|
|
function isSpecialtyApp(appName, deviceName) {
|
|
if (!appName) return false;
|
|
const cacheKey = deviceName ? `${deviceName}::${appName}` : appName;
|
|
if (specialtyAppCache[cacheKey] !== undefined) return specialtyAppCache[cacheKey];
|
|
|
|
// 1. Check Global Blacklist
|
|
for (let regex of frontendBlacklist) {
|
|
if (regex.test(appName)) {
|
|
specialtyAppCache[cacheKey] = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// 2. Check Device-Specific Rules
|
|
if (deviceName) {
|
|
for (let rule of frontendDeviceRules) {
|
|
if (rule.deviceRegex.test(deviceName)) {
|
|
for (let appReg of rule.appRegexes) {
|
|
if (appReg.test(appName)) {
|
|
specialtyAppCache[cacheKey] = false;
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
specialtyAppCache[cacheKey] = true;
|
|
return true;
|
|
}
|
|
|
|
document.addEventListener('mousedown', function(e) {
|
|
const cell = e.target.closest('td, th');
|
|
if (!cell) return;
|
|
const tr = cell.closest('tr');
|
|
if (!tr) return;
|
|
const table = cell.closest('table');
|
|
if (!table) return;
|
|
|
|
if (tr.classList.contains('filter-row') || table.classList.contains('details-table')) {
|
|
table.removeAttribute('data-sel-col');
|
|
return;
|
|
}
|
|
|
|
const colIdx = Array.from(tr.children).indexOf(cell);
|
|
table.dataset.selCol = colIdx;
|
|
});
|
|
|
|
function copyHtmlToClipboard(el) {
|
|
const tempDiv = document.createElement('div');
|
|
tempDiv.style.position = 'absolute';
|
|
tempDiv.style.left = '-9999px';
|
|
tempDiv.appendChild(el);
|
|
document.body.appendChild(tempDiv);
|
|
const selection = window.getSelection();
|
|
const range = document.createRange();
|
|
range.selectNodeContents(tempDiv);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
try { document.execCommand('copy'); } catch (err) {}
|
|
selection.removeAllRanges();
|
|
document.body.removeChild(tempDiv);
|
|
}
|
|
|
|
function fallbackCopyTextToClipboard(text) {
|
|
var textArea = document.createElement("textarea");
|
|
textArea.value = text;
|
|
textArea.style.top = "0";
|
|
textArea.style.left = "0";
|
|
textArea.style.position = "fixed";
|
|
document.body.appendChild(textArea);
|
|
textArea.focus();
|
|
textArea.select();
|
|
try { document.execCommand('copy'); } catch (err) {}
|
|
document.body.removeChild(textArea);
|
|
}
|
|
|
|
document.addEventListener('click', function(e) {
|
|
// 1. Inline Code Copy Logic
|
|
if (e.target.tagName === 'CODE' && e.target.closest('#docContent') && !e.target.classList.contains('hljs')) {
|
|
fallbackCopyTextToClipboard(e.target.innerText);
|
|
showToast("Copied to clipboard!");
|
|
return;
|
|
}
|
|
|
|
// 2. Universal Copy Link Logic
|
|
const copyLink = e.target.closest('.copy-trigger');
|
|
if (copyLink) {
|
|
e.preventDefault();
|
|
const valToCopy = copyLink.getAttribute('data-copy');
|
|
|
|
if (valToCopy) {
|
|
fallbackCopyTextToClipboard(valToCopy);
|
|
|
|
// Show different toasts depending on what was copied!
|
|
if (valToCopy.startsWith('\\\\') || /^[A-Za-z]:\\/.test(valToCopy)) {
|
|
showToast("Path Copied! Press Win + R to open.");
|
|
} else {
|
|
showToast("Text Copied!");
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
function showCopySuccess(el, isTableBtn) {
|
|
const originalHTML = el.innerHTML;
|
|
if (isTableBtn) {
|
|
el.innerHTML = `${CHECK_SVG} Table Copied!`;
|
|
} else {
|
|
el.innerHTML = CHECK_SVG;
|
|
}
|
|
setTimeout(() => { el.innerHTML = originalHTML; }, 2000);
|
|
}
|
|
|
|
function showToast(msg) {
|
|
const toast = document.getElementById("toast");
|
|
toast.innerText = msg;
|
|
toast.className = "show";
|
|
setTimeout(() => { toast.className = toast.className.replace("show", ""); }, 2800);
|
|
}
|
|
|
|
function copyTableHeaders(safeG, btn, event) {
|
|
event.stopPropagation();
|
|
const table = document.getElementById(`table-${safeG}`);
|
|
if (!table) return;
|
|
|
|
const tempTable = document.createElement('table');
|
|
tempTable.style.borderCollapse = 'collapse';
|
|
tempTable.style.fontFamily = 'Arial, sans-serif';
|
|
tempTable.style.fontSize = '12px';
|
|
tempTable.setAttribute('border', '1');
|
|
tempTable.setAttribute('cellpadding', '8');
|
|
|
|
const tbody = document.createElement('tbody');
|
|
const newHeaderRow = document.createElement('tr');
|
|
const originalHeaders = table.querySelector('thead').rows[0].cells;
|
|
|
|
for (let i = 0; i < originalHeaders.length; i++) {
|
|
if (originalHeaders[i].innerHTML.includes('Copy Headers')) continue;
|
|
if (originalHeaders[i].classList.contains('no-copy')) continue;
|
|
|
|
const td = document.createElement('td');
|
|
td.innerText = originalHeaders[i].textContent.trim().replace(/↕|↑|↓|✕/g, '').trim();
|
|
td.style.backgroundColor = '#e7e7e7';
|
|
td.style.color = '#000000';
|
|
td.style.padding = '8px';
|
|
td.style.border = '1px solid #dddddd';
|
|
td.style.fontWeight = 'bold';
|
|
newHeaderRow.appendChild(td);
|
|
}
|
|
|
|
tbody.appendChild(newHeaderRow);
|
|
tempTable.appendChild(tbody);
|
|
copyHtmlToClipboard(tempTable);
|
|
showCopySuccess(btn, false);
|
|
}
|
|
|
|
function copyColData(safeG, colIdx, btn, event) {
|
|
event.stopPropagation();
|
|
const table = document.getElementById(`table-${safeG}`);
|
|
const tbody = document.getElementById(`tbody-${safeG}`);
|
|
if (!tbody || !table) return;
|
|
|
|
const rows = Array.from(tbody.querySelectorAll('tr')).filter(tr => tr.style.display !== 'none');
|
|
const rowData = rows.map(tr => tr.cells[colIdx].textContent.trim());
|
|
|
|
const data = rowData.join('\n');
|
|
fallbackCopyTextToClipboard(data);
|
|
showCopySuccess(btn, false);
|
|
}
|
|
|
|
function copyRowData(btn, event) {
|
|
event.stopPropagation();
|
|
const tr = btn.closest('tr');
|
|
|
|
const tempTable = document.createElement('table');
|
|
tempTable.style.borderCollapse = 'collapse';
|
|
tempTable.style.fontFamily = 'Arial, sans-serif';
|
|
tempTable.style.fontSize = '12px';
|
|
tempTable.setAttribute('border', '1');
|
|
tempTable.setAttribute('cellpadding', '8');
|
|
|
|
const tbody = document.createElement('tbody');
|
|
const newRow = document.createElement('tr');
|
|
|
|
newRow.style.backgroundColor = tr.style.backgroundColor;
|
|
newRow.style.color = window.getComputedStyle(tr).color;
|
|
|
|
for (let i = 0; i < tr.cells.length; i++) {
|
|
if (tr.cells[i].classList.contains('no-copy') || tr.cells[i].outerHTML.includes('copyRowData')) continue;
|
|
|
|
const td = document.createElement('td');
|
|
td.innerText = tr.cells[i].textContent.trim();
|
|
td.style.padding = '8px';
|
|
td.style.border = '1px solid #dddddd';
|
|
newRow.appendChild(td);
|
|
}
|
|
|
|
tbody.appendChild(newRow);
|
|
tempTable.appendChild(tbody);
|
|
copyHtmlToClipboard(tempTable);
|
|
showCopySuccess(btn, false);
|
|
}
|
|
|
|
function copyTableRich(safeG, btn) {
|
|
const table = document.getElementById(`table-${safeG}`);
|
|
if (!table) return;
|
|
|
|
const tempTable = document.createElement('table');
|
|
tempTable.style.borderCollapse = 'collapse';
|
|
tempTable.style.fontFamily = 'Arial, sans-serif';
|
|
tempTable.style.fontSize = '12px';
|
|
tempTable.style.width = '100%';
|
|
tempTable.setAttribute('border', '1');
|
|
tempTable.setAttribute('cellpadding', '8');
|
|
|
|
const thead = document.createElement('thead');
|
|
const newHeaderRow = document.createElement('tr');
|
|
const originalHeaders = table.querySelector('thead').rows[0].cells;
|
|
|
|
for (let i = 0; i < originalHeaders.length; i++) {
|
|
if (originalHeaders[i].classList.contains('no-copy') || originalHeaders[i].outerHTML.includes('copyTableHeaders')) continue;
|
|
|
|
const th = document.createElement('th');
|
|
th.innerText = originalHeaders[i].textContent.trim().replace(/↕|↑|↓|✕/g, '').trim();
|
|
th.style.backgroundColor = '#e7e7e7';
|
|
th.style.color = '#000000';
|
|
th.style.padding = '8px';
|
|
th.style.border = '1px solid #dddddd';
|
|
th.style.textAlign = 'left';
|
|
th.style.fontWeight = 'bold';
|
|
newHeaderRow.appendChild(th);
|
|
}
|
|
thead.appendChild(newHeaderRow);
|
|
tempTable.appendChild(thead);
|
|
|
|
const tbody = document.createElement('tbody');
|
|
const originalRows = table.querySelector('tbody').querySelectorAll('tr');
|
|
|
|
originalRows.forEach(tr => {
|
|
if (tr.style.display === 'none') return;
|
|
|
|
const newRow = document.createElement('tr');
|
|
|
|
newRow.style.backgroundColor = tr.style.backgroundColor;
|
|
newRow.style.color = window.getComputedStyle(tr).color;
|
|
|
|
for (let i = 0; i < tr.cells.length; i++) {
|
|
if (tr.cells[i].classList.contains('no-copy') || tr.cells[i].outerHTML.includes('copyRowData')) continue;
|
|
|
|
const td = document.createElement('td');
|
|
td.innerText = tr.cells[i].textContent.trim();
|
|
td.style.padding = '8px';
|
|
td.style.border = '1px solid #dddddd';
|
|
newRow.appendChild(td);
|
|
}
|
|
tbody.appendChild(newRow);
|
|
});
|
|
|
|
tempTable.appendChild(tbody);
|
|
copyHtmlToClipboard(tempTable);
|
|
showCopySuccess(btn, true);
|
|
}
|
|
|
|
function copyLocationInfo(safeG, btn) {
|
|
const table = document.getElementById(`table-${safeG}`);
|
|
if (!table) return;
|
|
|
|
const tbody = table.querySelector('tbody');
|
|
const rows = tbody.querySelectorAll('tr');
|
|
let dataToCopy = [];
|
|
|
|
// 1. Gather visible data and cross-reference with advancedDataCache
|
|
rows.forEach(tr => {
|
|
if (tr.style.display === 'none' || tr.classList.contains('filter-row')) return;
|
|
|
|
const deviceName = tr.cells[0].textContent.trim();
|
|
const advData = advancedDataCache[deviceName] || {};
|
|
|
|
dataToCopy.push({
|
|
device: deviceName,
|
|
rm: advData["Location"] || 'N/A',
|
|
vanity: advData["Vanity"] || 'N/A',
|
|
building: advData["Building"] || 'N/A',
|
|
department: advData["Department"] || 'N/A',
|
|
lastUser: advData["LastUser"] || 'N/A'
|
|
});
|
|
});
|
|
|
|
// 2. Sort by Building, then Naturally by Room Number
|
|
dataToCopy.sort((a, b) => {
|
|
const bldgA = a.building.toUpperCase();
|
|
const bldgB = b.building.toUpperCase();
|
|
if (bldgA < bldgB) return -1;
|
|
if (bldgA > bldgB) return 1;
|
|
|
|
return a.rm.localeCompare(b.rm, undefined, { numeric: true, sensitivity: 'base' });
|
|
});
|
|
|
|
// 3. Construct the HTML payload for the clipboard
|
|
const tempTable = document.createElement('table');
|
|
tempTable.style.borderCollapse = 'collapse';
|
|
tempTable.style.fontFamily = 'Arial, sans-serif';
|
|
tempTable.style.fontSize = '12px';
|
|
tempTable.style.width = '100%';
|
|
tempTable.setAttribute('border', '1');
|
|
tempTable.setAttribute('cellpadding', '8');
|
|
|
|
const thead = document.createElement('thead');
|
|
const newHeaderRow = document.createElement('tr');
|
|
const headers = ["Device Name", "Rm #", "Vanity Rm #", "Building", "Department", "Last User"];
|
|
|
|
headers.forEach(hText => {
|
|
const th = document.createElement('th');
|
|
th.innerText = hText;
|
|
th.style.backgroundColor = '#e7e7e7';
|
|
th.style.color = '#000000';
|
|
th.style.padding = '8px';
|
|
th.style.border = '1px solid #dddddd';
|
|
th.style.textAlign = 'left';
|
|
th.style.fontWeight = 'bold';
|
|
newHeaderRow.appendChild(th);
|
|
});
|
|
thead.appendChild(newHeaderRow);
|
|
tempTable.appendChild(thead);
|
|
|
|
const tempTbody = document.createElement('tbody');
|
|
dataToCopy.forEach(row => {
|
|
const newRow = document.createElement('tr');
|
|
Object.values(row).forEach(val => {
|
|
const td = document.createElement('td');
|
|
td.innerText = val;
|
|
td.style.padding = '8px';
|
|
td.style.border = '1px solid #dddddd';
|
|
newRow.appendChild(td);
|
|
});
|
|
tempTbody.appendChild(newRow);
|
|
});
|
|
tempTable.appendChild(tempTbody);
|
|
|
|
// 4. Send to clipboard and trigger the existing checkmark UI
|
|
copyHtmlToClipboard(tempTable);
|
|
showCopySuccess(btn, true);
|
|
}
|
|
|
|
function toggleMultiSelect(group) {
|
|
let containerId = `tags-options-${group}`; // Default fallback for tags
|
|
|
|
if (group === 'missing') {
|
|
containerId = 'missing-options-software';
|
|
} else if (group === 'status') {
|
|
containerId = 'status-options-software';
|
|
} else if (group.includes('-')) {
|
|
// dynamically split the IDs mapped in the HTML
|
|
const parts = group.split('-');
|
|
const prefix = parts[0];
|
|
const safeG = parts.slice(1).join('-');
|
|
|
|
if (['srcping', 'destping', 'usmt'].includes(prefix)) {
|
|
containerId = `${prefix}-options-${safeG}`;
|
|
}
|
|
}
|
|
|
|
const container = document.getElementById(containerId);
|
|
if (!container) return;
|
|
|
|
const isOpening = container.style.display === 'none' || container.style.display === '';
|
|
|
|
document.querySelectorAll('.table-wrapper, .stack-box').forEach(w => {
|
|
w.style.paddingBottom = '';
|
|
w.style.overflow = '';
|
|
});
|
|
document.querySelectorAll('.options-container').forEach(c => c.style.display = 'none');
|
|
|
|
if (isOpening) {
|
|
container.style.display = 'block';
|
|
const wrapper = container.closest('.table-wrapper') || container.closest('.stack-box');
|
|
if (wrapper) {
|
|
wrapper.style.overflow = 'visible';
|
|
}
|
|
}
|
|
}
|
|
|
|
function renameStack(id, newName) {
|
|
const stack = myStacks.find(s => s.id === id);
|
|
if (stack) {
|
|
stack.name = newName;
|
|
localStorage.setItem('usmt_workbench_stacks', JSON.stringify(myStacks));
|
|
}
|
|
}
|
|
|
|
function filterMultiSelect(input) {
|
|
const term = input.value.toUpperCase();
|
|
const labels = input.parentElement.querySelectorAll('.option-label');
|
|
labels.forEach(lbl => {
|
|
const txt = lbl.textContent.toUpperCase();
|
|
lbl.style.display = txt.includes(term) ? 'flex' : 'none';
|
|
});
|
|
}
|
|
|
|
document.addEventListener('click', function(e) {
|
|
if (!e.target.closest('.multi-select')) {
|
|
document.querySelectorAll('.table-wrapper, .stack-box').forEach(w => {
|
|
w.style.paddingBottom = '';
|
|
w.style.overflow = ''; // Restores horizontal scrolling when menu closes
|
|
});
|
|
document.querySelectorAll('.options-container').forEach(c => c.style.display = 'none');
|
|
}
|
|
if (!e.target.closest('.col-filter-popup') && !e.target.closest('.col-filter-btn')) {
|
|
if (typeof closeColFilterPopup === 'function') closeColFilterPopup();
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keydown', function(event) {
|
|
if (event.key === "Escape") {
|
|
closeModal('modal');
|
|
closeModal('detailsModal');
|
|
closeModal('syncModal');
|
|
}
|
|
});
|
|
|
|
function toggleMobileMenu() {
|
|
document.getElementById('nav-links-menu').classList.toggle('show-menu');
|
|
}
|
|
|
|
function switchPage(id) {
|
|
// Close the mobile menu automatically when a page is selected
|
|
document.getElementById('nav-links-menu').classList.remove('show-menu');
|
|
|
|
document.querySelectorAll('.page').forEach(p => p.style.display = 'none');
|
|
document.querySelectorAll('.nav-btn').forEach(b => b.classList.remove('active'));
|
|
document.getElementById(id).style.display = 'block';
|
|
|
|
let btnId = `btn-${id.split('-')[0]}`;
|
|
if (id === 'workbench') btnId = 'btn-workbench';
|
|
if (id === 'tracked-devices') btnId = 'btn-tracked';
|
|
if (id === 'all-devices') btnId = 'btn-all';
|
|
if (id === 'software') btnId = 'btn-software';
|
|
if (id === 'custom') btnId = 'btn-custom';
|
|
if (id === 'docs') btnId = 'btn-docs';
|
|
|
|
document.getElementById(btnId).classList.add('active');
|
|
|
|
if (id === 'tracked-devices') { loadAllTags(); }
|
|
if (id === 'software') { renderSoftwareTable(); }
|
|
if (id === 'custom') { renderCustomTable(); }
|
|
}
|
|
|
|
// --- IMAGE MANAGER LOGIC ---
|
|
async function openImageManager() {
|
|
document.getElementById('imageModal').style.display = 'block';
|
|
await refreshImageList();
|
|
}
|
|
|
|
let globalImageData = [];
|
|
|
|
async function refreshImageList() {
|
|
const tbody = document.getElementById('imageListBody');
|
|
tbody.innerHTML = `<tr><td colspan="3" style="text-align: center; padding: 15px; color: var(--text-muted);">Loading...</td></tr>`;
|
|
|
|
try {
|
|
const res = await fetch('/list-images?t=' + Date.now());
|
|
if (res.ok) {
|
|
let images = await res.json();
|
|
|
|
// Fallback formatting if the backend hasn't refreshed yet
|
|
if (typeof images === 'string') images = [{Name: images, Date: new Date().toISOString()}];
|
|
if (images.length > 0 && typeof images[0] === 'string') {
|
|
images = images.map(img => ({Name: img, Date: new Date().toISOString()}));
|
|
}
|
|
|
|
globalImageData = Array.isArray(images) ? images : [images];
|
|
renderImageTable();
|
|
}
|
|
} catch (e) {
|
|
tbody.innerHTML = `<tr><td colspan="3" style="text-align: center; padding: 15px; color: #e74c3c;">Failed to load images.</td></tr>`;
|
|
}
|
|
}
|
|
|
|
function renderImageTable() {
|
|
const tbody = document.getElementById('imageListBody');
|
|
const search = document.getElementById('imgSearchInput').value.toLowerCase();
|
|
const sortMode = document.getElementById('imgSortSelect').value;
|
|
|
|
if (globalImageData.length === 0) {
|
|
tbody.innerHTML = `<tr><td colspan="3" style="text-align: center; padding: 15px; color: var(--text-muted);">No images found in \\Documentation\\images</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
// Filter
|
|
let filtered = globalImageData.filter(img => img.Name.toLowerCase().includes(search));
|
|
|
|
// Sort
|
|
filtered.sort((a, b) => {
|
|
if (sortMode === 'nameAsc') return a.Name.localeCompare(b.Name);
|
|
if (sortMode === 'nameDesc') return b.Name.localeCompare(a.Name);
|
|
if (sortMode === 'dateAsc') return new Date(a.Date) - new Date(b.Date);
|
|
if (sortMode === 'dateDesc') return new Date(b.Date) - new Date(a.Date);
|
|
return 0;
|
|
});
|
|
|
|
if (filtered.length === 0) {
|
|
tbody.innerHTML = `<tr><td colspan="4" style="text-align: center; padding: 15px; color: var(--text-muted);">No matches found.</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
// Uncheck the 'select all' box whenever we search or re-render
|
|
const selectAllCb = document.getElementById('selectAllImagesCb');
|
|
if (selectAllCb) selectAllCb.checked = false;
|
|
|
|
tbody.innerHTML = filtered.map(img => {
|
|
const dObj = new Date(img.Date);
|
|
const dateStr = isNaN(dObj) ? "Unknown" : dObj.toLocaleDateString();
|
|
|
|
return `
|
|
<tr style="border-bottom: 1px solid var(--border-light);">
|
|
<td style="padding: 15px; text-align: center;">
|
|
<input type="checkbox" class="img-cb" value="${img.Name}" style="cursor: pointer;">
|
|
</td>
|
|
<td style="padding: 15px; color: var(--text-main); word-break: break-all; display: flex; align-items: center; gap: 20px;">
|
|
<img src="/Documentation/images/${encodeURIComponent(img.Name)}" style="width: 120px; height: 80px; object-fit: contain; background: rgba(0,0,0,0.2); border-radius: 4px; border: 1px solid var(--border-light);" loading="lazy" />
|
|
<b style="font-size: 1.1em;">${img.Name}</b>
|
|
</td>
|
|
<td style="padding: 15px; color: var(--text-muted);">${dateStr}</td>
|
|
<td style="padding: 15px; text-align: right; white-space: nowrap;">
|
|
<button class="nav-btn" style="padding: 6px 12px; border: 1px solid var(--border-color); margin-right: 5px;" onclick="copyImgMarkdown('${img.Name}')" title="Copy Markdown">Copy</button>
|
|
<button class="nav-btn" style="padding: 6px 12px; border: 1px solid var(--border-color); margin-right: 5px; color: #f1c40f;" onclick="renameImagePrompt('${img.Name}')" title="Rename">Rename</button>
|
|
<button class="del-stack" style="padding: 6px 12px; border: 1px solid #e74c3c;" onclick="deleteImage('${img.Name}')" title="Delete">Delete</button>
|
|
</td>
|
|
</tr>
|
|
`}).join('');
|
|
}
|
|
|
|
async function uploadImage() {
|
|
const fileInput = document.getElementById('imageUploadInput');
|
|
const files = fileInput.files;
|
|
if (!files || files.length === 0) return showToast("Please select at least one image.");
|
|
|
|
const btn = document.getElementById('btn-upload-img');
|
|
btn.disabled = true;
|
|
btn.innerText = "Uploading...";
|
|
|
|
let successCount = 0;
|
|
let failCount = 0;
|
|
|
|
for (let i = 0; i < files.length; i++) {
|
|
const file = files[i];
|
|
|
|
// Wrap the file reader in a promise so we can await it inside the loop
|
|
const base64String = await new Promise((resolve) => {
|
|
const reader = new FileReader();
|
|
reader.onloadend = () => resolve(reader.result.split(',')[1]);
|
|
reader.readAsDataURL(file);
|
|
});
|
|
|
|
try {
|
|
const res = await fetch('/upload-image', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ filename: file.name, data: base64String })
|
|
});
|
|
|
|
if (res.ok) {
|
|
successCount++;
|
|
} else {
|
|
failCount++;
|
|
}
|
|
} catch (e) {
|
|
failCount++;
|
|
}
|
|
}
|
|
|
|
if (failCount === 0) {
|
|
showToast(`Successfully uploaded ${successCount} image(s)!`);
|
|
} else {
|
|
showToast(`Uploaded ${successCount}, Failed ${failCount}.`);
|
|
}
|
|
|
|
fileInput.value = ""; // Clear input
|
|
await refreshImageList();
|
|
|
|
btn.disabled = false;
|
|
btn.innerText = "Upload";
|
|
}
|
|
|
|
// Custom Themed Modals to replace native browser popups
|
|
function customPrompt(title, message, defaultValue) {
|
|
return new Promise((resolve) => {
|
|
const modal = document.getElementById('promptModal');
|
|
document.getElementById('promptTitle').innerText = title;
|
|
document.getElementById('promptMessage').innerText = message;
|
|
const input = document.getElementById('promptInput');
|
|
|
|
input.style.display = 'block';
|
|
input.value = defaultValue || '';
|
|
modal.style.display = 'block';
|
|
input.focus();
|
|
input.select();
|
|
|
|
const actionBtn = document.getElementById('promptActionBtn');
|
|
const cancelBtn = document.getElementById('promptCancelBtn');
|
|
actionBtn.innerText = "Rename";
|
|
actionBtn.className = "add-btn";
|
|
|
|
const cleanup = () => {
|
|
modal.style.display = 'none';
|
|
actionBtn.onclick = null; cancelBtn.onclick = null; input.onkeydown = null;
|
|
};
|
|
|
|
actionBtn.onclick = () => { cleanup(); resolve(input.value); };
|
|
cancelBtn.onclick = () => { cleanup(); resolve(null); };
|
|
input.onkeydown = (e) => { if (e.key === 'Enter') actionBtn.click(); if (e.key === 'Escape') cancelBtn.click(); };
|
|
});
|
|
}
|
|
|
|
function customConfirm(title, message, confirmText, isDestructive) {
|
|
return new Promise((resolve) => {
|
|
const modal = document.getElementById('promptModal');
|
|
document.getElementById('promptTitle').innerText = title;
|
|
document.getElementById('promptMessage').innerText = message;
|
|
document.getElementById('promptInput').style.display = 'none'; // Hide input box
|
|
modal.style.display = 'block';
|
|
|
|
const actionBtn = document.getElementById('promptActionBtn');
|
|
const cancelBtn = document.getElementById('promptCancelBtn');
|
|
actionBtn.innerText = confirmText;
|
|
actionBtn.className = isDestructive ? "del-btn" : "add-btn";
|
|
|
|
const cleanup = () => {
|
|
modal.style.display = 'none';
|
|
actionBtn.onclick = null; cancelBtn.onclick = null;
|
|
};
|
|
|
|
actionBtn.onclick = () => { cleanup(); resolve(true); };
|
|
cancelBtn.onclick = () => { cleanup(); resolve(false); };
|
|
});
|
|
}
|
|
|
|
async function renameImagePrompt(oldName) {
|
|
const newName = await customPrompt("Rename Image", `Enter new filename for ${oldName} (include extension):`, oldName);
|
|
if (!newName || newName === oldName) return;
|
|
|
|
try {
|
|
const res = await fetch('/rename-image', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ oldName: oldName, newName: newName })
|
|
});
|
|
|
|
if (res.ok) {
|
|
showToast("Image renamed!");
|
|
await refreshImageList();
|
|
// Surgically update the text editor without destroying unsaved typing
|
|
hotPatchOpenDocument(oldName, newName);
|
|
} else {
|
|
showToast("Failed to rename.");
|
|
}
|
|
} catch (e) {
|
|
showToast("Server error during rename.");
|
|
}
|
|
}
|
|
|
|
async function deleteImage(filename) {
|
|
const confirmed = await customConfirm("Delete Image", `Are you sure you want to permanently delete "${filename}"?`, "Delete", true);
|
|
if (!confirmed) return;
|
|
|
|
try {
|
|
const res = await fetch('/delete-image', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ filename: filename })
|
|
});
|
|
|
|
if (res.ok) {
|
|
showToast("Image deleted!");
|
|
await refreshImageList();
|
|
} else {
|
|
showToast("Failed to delete.");
|
|
}
|
|
} catch (e) {
|
|
showToast("Server error during deletion.");
|
|
}
|
|
}
|
|
|
|
// --- BULK ACTION LOGIC ---
|
|
function toggleAllImages(cb) {
|
|
document.querySelectorAll('.img-cb').forEach(box => box.checked = cb.checked);
|
|
}
|
|
|
|
function getSelectedImages() {
|
|
return Array.from(document.querySelectorAll('.img-cb:checked')).map(cb => cb.value);
|
|
}
|
|
|
|
function bulkCopyImages() {
|
|
const selected = getSelectedImages();
|
|
if (selected.length === 0) return showToast("No images selected.");
|
|
|
|
const markdownBlocks = selected.map(filename => {
|
|
const safeName = encodeURIComponent(filename);
|
|
return `![${filename.split('.')[0]}](images/${safeName})`;
|
|
});
|
|
|
|
fallbackCopyTextToClipboard(markdownBlocks.join('\n\n'));
|
|
showToast(`Copied markdown for ${selected.length} image(s)!`);
|
|
}
|
|
|
|
async function bulkRenameImages() {
|
|
const selected = getSelectedImages();
|
|
if (selected.length === 0) return showToast("No images selected.");
|
|
|
|
const baseName = await customPrompt("Bulk Rename", `Enter a base name to rename ${selected.length} images (e.g. "Command Client"):`);
|
|
if (!baseName || !baseName.trim()) return;
|
|
|
|
const safeBase = baseName.trim();
|
|
let successCount = 0;
|
|
let failCount = 0;
|
|
|
|
const btn = document.querySelector('button[onclick="bulkRenameImages()"]');
|
|
const origText = btn.innerText;
|
|
btn.innerText = "Renaming...";
|
|
btn.disabled = true;
|
|
|
|
for (let i = 0; i < selected.length; i++) {
|
|
const oldName = selected[i];
|
|
const ext = oldName.substring(oldName.lastIndexOf('.'));
|
|
const newName = `${safeBase} ${i + 1}${ext}`;
|
|
|
|
try {
|
|
const res = await fetch('/rename-image', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ oldName: oldName, newName: newName })
|
|
});
|
|
if (res.ok) {
|
|
successCount++;
|
|
hotPatchOpenDocument(oldName, newName);
|
|
}
|
|
else failCount++;
|
|
} catch(e) {
|
|
failCount++;
|
|
}
|
|
}
|
|
|
|
if (failCount === 0) showToast(`Successfully renamed ${successCount} image(s)!`);
|
|
else showToast(`Renamed ${successCount}, Failed ${failCount}.`);
|
|
|
|
btn.innerText = origText;
|
|
btn.disabled = false;
|
|
await refreshImageList();
|
|
}
|
|
|
|
async function bulkDeleteImages() {
|
|
const selected = getSelectedImages();
|
|
if (selected.length === 0) return showToast("No images selected.");
|
|
|
|
const confirmed = await customConfirm("Bulk Delete", `Are you sure you want to permanently delete ${selected.length} image(s)?`, "Delete All", true);
|
|
if (!confirmed) return;
|
|
|
|
const btn = document.querySelector('button[onclick="bulkDeleteImages()"]');
|
|
const origText = btn.innerText;
|
|
btn.innerText = "Deleting...";
|
|
btn.disabled = true;
|
|
|
|
let successCount = 0;
|
|
let failCount = 0;
|
|
|
|
for (const filename of selected) {
|
|
try {
|
|
const res = await fetch('/delete-image', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ filename: filename })
|
|
});
|
|
if (res.ok) successCount++;
|
|
else failCount++;
|
|
} catch(e) {
|
|
failCount++;
|
|
}
|
|
}
|
|
|
|
if (failCount === 0) showToast(`Successfully deleted ${successCount} image(s)!`);
|
|
else showToast(`Deleted ${successCount}, Failed ${failCount}.`);
|
|
|
|
btn.innerText = origText;
|
|
btn.disabled = false;
|
|
await refreshImageList();
|
|
}
|
|
|
|
function copyImgMarkdown(filename) {
|
|
// URL Encode the filename so Markdown doesn't choke on the spaces
|
|
const safeName = encodeURIComponent(filename);
|
|
const markdown = `![${filename.split('.')[0]}](images/${safeName})`;
|
|
fallbackCopyTextToClipboard(markdown);
|
|
showToast("Markdown copied to clipboard!");
|
|
}
|
|
|
|
function hotPatchOpenDocument(oldName, newName) {
|
|
const editor = document.getElementById('docEditor');
|
|
if (!editor || !editor.value) return;
|
|
|
|
let content = editor.value;
|
|
let changed = false;
|
|
|
|
const encodedOld = encodeURIComponent(oldName);
|
|
const encodedNew = encodeURIComponent(newName);
|
|
|
|
// Escape special regex characters in the filenames so they don't break the search
|
|
const escapeRegEx = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
// Find and replace unencoded paths (e.g., images/My Photo.png)
|
|
const rawRegex = new RegExp("images/" + escapeRegEx(oldName), "g");
|
|
if (rawRegex.test(content)) {
|
|
content = content.replace(rawRegex, "images/" + newName);
|
|
changed = true;
|
|
}
|
|
|
|
// Find and replace encoded paths (e.g., images/My%20Photo.png)
|
|
const encRegex = new RegExp("images/" + escapeRegEx(encodedOld), "g");
|
|
if (encRegex.test(content)) {
|
|
content = content.replace(encRegex, "images/" + encodedNew);
|
|
changed = true;
|
|
}
|
|
|
|
// If we found the old image in your text, surgically swap it!
|
|
if (changed) {
|
|
editor.value = content;
|
|
|
|
// If you happen to be in Read Mode, instantly update the visual preview too
|
|
const preview = document.getElementById('docContent');
|
|
if (preview && preview.style.display !== 'none') {
|
|
// Encode spaces ONLY in UNC/Local paths so marked.js doesn't fail to parse them
|
|
let safeText = shieldCode(content).replace(/\[([^\]]+)\]\(((\\\\|[A-Za-z]:\\)[^\)]+)\)/g, (match, title, url) => {
|
|
return `[${title}](${url.replace(/ /g, '%20')})`;
|
|
});
|
|
|
|
// Inject Custom Syntax Badges
|
|
safeText = safeText.replace(/\[\[(Red|Green|Yellow|Blue|Gray):([^\]]+)\]\]/gi, (match, color, label) => {
|
|
return `<span class="tag-badge tag-${color.toLowerCase()}">${label}</span>`;
|
|
});
|
|
|
|
// Inject Universal Copy Links (renders as a normal hyperlink)
|
|
safeText = safeText.replace(/\[\[Copy:([^\]\|]+)(?:\|([^\]]+))?\]\]/gi, (match, p1, p2) => {
|
|
let display = p1.trim();
|
|
let value = p2 ? p2.trim() : display; // If p2 doesn't exist, fallback to display text
|
|
|
|
// Use HTML entities to hide quotes and backslashes from marked.js
|
|
const safeVal = value.replace(/"/g, '"').replace(/'/g, ''').replace(/\\/g, '\');
|
|
const safeDisplay = display.replace(/\\/g, '\');
|
|
|
|
return `<a href="javascript:void(0);" class="copy-trigger" data-copy="${safeVal}" title="Click to copy">${safeDisplay}</a>`;
|
|
});
|
|
|
|
preview.innerHTML = typeof marked !== 'undefined' ? marked.parse(unshieldCode(safeText)) : `<pre>${unshieldCode(safeText)}</pre>`;
|
|
formatCodeBlocks();
|
|
}
|
|
}
|
|
}
|
|
|
|
// PDF export library (~900 KB): fetched the first time someone exports, not on every visit
|
|
function loadHtml2Pdf() {
|
|
if (window.html2pdf) return Promise.resolve();
|
|
return window._html2pdfLoading || (window._html2pdfLoading = new Promise((resolve, reject) => {
|
|
const s = document.createElement('script');
|
|
s.src = '/vendor/html2pdf/html2pdf.bundle.min.js';
|
|
s.onload = resolve;
|
|
s.onerror = () => { window._html2pdfLoading = null; reject(new Error('Could not load the PDF library')); };
|
|
document.head.appendChild(s);
|
|
}));
|
|
}
|
|
|
|
// Start as soon as the page is parsed (not after every image and script has finished, as
|
|
// window.onload would); the timeout lets every module's own DOMContentLoaded setup run first
|
|
document.addEventListener('DOMContentLoaded', () => setTimeout(initDashboard, 0));
|
|
async function initDashboard() {
|
|
updateThemeBtn();
|
|
initMasterTables();
|
|
|
|
// Setup custom marked.js renderers
|
|
const renderer = new marked.Renderer();
|
|
|
|
// Intercept images to ensure the backend routes them properly
|
|
renderer.image = function(href, title, text) {
|
|
let actualHref = (typeof href === 'object' && href !== null) ? href.href : href;
|
|
let actualTitle = (typeof href === 'object' && href !== null) ? href.title : title;
|
|
let actualText = (typeof href === 'object' && href !== null) ? href.text : text;
|
|
|
|
// If it's a relative path to the images folder, point it to the backend documentation route
|
|
if (actualHref && actualHref.startsWith('images/')) {
|
|
actualHref = '/Documentation/' + actualHref;
|
|
}
|
|
|
|
return `<img src="${actualHref}" alt="${actualText || ''}" title="${actualTitle || ''}">`;
|
|
};
|
|
|
|
// Turn on GitHub-style line breaks
|
|
marked.setOptions({ renderer: renderer, breaks: true });
|
|
|
|
document.getElementById('btn-read-mode').innerHTML = READ_SVG;
|
|
document.getElementById('btn-edit-mode').innerHTML = EDIT_SVG;
|
|
document.getElementById('btn-save-doc').innerHTML = `${SAVE_SVG} Save`;
|
|
|
|
// Load side by side; the device data doesn't wait for the article list
|
|
const docsReady = loadDocsList();
|
|
await Promise.all([loadBlacklist(), loadAllTags()]);
|
|
|
|
const savedTech = localStorage.getItem('usmt_tech_id');
|
|
if (savedTech) {
|
|
document.getElementById('techId').value = savedTech;
|
|
await loadTechRoster();
|
|
}
|
|
|
|
startDashboard();
|
|
|
|
// Check if we were opened in a new tab specifically to view a document
|
|
await docsReady;
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const targetDoc = urlParams.get('doc');
|
|
if (targetDoc && availableDocs.includes(targetDoc)) {
|
|
await openDoc(targetDoc);
|
|
}
|
|
}
|
|
|
|
// Code spans and fenced blocks are set aside while the [[Badge:..]] / [[Copy:..]] / UNC
|
|
// path rewrites run, so an article can show that syntax literally inside `code`
|
|
const CODE_SHIELD = [];
|
|
function shieldCode(text) {
|
|
CODE_SHIELD.length = 0;
|
|
return String(text).replace(/(```[\s\S]*?```|~~~[\s\S]*?~~~|`[^`\n]+`)/g, m => `\u0000CODE${CODE_SHIELD.push(m) - 1}\u0000`);
|
|
}
|
|
function unshieldCode(text) {
|
|
return String(text).replace(/\u0000CODE(\d+)\u0000/g, (m, i) => CODE_SHIELD[+i]);
|
|
}
|
|
|
|
async function loadDocsList() {
|
|
try {
|
|
const res = await fetch('/get-docs-list?t=' + Date.now());
|
|
if (res.ok) {
|
|
const data = await res.json();
|
|
availableDocs = Array.isArray(data) ? data : [data];
|
|
}
|
|
renderDocsSidebar();
|
|
} catch(e) { console.log("Failed to load docs."); }
|
|
}
|
|
|
|
function renderDocsSidebar() {
|
|
const container = document.getElementById('docList');
|
|
if (!availableDocs || availableDocs.length === 0) {
|
|
container.innerHTML = "<span style='color: var(--text-muted); font-size: 0.9em;'>No markdown files found in .\\Documentation</span>";
|
|
return;
|
|
}
|
|
container.innerHTML = availableDocs.map(doc => `
|
|
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 2px;">
|
|
<span style="cursor: pointer; color: var(--text-muted); opacity: 0.5; transition: 0.2s;" onmouseover="this.style.opacity='1'" onmouseout="this.style.opacity='0.5'" onclick="copyDocLink('${doc.replace(/'/g, "\\'")}')" title="Copy Article Link">
|
|
<svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M451.5 160C434.9 160 418.8 164.5 404.7 172.7C388.9 156.7 370.5 143.3 350.2 133.2C378.4 109.2 414.3 96 451.5 96C537.9 96 608 166 608 252.5C608 294 591.5 333.8 562.2 363.1L491.1 434.2C461.8 463.5 422 480 380.5 480C294.1 480 224 410 224 323.5C224 322 224 320.5 224.1 319C224.6 301.3 239.3 287.4 257 287.9C274.7 288.4 288.6 303.1 288.1 320.8C288.1 321.7 288.1 322.6 288.1 323.4C288.1 374.5 329.5 415.9 380.6 415.9C405.1 415.9 428.6 406.2 446 388.8L517.1 317.7C534.4 300.4 544.2 276.8 544.2 252.3C544.2 201.2 502.8 159.8 451.7 159.8zM307.2 237.3C305.3 236.5 303.4 235.4 301.7 234.2C289.1 227.7 274.7 224 259.6 224C235.1 224 211.6 233.7 194.2 251.1L123.1 322.2C105.8 339.5 96 363.1 96 387.6C96 438.7 137.4 480.1 188.5 480.1C205 480.1 221.1 475.7 235.2 467.5C251 483.5 269.4 496.9 289.8 507C261.6 530.9 225.8 544.2 188.5 544.2C102.1 544.2 32 474.2 32 387.7C32 346.2 48.5 306.4 77.8 277.1L148.9 206C178.2 176.7 218 160.2 259.5 160.2C346.1 160.2 416 230.8 416 317.1C416 318.4 416 319.7 416 321C415.6 338.7 400.9 352.6 383.2 352.2C365.5 351.8 351.6 337.1 352 319.4C352 318.6 352 317.9 352 317.1C352 283.4 334 253.8 307.2 237.5z"/></svg>
|
|
</span>
|
|
<a href="#" onclick="openDoc('${doc.replace(/'/g, "\\'")}'); return false;" style="color: #3498db; text-decoration: none; font-weight: 500;">${doc}</a>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
function copyDocLink(docName) {
|
|
// Encode spaces to %20 to make it perfectly markdown safe
|
|
const safeName = encodeURIComponent(docName);
|
|
const markdown = `[${docName}](?doc=${safeName})`;
|
|
fallbackCopyTextToClipboard(markdown);
|
|
showToast("Article link copied to clipboard!");
|
|
}
|
|
|
|
let currentDocTitle = "";
|
|
|
|
async function openDoc(docName) {
|
|
switchPage('docs');
|
|
currentDocTitle = docName;
|
|
|
|
document.getElementById('docToolbar').style.display = 'flex';
|
|
document.getElementById('docContent').innerHTML = "<span style='color: var(--text-muted);'>Loading...</span>";
|
|
setDocMode('read');
|
|
|
|
try {
|
|
const res = await fetch(`/Documentation/${encodeURIComponent(docName)}.md?t=` + Date.now());
|
|
if (res.ok) {
|
|
const text = await res.text();
|
|
|
|
const lastModHeader = res.headers.get('Last-Modified');
|
|
let dateStr = "Unknown Date";
|
|
if (lastModHeader) {
|
|
const dateObj = new Date(lastModHeader);
|
|
dateStr = dateObj.toLocaleDateString() + ' at ' + dateObj.toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'});
|
|
}
|
|
|
|
document.getElementById('docHeaderInfo').innerHTML = `
|
|
<h2 id="docDisplayTitle" style="margin: 0; color: var(--text-main);">${docName}</h2>
|
|
<input type="text" id="docEditTitle" style="display: none; font-size: 1.5em; font-weight: bold; margin: 0; padding: 5px; width: 100%; border: 1px solid var(--border-color); background: var(--input-bg); color: var(--text-main); border-radius: 4px; box-sizing: border-box;" value="${docName}">
|
|
<span id="docEditDate" style="font-size: 0.8em; color: var(--text-muted); display: block; margin-top: 2px;">Last Edited: ${dateStr}</span>
|
|
`;
|
|
|
|
// Encode spaces ONLY in UNC/Local paths so marked.js doesn't fail to parse them
|
|
let safeText = shieldCode(text).replace(/\[([^\]]+)\]\(((\\\\|[A-Za-z]:\\)[^\)]+)\)/g, (match, title, url) => {
|
|
return `[${title}](${url.replace(/ /g, '%20')})`;
|
|
});
|
|
|
|
// Inject Custom Syntax Badges
|
|
safeText = safeText.replace(/\[\[(Red|Green|Yellow|Blue|Gray):([^\]]+)\]\]/gi, (match, color, label) => {
|
|
return `<span class="tag-badge tag-${color.toLowerCase()}">${label}</span>`;
|
|
});
|
|
|
|
// Inject Universal Copy Links (renders as a normal hyperlink)
|
|
safeText = safeText.replace(/\[\[Copy:([^\]\|]+)(?:\|([^\]]+))?\]\]/gi, (match, p1, p2) => {
|
|
let display = p1.trim();
|
|
let value = p2 ? p2.trim() : display; // If p2 doesn't exist, fallback to display text
|
|
|
|
// Use HTML entities to hide quotes and backslashes from marked.js
|
|
const safeVal = value.replace(/"/g, '"').replace(/'/g, ''').replace(/\\/g, '\');
|
|
const safeDisplay = display.replace(/\\/g, '\');
|
|
|
|
return `<a href="javascript:void(0);" class="copy-trigger" data-copy="${safeVal}" title="Click to copy">${safeDisplay}</a>`;
|
|
});
|
|
|
|
const parsedContent = typeof marked !== 'undefined' ? marked.parse(unshieldCode(safeText)) : `<pre style="white-space: pre-wrap; font-family: inherit;">${unshieldCode(safeText)}</pre>`;
|
|
document.getElementById('docContent').innerHTML = parsedContent;
|
|
|
|
// Keep the actual spaces in the editor so the user doesn't see ugly %20 encoding!
|
|
document.getElementById('docEditor').value = text;
|
|
|
|
formatCodeBlocks();
|
|
} else {
|
|
document.getElementById('docContent').innerHTML = "<span style='color: #e74c3c; font-weight: bold;'>Error: Document not found.</span>";
|
|
document.getElementById('docEditor').value = "";
|
|
}
|
|
} catch(e) {
|
|
document.getElementById('docContent').innerHTML = "<span style='color: #e74c3c; font-weight: bold;'>Error loading documentation.</span>";
|
|
}
|
|
}
|
|
|
|
function setDocMode(mode) {
|
|
const preview = document.getElementById('docContent');
|
|
const editor = document.getElementById('docEditor');
|
|
const btnRead = document.getElementById('btn-read-mode');
|
|
const btnEdit = document.getElementById('btn-edit-mode');
|
|
const btnSave = document.getElementById('btn-save-doc');
|
|
const btnDelete = document.getElementById('btn-delete-doc');
|
|
const displayTitle = document.getElementById('docDisplayTitle');
|
|
const editTitle = document.getElementById('docEditTitle');
|
|
const dateStr = document.getElementById('docEditDate');
|
|
|
|
if (mode === 'edit') {
|
|
preview.style.display = 'none';
|
|
editor.style.display = 'block';
|
|
btnEdit.style.display = 'none';
|
|
btnRead.style.display = 'block';
|
|
const btnPDF = document.getElementById('btn-download-pdf');
|
|
if (btnPDF) btnPDF.style.display = 'none';
|
|
btnSave.style.display = 'flex';
|
|
if (btnDelete) btnDelete.style.display = 'flex';
|
|
if (dateStr) dateStr.style.display = 'none';
|
|
|
|
if (displayTitle) displayTitle.style.display = 'none';
|
|
if (editTitle) {
|
|
editTitle.style.display = 'block';
|
|
editTitle.focus();
|
|
}
|
|
} else {
|
|
preview.style.display = 'block';
|
|
editor.style.display = 'none';
|
|
btnEdit.style.display = 'block';
|
|
btnRead.style.display = 'none';
|
|
btnSave.style.display = 'none';
|
|
if (btnDelete) btnDelete.style.display = 'none';
|
|
const btnPDF = document.getElementById('btn-download-pdf');
|
|
if (btnPDF) btnPDF.style.display = 'flex';
|
|
|
|
if (editTitle) editTitle.style.display = 'none';
|
|
if (displayTitle) {
|
|
displayTitle.style.display = 'block';
|
|
// Only for display logic - we don't permanently set currentDocTitle here yet
|
|
if (editTitle) displayTitle.innerText = editTitle.value.trim() || currentDocTitle;
|
|
}
|
|
|
|
// Real-time render update if they toggled back without saving
|
|
const rawText = editor.value;
|
|
if (rawText) {
|
|
// Encode spaces ONLY in UNC/Local paths so marked.js doesn't fail to parse them
|
|
let safeText = shieldCode(rawText).replace(/\[([^\]]+)\]\(((\\\\|[A-Za-z]:\\)[^\)]+)\)/g, (match, title, url) => {
|
|
return `[${title}](${url.replace(/ /g, '%20')})`;
|
|
});
|
|
|
|
// Inject Custom Syntax Badges
|
|
safeText = safeText.replace(/\[\[(Red|Green|Yellow|Blue|Gray):([^\]]+)\]\]/gi, (match, color, label) => {
|
|
return `<span class="tag-badge tag-${color.toLowerCase()}">${label}</span>`;
|
|
});
|
|
|
|
// Inject Universal Copy Links (renders as a normal hyperlink)
|
|
safeText = safeText.replace(/\[\[Copy:([^\]\|]+)(?:\|([^\]]+))?\]\]/gi, (match, p1, p2) => {
|
|
let display = p1.trim();
|
|
let value = p2 ? p2.trim() : display; // If p2 doesn't exist, fallback to display text
|
|
|
|
// Use HTML entities to hide quotes and backslashes from marked.js
|
|
const safeVal = value.replace(/"/g, '"').replace(/'/g, ''').replace(/\\/g, '\');
|
|
const safeDisplay = display.replace(/\\/g, '\');
|
|
|
|
return `<a href="javascript:void(0);" class="copy-trigger" data-copy="${safeVal}" title="Click to copy">${safeDisplay}</a>`;
|
|
});
|
|
|
|
preview.innerHTML = typeof marked !== 'undefined' ? marked.parse(unshieldCode(safeText)) : `<pre>${unshieldCode(safeText)}</pre>`;
|
|
formatCodeBlocks();
|
|
}
|
|
}
|
|
}
|
|
|
|
function downloadDocAsPDF() {
|
|
const content = document.getElementById('docContent');
|
|
if (!content || !currentDocTitle) return;
|
|
|
|
const clone = content.cloneNode(true);
|
|
|
|
// Inject a style tag directly into the clone for reliable page-break control
|
|
const style = document.createElement('style');
|
|
style.textContent = `
|
|
* { box-sizing: border-box; }
|
|
body, div { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; }
|
|
p, li, td, th, blockquote { orphans: 4; widows: 4; }
|
|
h1, h2, h3, h4, h5, h6 { page-break-after: avoid; page-break-inside: avoid; }
|
|
img { page-break-inside: avoid; page-break-before: auto; display: block; margin: 6px 0; max-width: 100% !important; }
|
|
pre, blockquote, table { page-break-inside: avoid; }
|
|
p, li { page-break-inside: avoid; }
|
|
tr { page-break-inside: avoid; }
|
|
`;
|
|
clone.prepend(style);
|
|
|
|
clone.style.cssText = 'font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif; font-size: 13px; line-height: 1.6; color: #333; padding: 10px 15px;';
|
|
|
|
clone.querySelectorAll('pre, code').forEach(el => {
|
|
el.style.cssText = 'background:#f6f8fa; border:1px solid #ddd; border-radius:4px; padding:0.2em 0.4em; font-family:monospace; font-size:0.9em; white-space:pre-wrap; word-break:break-all;';
|
|
});
|
|
clone.querySelectorAll('pre').forEach(el => { el.style.padding = '12px'; });
|
|
clone.querySelectorAll('table').forEach(el => {
|
|
el.style.cssText = 'border-collapse:collapse; width:100%; font-size:0.9em;';
|
|
});
|
|
clone.querySelectorAll('th, td').forEach(el => {
|
|
el.style.cssText = 'border:1px solid #ddd; padding:6px 10px; text-align:left;';
|
|
});
|
|
clone.querySelectorAll('th').forEach(el => {
|
|
el.style.background = '#0078d4';
|
|
el.style.color = '#fff';
|
|
});
|
|
clone.querySelectorAll('blockquote').forEach(el => {
|
|
el.style.cssText = 'border-left:4px solid #0078d4; margin:0 0 12px 0; padding:8px 16px; background:#dce8f5; border-radius:0 4px 4px 0; color:#1a1a1a;';
|
|
});
|
|
clone.querySelectorAll('blockquote *').forEach(el => { el.style.color = '#1a1a1a'; });
|
|
|
|
// Scale images down to fit comfortably without leaving huge gaps
|
|
const TARGET_WIDTH = 380;
|
|
clone.querySelectorAll('img').forEach(el => {
|
|
const naturalW = el.naturalWidth || el.width || TARGET_WIDTH;
|
|
const naturalH = el.naturalHeight || el.height || 300;
|
|
const ratio = naturalH / naturalW;
|
|
const finalW = Math.min(naturalW, TARGET_WIDTH);
|
|
const finalH = Math.round(finalW * ratio);
|
|
el.style.width = finalW + 'px';
|
|
el.style.height = finalH + 'px';
|
|
el.style.maxWidth = '100%';
|
|
el.style.display = 'block';
|
|
el.style.margin = '6px 0';
|
|
});
|
|
|
|
const opt = {
|
|
margin: [12, 14, 12, 14],
|
|
filename: `${currentDocTitle}.pdf`,
|
|
image: { type: 'jpeg', quality: 0.92 },
|
|
html2canvas: { scale: 1.5, useCORS: true, logging: false, letterRendering: true },
|
|
jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' },
|
|
pagebreak: { mode: 'avoid-all' }
|
|
};
|
|
|
|
const btn = document.getElementById('btn-download-pdf');
|
|
btn.disabled = true;
|
|
btn.innerHTML = btn.innerHTML.replace('PDF', 'Exporting...');
|
|
|
|
loadHtml2Pdf().then(() => html2pdf().set(opt).from(clone).save()).finally(() => {
|
|
btn.disabled = false;
|
|
btn.innerHTML = btn.innerHTML.replace('Exporting...', 'PDF');
|
|
});
|
|
}
|
|
|
|
function formatCodeBlocks() {
|
|
// Add native hover tooltips to inline code blocks
|
|
document.querySelectorAll('#docContent code:not(.hljs)').forEach((block) => {
|
|
block.setAttribute('title', 'Click to copy');
|
|
});
|
|
|
|
document.querySelectorAll('#docContent pre code').forEach((block) => {
|
|
// Strip the invisible leading newline injected by marked.js
|
|
block.innerHTML = block.innerHTML.replace(/^\s*[\r\n]/, '');
|
|
|
|
if(typeof hljs !== 'undefined') hljs.highlightElement(block);
|
|
|
|
const pre = block.parentElement;
|
|
pre.style.margin = "0";
|
|
pre.style.padding = "15px";
|
|
pre.style.overflowX = "auto";
|
|
pre.style.background = "#282c34";
|
|
pre.style.borderRadius = "0 0 6px 6px";
|
|
|
|
const container = document.createElement('div');
|
|
container.style.margin = "15px 0";
|
|
container.style.border = "1px solid var(--border-color)";
|
|
container.style.borderRadius = "6px";
|
|
container.style.overflow = "hidden";
|
|
container.style.maxWidth = "100%"; // Prevent flexbox blowout
|
|
|
|
const header = document.createElement('div');
|
|
header.style.background = "var(--group-header-bg)";
|
|
header.style.padding = "5px 12px";
|
|
header.style.display = "flex";
|
|
header.style.justifyContent = "space-between";
|
|
header.style.alignItems = "center";
|
|
header.style.fontFamily = "monospace";
|
|
header.style.fontSize = "0.85em";
|
|
|
|
const langSpan = document.createElement('span');
|
|
langSpan.style.color = "var(--text-muted)";
|
|
langSpan.style.fontWeight = "bold";
|
|
langSpan.innerText = (block.className.replace('language-', '').replace('hljs', '').trim() || 'code').toUpperCase();
|
|
|
|
const copyBtn = document.createElement('button');
|
|
copyBtn.className = "nav-btn";
|
|
copyBtn.style.padding = "4px 8px";
|
|
copyBtn.style.fontSize = "0.9em";
|
|
copyBtn.style.margin = "0";
|
|
copyBtn.innerHTML = "Copy";
|
|
copyBtn.onclick = () => {
|
|
fallbackCopyTextToClipboard(block.innerText);
|
|
copyBtn.innerHTML = "Copied!";
|
|
setTimeout(() => copyBtn.innerHTML = "Copy", 2000);
|
|
};
|
|
|
|
header.appendChild(langSpan);
|
|
header.appendChild(copyBtn);
|
|
|
|
pre.parentNode.insertBefore(container, pre);
|
|
container.appendChild(header);
|
|
container.appendChild(pre);
|
|
});
|
|
}
|
|
|
|
function createNewDoc() {
|
|
const list = document.getElementById('docList');
|
|
|
|
// Prevent spawning multiple inputs if the user clicks "Add" multiple times
|
|
if (document.getElementById('new-doc-input')) {
|
|
document.getElementById('new-doc-input').focus();
|
|
return;
|
|
}
|
|
|
|
// Inject the inline text box at the top of the list
|
|
const inputHtml = `<input type="text" id="new-doc-input" placeholder="Article Title... (Press Enter)" style="padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; background: var(--input-bg); color: var(--text-main); font-family: inherit; margin-bottom: 5px; width: 100%; box-sizing: border-box; outline: none;">`;
|
|
list.insertAdjacentHTML('afterbegin', inputHtml);
|
|
|
|
const inputEl = document.getElementById('new-doc-input');
|
|
inputEl.focus();
|
|
|
|
// Listen for the Enter key to create the doc, or Escape to cancel
|
|
inputEl.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Enter') {
|
|
const title = this.value.trim();
|
|
if (title) {
|
|
currentDocTitle = title;
|
|
switchPage('docs');
|
|
|
|
document.getElementById('docToolbar').style.display = 'flex';
|
|
document.getElementById('docHeaderInfo').innerHTML = `
|
|
<h2 id="docDisplayTitle" style="display:none; margin: 0; color: var(--text-main);">${title}</h2>
|
|
<input type="text" id="docEditTitle" style="font-size: 1.5em; font-weight: bold; margin: 0; padding: 5px; width: 100%; border: 1px solid var(--border-color); background: var(--input-bg); color: var(--text-main); border-radius: 4px; box-sizing: border-box;" value="${title}">
|
|
<span id="docEditDate" style="font-size: 0.8em; color: var(--text-muted); display: block; margin-top: 2px;">New Unsaved Document</span>
|
|
`;
|
|
|
|
document.getElementById('docEditor').value = `# ${title}\n\nStart typing here...`;
|
|
setDocMode('edit');
|
|
this.remove();
|
|
}
|
|
}
|
|
if (e.key === 'Escape') {
|
|
this.remove();
|
|
}
|
|
});
|
|
|
|
// Clean up the input box if the user clicks away without typing anything
|
|
inputEl.addEventListener('blur', function() {
|
|
if(!this.value.trim()) this.remove();
|
|
});
|
|
}
|
|
|
|
async function saveDoc() {
|
|
const editTitleEl = document.getElementById('docEditTitle');
|
|
let newTitle = currentDocTitle;
|
|
let originalTitle = currentDocTitle; // Track what it used to be
|
|
|
|
if (editTitleEl && editTitleEl.value.trim()) {
|
|
newTitle = editTitleEl.value.trim();
|
|
}
|
|
|
|
if (!newTitle) return;
|
|
|
|
// Strip trailing newlines so PowerShell doesn't infinitely stack them
|
|
const content = document.getElementById('docEditor').value.trimEnd();
|
|
const btnSave = document.getElementById('btn-save-doc');
|
|
|
|
btnSave.disabled = true;
|
|
btnSave.style.opacity = '0.5';
|
|
btnSave.innerHTML = "Saving...";
|
|
|
|
try {
|
|
const res = await fetch('/save-doc', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: newTitle, originalTitle: originalTitle, content: content })
|
|
});
|
|
|
|
if (res.ok) {
|
|
showToast("Article saved successfully!");
|
|
currentDocTitle = newTitle; // Officially update state
|
|
await loadDocsList();
|
|
await openDoc(currentDocTitle);
|
|
} else {
|
|
showToast("Failed to save article.");
|
|
}
|
|
} catch (e) {
|
|
showToast("Server error while saving.");
|
|
}
|
|
|
|
btnSave.disabled = false;
|
|
btnSave.style.opacity = '1';
|
|
btnSave.innerHTML = `${SAVE_SVG} Save`;
|
|
}
|
|
|
|
function deleteDoc() {
|
|
if (!currentDocTitle) return;
|
|
|
|
document.getElementById('confirmMessage').innerText = `Are you sure you want to archive "${currentDocTitle}"?`;
|
|
document.getElementById('confirmModal').style.display = 'block';
|
|
|
|
const confirmBtn = document.getElementById('confirmActionBtn');
|
|
confirmBtn.onclick = async () => {
|
|
document.getElementById('confirmModal').style.display = 'none';
|
|
|
|
const btnDelete = document.getElementById('btn-delete-doc');
|
|
btnDelete.disabled = true;
|
|
btnDelete.style.opacity = '0.5';
|
|
btnDelete.innerHTML = "Archiving...";
|
|
|
|
try {
|
|
const res = await fetch('/delete-doc', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title: currentDocTitle })
|
|
});
|
|
|
|
if (res.ok) {
|
|
showToast("Article archived successfully!");
|
|
document.getElementById('docContent').innerHTML = "Select an article from the list to read it.";
|
|
document.getElementById('docToolbar').style.display = 'none';
|
|
document.getElementById('docEditor').style.display = 'none';
|
|
currentDocTitle = "";
|
|
await loadDocsList();
|
|
} else {
|
|
showToast("Failed to archive article.");
|
|
}
|
|
} catch (e) {
|
|
showToast("Server error while archiving.");
|
|
}
|
|
|
|
btnDelete.disabled = false;
|
|
btnDelete.style.opacity = '1';
|
|
btnDelete.innerHTML = `<svg class="icon-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path d="M135.2 17.7L128 32H32C14.3 32 0 46.3 0 64S14.3 96 32 96H416c17.7 0 32-14.3 32-32s-14.3-32-32-32H320l-7.2-14.3C307.4 6.8 296.3 0 284.2 0H163.8c-12.1 0-23.2 6.8-28.6 17.7zM416 128H32L53.2 467c1.6 25.3 22.6 45 47.9 45H346.9c25.3 0 46.3-19.7 47.9-45L416 128z"/></svg> Archive`;
|
|
};
|
|
}
|
|
|
|
async function loadBlacklist() {
|
|
try {
|
|
const res = await fetch('Blacklisted-Apps.txt?t=' + Date.now());
|
|
if (res.ok) {
|
|
const text = await res.text();
|
|
const blArray = text.split(/\r?\n/).map(line => line.trim()).filter(line => line !== "");
|
|
|
|
frontendBlacklist = [];
|
|
frontendSuites = {};
|
|
frontendDeviceRules = [];
|
|
frontendComboRules = [];
|
|
|
|
blArray.forEach(pattern => {
|
|
// Check for Combo Rule (additive: inject synthetic app if ALL conditions present)
|
|
if (pattern.toUpperCase().startsWith("COMBO:")) {
|
|
let comboMatch = pattern.match(/^Combo:\s*([^=]+?)\s*=\s*(.+)$/i);
|
|
if (comboMatch) {
|
|
let label = comboMatch[1].trim();
|
|
let conditions = comboMatch[2].split('&&').map(s => s.trim()).filter(s => s !== "");
|
|
let condRegexes = conditions.map(condPat => {
|
|
let regexStr = condPat.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
return new RegExp('^' + regexStr + '$', 'i');
|
|
});
|
|
if (condRegexes.length > 0) {
|
|
frontendComboRules.push({ label: label, condRegexes: condRegexes });
|
|
}
|
|
}
|
|
return; // Skip standard parsing for this line
|
|
}
|
|
|
|
// Check for Device-Specific Rule
|
|
if (pattern.toUpperCase().startsWith("RULE:")) {
|
|
let ruleMatch = pattern.match(/^Rule:\s*([^=]+?)\s*=\s*(.+)$/i);
|
|
if (ruleMatch) {
|
|
let devPattern = ruleMatch[1].trim();
|
|
let apps = ruleMatch[2].split(',').map(s => s.trim());
|
|
|
|
let devRegexStr = devPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
let devRegex = new RegExp('^' + devRegexStr + '$', 'i');
|
|
|
|
let appRegexes = apps.map(appPat => {
|
|
let regexStr = appPat.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
return new RegExp('^' + regexStr + '$', 'i');
|
|
});
|
|
|
|
frontendDeviceRules.push({ deviceRegex: devRegex, appRegexes: appRegexes });
|
|
}
|
|
return; // Skip standard parsing for this line
|
|
}
|
|
|
|
let suiteMatch = pattern.match(/^([^=]+?)\s*=\s*(.+)$/);
|
|
if (suiteMatch) {
|
|
let suiteName = suiteMatch[1].trim();
|
|
let apps = suiteMatch[2].split(',').map(s => s.trim());
|
|
frontendSuites[suiteName] = apps.map(appPat => {
|
|
let regexStr = appPat.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
return new RegExp('^' + regexStr + '$', 'i');
|
|
});
|
|
} else {
|
|
let regexStr = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*').replace(/\?/g, '.');
|
|
frontendBlacklist.push(new RegExp('^' + regexStr + '$', 'i'));
|
|
}
|
|
});
|
|
}
|
|
} catch(e) {}
|
|
}
|
|
|
|
async function loadAllTags() {
|
|
try {
|
|
const res = await fetch('Tracked-Devices.json?t=' + Date.now());
|
|
if (!res.ok) return;
|
|
const trackData = await res.json();
|
|
const tags = Object.keys(trackData);
|
|
|
|
const container = document.getElementById('tagContainer');
|
|
|
|
if (!tags || tags.length === 0) {
|
|
container.innerHTML = `<span style="color: var(--text-muted); font-style: italic;">No active tags found.</span>`;
|
|
currentAvailableTags = [];
|
|
updateTagDropdowns();
|
|
return;
|
|
}
|
|
|
|
currentAvailableTags = tags;
|
|
container.innerHTML = tags.map(tag =>
|
|
`<button class="tag-btn" onclick="selectTag('${tag}')">${tag}</button>`
|
|
).join('');
|
|
|
|
updateTagDropdowns();
|
|
} catch (e) { console.log("Failed to load tags."); }
|
|
}
|
|
|
|
function updateTagDropdowns() {
|
|
groupsArray.forEach(g => {
|
|
let safeG = g.replace(/ /g, '-');
|
|
const container = document.getElementById(`tags-options-${safeG}`);
|
|
if (!container) return;
|
|
|
|
const selected = Array.from(container.querySelectorAll('input:checked')).map(cb => cb.value);
|
|
|
|
let html = `<input type="text" class="multi-search" oninput="filterMultiSelect(this)" placeholder="Search tags...">`;
|
|
currentAvailableTags.forEach(tag => {
|
|
const isChecked = selected.includes(tag) ? 'checked' : '';
|
|
html += `<label class="option-label"><input type="checkbox" value="${tag}" onchange="applyFilters('${safeG}')" ${isChecked}> ${tag}</label>`;
|
|
});
|
|
container.innerHTML = html;
|
|
|
|
updateMultiSelectText(safeG);
|
|
});
|
|
|
|
const swTagContainer = document.getElementById(`tags-options-software`);
|
|
if (swTagContainer) {
|
|
const selected = Array.from(swTagContainer.querySelectorAll('input:checked')).map(cb => cb.value);
|
|
let html = `<input type="text" class="multi-search" oninput="filterMultiSelect(this)" placeholder="Search tags...">`;
|
|
currentAvailableTags.forEach(tag => {
|
|
const isChecked = selected.includes(tag) ? 'checked' : '';
|
|
html += `<label class="option-label"><input type="checkbox" value="${tag}" onchange="filterSoftwareTab()" ${isChecked}> ${tag}</label>`;
|
|
});
|
|
swTagContainer.innerHTML = html;
|
|
|
|
const box = document.querySelector(`#tags-filter-software .select-box`);
|
|
if(box) {
|
|
const checked = swTagContainer.querySelectorAll('input:checked');
|
|
if(checked.length === 0) box.innerText = "Select Tags...";
|
|
else if(checked.length === 1) box.innerText = checked[0].value;
|
|
else box.innerText = `${checked.length} selected`;
|
|
}
|
|
}
|
|
}
|
|
|
|
function updateMultiSelectText(safeG) {
|
|
const container = document.getElementById(`tags-options-${safeG}`);
|
|
const box = document.querySelector(`#tags-filter-${safeG} .select-box`);
|
|
if(!container || !box) return;
|
|
|
|
const checked = container.querySelectorAll('input:checked');
|
|
if(checked.length === 0) box.innerText = "Select Tags...";
|
|
else if(checked.length === 1) box.innerText = checked[0].value;
|
|
else box.innerText = `${checked.length} selected`;
|
|
}
|
|
|
|
function selectTag(tagName) {
|
|
document.getElementById('techId').value = tagName;
|
|
loadTechRoster();
|
|
loadTagMeta(tagName);
|
|
}
|
|
|
|
async function loadTagMeta(tagName) {
|
|
const row = document.getElementById('skipUSMTRow');
|
|
const cb = document.getElementById('skipUSMTCheckbox');
|
|
try {
|
|
const res = await fetch(`/get-tag-meta?tech=${encodeURIComponent(tagName)}&t=${Date.now()}`);
|
|
const meta = await res.json();
|
|
cb.checked = !!meta.skipUSMT;
|
|
} catch (e) {
|
|
cb.checked = false;
|
|
}
|
|
row.style.display = 'flex';
|
|
}
|
|
|
|
async function saveSkipUSMT() {
|
|
const tech = document.getElementById('techId').value.trim();
|
|
if (!tech) return;
|
|
const skipUSMT = document.getElementById('skipUSMTCheckbox').checked;
|
|
// Load the current device list so we only update the flag, not overwrite devices
|
|
try {
|
|
const res = await fetch(`/get-tracked?tech=${encodeURIComponent(tech)}&t=${Date.now()}`);
|
|
const devices = await res.json();
|
|
await fetch('/update-tracked', {
|
|
method: 'POST',
|
|
body: JSON.stringify({ tech: tech, devices: devices, skipUSMT: skipUSMT }),
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
showToast(`Skip USMT ${skipUSMT ? 'enabled' : 'disabled'} for tag: ${tech}`);
|
|
} catch (e) {
|
|
showToast('Failed to save Skip USMT setting.');
|
|
}
|
|
}
|
|
|
|
async function removeTrackedDevices() {
|
|
const tech = document.getElementById('techId').value.trim();
|
|
if (!tech) { alert("Please enter a Tech Identifier or Tag first."); return; }
|
|
|
|
const removeText = document.getElementById('removeDevices').value;
|
|
const removeArray = removeText.split(/\r?\n/).map(n => n.trim().toUpperCase()).filter(n => n);
|
|
|
|
if (removeArray.length === 0) return;
|
|
|
|
let expandedRemoveArray = [...removeArray];
|
|
removeArray.forEach(rem => {
|
|
const matches = apiData.filter(d =>
|
|
(d["Destination Device"] && d["Destination Device"].toUpperCase() === rem) ||
|
|
(d["Source Device"] && d["Source Device"].toUpperCase() === rem)
|
|
);
|
|
matches.forEach(match => {
|
|
expandedRemoveArray.push(match["Source Device"].toUpperCase());
|
|
if (match["Destination Device"] && match["Destination Device"] !== "N/A") {
|
|
expandedRemoveArray.push(match["Destination Device"].toUpperCase());
|
|
}
|
|
});
|
|
});
|
|
|
|
const currentText = document.getElementById('techDevices').value;
|
|
let currentArray = currentText.split(/\r?\n/).map(n => n.trim().toUpperCase()).filter(n => n);
|
|
|
|
const originalLength = currentArray.length;
|
|
currentArray = currentArray.filter(d => {
|
|
const parts = d.split(',').map(p => p.trim());
|
|
// Use the expanded list to catch both Source and Destination names
|
|
return !parts.some(p => expandedRemoveArray.includes(p));
|
|
});
|
|
|
|
const itemsRemoved = originalLength - currentArray.length;
|
|
|
|
document.getElementById('techDevices').value = currentArray.join('\n');
|
|
document.getElementById('removeDevices').value = '';
|
|
|
|
await saveTechRoster(true);
|
|
showToast(`Successfully removed ${itemsRemoved} device(s)!`);
|
|
}
|
|
|
|
async function globalRemoveDevices() {
|
|
const removeText = document.getElementById('globalRemoveDevices').value;
|
|
const removeArray = removeText.split(/\r?\n/).map(n => n.trim().toUpperCase()).filter(n => n);
|
|
|
|
if (removeArray.length === 0) return;
|
|
|
|
// --- NEW: Cross-reference with apiData to find SCCM-mapped pairs ---
|
|
let expandedRemoveArray = [...removeArray];
|
|
removeArray.forEach(rem => {
|
|
const matches = apiData.filter(d =>
|
|
(d["Destination Device"] && d["Destination Device"].toUpperCase() === rem) ||
|
|
(d["Source Device"] && d["Source Device"].toUpperCase() === rem)
|
|
);
|
|
matches.forEach(match => {
|
|
expandedRemoveArray.push(match["Source Device"].toUpperCase());
|
|
if (match["Destination Device"] && match["Destination Device"] !== "N/A") {
|
|
expandedRemoveArray.push(match["Destination Device"].toUpperCase());
|
|
}
|
|
});
|
|
});
|
|
// -------------------------------------------------------------------
|
|
|
|
const btn = document.getElementById('global-del-btn');
|
|
const originalText = btn.innerText;
|
|
btn.innerText = "Removing...";
|
|
btn.disabled = true;
|
|
|
|
let totalRemoved = 0;
|
|
|
|
// Loop through all active tags in the system
|
|
for (const tag of currentAvailableTags) {
|
|
try {
|
|
// Fetch the current devices for this tag
|
|
const res = await fetch(`/get-tracked?tech=${encodeURIComponent(tag)}&t=${Date.now()}`);
|
|
const currentDevices = await res.json();
|
|
|
|
if (Array.isArray(currentDevices)) {
|
|
const originalLength = currentDevices.length;
|
|
|
|
// Filter out the devices the user wants to globally delete using expanded list
|
|
const newDevices = currentDevices.filter(d => {
|
|
const parts = d.toUpperCase().split(',').map(p => p.trim());
|
|
return !parts.some(p => expandedRemoveArray.includes(p));
|
|
});
|
|
|
|
// If devices were removed, push the updated list back to the server
|
|
if (newDevices.length < originalLength) {
|
|
totalRemoved += (originalLength - newDevices.length);
|
|
let tagSkipUSMT = undefined;
|
|
try {
|
|
const metaRes = await fetch(`/get-tag-meta?tech=${encodeURIComponent(tag)}&t=${Date.now()}`);
|
|
const meta = await metaRes.json();
|
|
tagSkipUSMT = !!meta.skipUSMT;
|
|
} catch(e) {}
|
|
const globalPayload = { tech: tag, devices: newDevices };
|
|
if (tagSkipUSMT !== undefined) globalPayload.skipUSMT = tagSkipUSMT;
|
|
await fetch('/update-tracked', {
|
|
method: 'POST',
|
|
body: JSON.stringify(globalPayload),
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
}
|
|
}
|
|
} catch (e) {
|
|
console.error("Failed processing tag: " + tag);
|
|
}
|
|
}
|
|
|
|
document.getElementById('globalRemoveDevices').value = '';
|
|
btn.innerText = originalText;
|
|
btn.disabled = false;
|
|
|
|
// Refresh the UI to show changes
|
|
if (document.getElementById('techId').value) {
|
|
loadTechRoster();
|
|
}
|
|
pullData();
|
|
showToast(`Globally removed ${totalRemoved} device instance(s)!`);
|
|
}
|
|
|
|
async function loadTechRoster() {
|
|
const tech = document.getElementById('techId').value.trim();
|
|
if (!tech) {
|
|
document.getElementById('skipUSMTRow').style.display = 'none';
|
|
return;
|
|
}
|
|
localStorage.setItem('usmt_tech_id', tech);
|
|
try {
|
|
const res = await fetch(`/get-tracked?tech=${encodeURIComponent(tech)}&t=${Date.now()}`);
|
|
const devices = await res.json();
|
|
document.getElementById('techDevices').value = devices.join('\n');
|
|
} catch (e) { document.getElementById('techDevices').value = ""; }
|
|
}
|
|
|
|
async function saveTechRoster(silent = false) {
|
|
const tech = document.getElementById('techId').value.trim();
|
|
const text = document.getElementById('techDevices').value;
|
|
if (!tech) { alert("Please enter a Tech Identifier or Tag."); return; }
|
|
|
|
const devicesArray = text.split(/\r?\n/).map(n => n.trim().toUpperCase()).filter(n => n);
|
|
|
|
try {
|
|
const skipUSMTCheckbox = document.getElementById('skipUSMTCheckbox');
|
|
const skipUSMT = skipUSMTCheckbox && skipUSMTCheckbox.closest('#skipUSMTRow').style.display !== 'none'
|
|
? skipUSMTCheckbox.checked : undefined;
|
|
const payload = { tech: tech, devices: devicesArray };
|
|
if (skipUSMT !== undefined) payload.skipUSMT = skipUSMT;
|
|
await fetch('/update-tracked', {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
headers: { 'Content-Type': 'application/json' }
|
|
});
|
|
|
|
if (!silent) {
|
|
const status = document.getElementById('save-status');
|
|
status.style.display = 'inline';
|
|
setTimeout(() => status.style.display = 'none', 3000);
|
|
}
|
|
|
|
loadAllTags();
|
|
pullData();
|
|
} catch (e) { alert("Failed to save to server."); }
|
|
}
|
|
|
|
async function fetchSoftware(name) {
|
|
window.currentSoftwareDevice = name;
|
|
document.getElementById('modal').style.display = 'block';
|
|
document.getElementById('modalTitle').innerHTML = `Software: ${name} ${inlineCopyBtn(name)}`;
|
|
|
|
let isCached = false;
|
|
let cachedJsonString = "";
|
|
|
|
if (softwareCache[name] && Array.isArray(softwareCache[name])) {
|
|
drawModalTables(softwareCache[name], true, name);
|
|
isCached = true;
|
|
cachedJsonString = JSON.stringify(softwareCache[name]);
|
|
} else {
|
|
document.getElementById('softBody').innerHTML = "<tr><td colspan='5'>Querying Endpoint / SCCM...</td></tr>";
|
|
document.getElementById('allSoftBody').innerHTML = "";
|
|
document.getElementById('specialtyCount').innerText = "0";
|
|
document.getElementById('allCount').innerText = "0";
|
|
}
|
|
|
|
document.getElementById('softSearch').value = "";
|
|
|
|
try {
|
|
const res = await fetch(`/get-software?name=${name}`);
|
|
const rawData = await res.json();
|
|
|
|
if (rawData.error) {
|
|
if (!isCached) document.getElementById('softBody').innerHTML = `<tr><td colspan='5' style='color:red'>${rawData.error}</td></tr>`;
|
|
return;
|
|
}
|
|
|
|
const dataArray = Array.isArray(rawData) ? rawData : [rawData];
|
|
const data = dataArray.filter(s => s && s.Name && s.Name.trim() !== '' && s.Name.trim() !== '--');
|
|
|
|
const newJsonString = JSON.stringify(data);
|
|
|
|
if (newJsonString !== cachedJsonString) {
|
|
if (window.currentSoftwareDevice === name) drawModalTables(data, false, name);
|
|
softwareCache[name] = data;
|
|
pullData();
|
|
}
|
|
|
|
} catch (e) {
|
|
if (!isCached) document.getElementById('softBody').innerHTML = "<tr><td colspan='5' style='color:red'>Failed to connect to server.</td></tr>";
|
|
}
|
|
}
|
|
|
|
function drawModalTables(data, isCached, deviceName) {
|
|
const parseSCCMDate = (dateStr) => {
|
|
if (!dateStr || dateStr === '--') return '--';
|
|
const match = dateStr.match(/\/Date\((\d+)\)\//);
|
|
if (match) {
|
|
return new Date(parseInt(match[1])).toLocaleDateString();
|
|
}
|
|
// PowerShell 7 serializes dates as ISO 8601 strings
|
|
if (/^\d{4}-\d{2}-\d{2}T/.test(dateStr)) {
|
|
const iso = new Date(dateStr);
|
|
if (!isNaN(iso)) return iso.toLocaleDateString();
|
|
}
|
|
return dateStr;
|
|
};
|
|
|
|
const specialtyApps = getConsolidatedSpecialtyApps(data, deviceName);
|
|
|
|
document.getElementById('specialtyCount').innerText = specialtyApps.length;
|
|
document.getElementById('allCount').innerText = data.length;
|
|
|
|
const tdWrap = `style="white-space: normal; word-wrap: break-word; overflow-wrap: break-word; color: var(--text-main); font-size: 0.95em; border-bottom: 1px solid var(--border-color); padding: 8px 6px;"`;
|
|
|
|
if (specialtyApps.length > 0) {
|
|
document.getElementById('softBody').innerHTML = specialtyApps.map(s => {
|
|
const nameText = s.Name || '--';
|
|
const linkMatch = availableDocs.find(d => d.toLowerCase() === nameText.toLowerCase());
|
|
const nameDisplay = linkMatch
|
|
? `<a href="/docs/${encodeURIComponent(linkMatch)}" target="_blank" onclick="event.stopPropagation();" style="color: #3498db;"><b>${nameText}</b></a>`
|
|
: `<b>${nameText}</b>`;
|
|
return `
|
|
<tr>
|
|
<td ${tdWrap}>${nameDisplay}</td>
|
|
<td ${tdWrap}>${s.Version || '--'}</td>
|
|
<td ${tdWrap}>${s.Publisher || '--'}</td>
|
|
<td ${tdWrap}>${s.Source || '--'}</td>
|
|
<td ${tdWrap}>${parseSCCMDate(s.InstallDate)}</td>
|
|
</tr>
|
|
`;
|
|
}).join('');
|
|
} else {
|
|
document.getElementById('softBody').innerHTML = `<tr><td colspan='5' style='text-align:center; color: var(--text-muted); border-bottom: 1px solid var(--border-color); padding: 8px 6px;'>No specialty apps found.</td></tr>`;
|
|
}
|
|
|
|
if (data.length > 0) {
|
|
document.getElementById('allSoftBody').innerHTML = data.map(s => {
|
|
const isSpec = isSpecialtyApp(s.Name, deviceName);
|
|
const nameDisplay = isSpec ? `<b>${s.Name}</b>` : s.Name;
|
|
return `
|
|
<tr>
|
|
<td ${tdWrap}>${nameDisplay}</td>
|
|
<td ${tdWrap}>${s.Version || '--'}</td>
|
|
<td ${tdWrap}>${s.Publisher || '--'}</td>
|
|
<td ${tdWrap}>${s.Source || '--'}</td>
|
|
<td ${tdWrap}>${parseSCCMDate(s.InstallDate)}</td>
|
|
</tr>
|
|
`}).join('');
|
|
} else {
|
|
document.getElementById('allSoftBody').innerHTML = `<tr><td colspan='5' style='text-align:center; color: var(--text-muted); border-bottom: 1px solid var(--border-color); padding: 8px 6px;'>No apps found in SCCM.</td></tr>`;
|
|
}
|
|
}
|
|
|
|
function filterSoftwareModal() {
|
|
const input = document.getElementById('softSearch').value.toUpperCase();
|
|
|
|
let specVisible = 0;
|
|
const trSpecialty = document.getElementById('softTable').getElementsByTagName('tr');
|
|
for (let i = 1; i < trSpecialty.length; i++) {
|
|
const txt = trSpecialty[i].textContent || trSpecialty[i].innerText;
|
|
const show = txt.toUpperCase().indexOf(input) > -1;
|
|
trSpecialty[i].style.display = show ? '' : 'none';
|
|
if (show) specVisible++;
|
|
}
|
|
document.getElementById('specialtyCount').innerText = specVisible;
|
|
|
|
let allVisible = 0;
|
|
const trAll = document.getElementById('allSoftTable').getElementsByTagName('tr');
|
|
for (let i = 1; i < trAll.length; i++) {
|
|
const txt = trAll[i].textContent || trAll[i].innerText;
|
|
const show = txt.toUpperCase().indexOf(input) > -1;
|
|
trAll[i].style.display = show ? '' : 'none';
|
|
if (show) allVisible++;
|
|
}
|
|
document.getElementById('allCount').innerText = allVisible;
|
|
}
|
|
|
|
function closeModal(id) { document.getElementById(id).style.display = 'none'; }
|
|
|
|
function openSyncModal() {
|
|
// Close the mobile menu if it's open
|
|
document.getElementById('nav-links-menu').classList.remove('show-menu');
|
|
|
|
// Open the modal
|
|
document.getElementById('syncModal').style.display = 'block';
|
|
}
|
|
|
|
async function forceUpdate() {
|
|
const btn = document.getElementById('forceSyncBtn');
|
|
btn.innerText = "Triggering Update...";
|
|
btn.disabled = true;
|
|
try {
|
|
await fetch('/force-update', { method: 'POST' });
|
|
showToast("Worker update triggered! Refreshing...");
|
|
} catch (e) {
|
|
showToast("Failed to reach server.");
|
|
}
|
|
setTimeout(() => {
|
|
btn.innerText = "Force Update Now";
|
|
btn.disabled = false;
|
|
closeModal('syncModal');
|
|
}, 2000);
|
|
}
|
|
|
|
async function forceFullRefresh() {
|
|
const btn = document.getElementById('forceFullBtn');
|
|
btn.innerText = "Triggering Full Refresh...";
|
|
btn.disabled = true;
|
|
try {
|
|
await fetch('/force-full', { method: 'POST' });
|
|
showToast("Full software/advanced refresh triggered!");
|
|
} catch (e) {
|
|
showToast("Failed to reach server.");
|
|
}
|
|
setTimeout(() => {
|
|
btn.innerText = "Full Software/Advanced Refresh";
|
|
btn.disabled = false;
|
|
closeModal('syncModal');
|
|
}, 2000);
|
|
}
|
|
|
|
window.currentDetailsMatch = null;
|
|
window.isAdvView = localStorage.getItem('usmt_adv_view') === 'true';
|
|
|
|
function showDeviceDetails(lookupKey) {
|
|
if (window.getSelection().toString().length > 0) return;
|
|
const match = apiData.find(x => x["Source Device"] === lookupKey || x["Destination Device"] === lookupKey);
|
|
if (!match) return;
|
|
|
|
window.currentDetailsMatch = match;
|
|
window.currentDetailsKey = match["Source Device"];
|
|
document.getElementById('detailsModal').style.display = 'block';
|
|
renderDetailsHTML();
|
|
}
|
|
|
|
function toggleAdvView() {
|
|
window.isAdvView = !window.isAdvView;
|
|
localStorage.setItem('usmt_adv_view', window.isAdvView);
|
|
renderDetailsHTML();
|
|
}
|
|
|
|
function renderDetailsHTML() {
|
|
const match = window.currentDetailsMatch;
|
|
if (!match) return;
|
|
|
|
const btnText = window.isAdvView ? "Simple View" : "Advanced View";
|
|
document.getElementById('detailsTitle').innerHTML = `<span class="dt-name">Details: ${match["Source Device"]} ${inlineCopyBtn(match["Source Device"])}</span><button class="dt-toggle" onclick="toggleAdvView()">${btnText}</button>`;
|
|
|
|
const statusBg = themes[currentTheme].groupColors[match["Backup Status"]] || "#ffffff";
|
|
const statusText = themes[currentTheme].textColors[match["Backup Status"]] || "#333333";
|
|
|
|
const srcAppBtn = `<button class="soft-btn inline-btn" onclick="fetchSoftware('${match["Source Device"]}')">Apps</button>`;
|
|
const dstAppBtn = (match["Destination Device"] && match["Destination Device"] !== "N/A") ?
|
|
`<button class="soft-btn inline-btn" onclick="fetchSoftware('${match["Destination Device"]}')">Apps</button>` : '';
|
|
|
|
let specialtySoftware = isSoftwareCacheLoaded ? "Waiting for Worker..." : "Loading...";
|
|
if (Array.isArray(softwareCache[match["Source Device"]])) {
|
|
let sApps = [...new Set([
|
|
...softwareCache[match["Source Device"]].filter(a => isSpecialtyApp(a.Name, match["Source Device"])).map(a => getAppDisplayName(a.Name)),
|
|
...getComboApps(softwareCache[match["Source Device"]]).map(c => c.Name)
|
|
])].sort((a, b) => a.localeCompare(b));
|
|
let hyperlinkedApps = sApps.map(app => {
|
|
const linkMatch = availableDocs.find(d => d.toLowerCase() === app.toLowerCase());
|
|
return linkMatch
|
|
? `<a href="/docs/${encodeURIComponent(linkMatch)}" target="_blank" onclick="event.stopPropagation();" style="color: #3498db; font-weight: bold;">${app}</a>`
|
|
: app;
|
|
});
|
|
specialtySoftware = hyperlinkedApps.length > 0 ? hyperlinkedApps.join("<br>") : "None";
|
|
}
|
|
|
|
const modalContent = document.querySelector('#detailsModal .modal-content');
|
|
const modalHeader = document.querySelector('#detailsModal .modal-header');
|
|
const detailsBody = document.getElementById('detailsBody');
|
|
|
|
if (window.isAdvView) {
|
|
modalContent.style.maxWidth = '1600px';
|
|
modalContent.style.width = '98%';
|
|
modalContent.style.margin = '80px auto 20px auto';
|
|
modalContent.style.height = 'calc(100vh - 100px)';
|
|
modalContent.style.display = 'flex';
|
|
modalContent.style.flexDirection = 'column';
|
|
modalContent.style.overflow = 'hidden';
|
|
|
|
modalHeader.style.flexShrink = '0';
|
|
modalHeader.style.background = 'var(--bg-card)';
|
|
modalHeader.style.zIndex = '1000';
|
|
|
|
detailsBody.style.display = 'flex';
|
|
detailsBody.style.flexDirection = 'column';
|
|
detailsBody.style.flex = '1';
|
|
detailsBody.style.minHeight = '0';
|
|
detailsBody.style.padding = '0';
|
|
} else {
|
|
modalContent.style.maxWidth = '680px';
|
|
modalContent.style.width = '85%';
|
|
modalContent.style.height = 'auto';
|
|
modalContent.style.maxHeight = 'calc(100vh - 100px)';
|
|
modalContent.style.margin = '80px auto 20px auto';
|
|
modalContent.style.display = 'block';
|
|
modalContent.style.overflowY = 'auto';
|
|
|
|
detailsBody.style.display = 'block';
|
|
detailsBody.style.flex = 'none';
|
|
detailsBody.style.padding = '20px';
|
|
}
|
|
|
|
const tdStyle = `style="white-space: normal; word-wrap: break-word; overflow-wrap: break-word; padding: 10px;"`;
|
|
const scrollBoxStyle = `style="flex: 1; overflow-y: auto; padding: 5px; border: 1px solid var(--border-light); border-radius: 4px;"`;
|
|
|
|
const srcName = match["Source Device"], dstName = match["Destination Device"];
|
|
const hasDst = DeviceInfo.isName(dstName);
|
|
// Advanced fields explain a blank: "Not in AD", "Not in SCCM", "No DNS record", or
|
|
// "Waiting for worker..." when the worker hasn't looked the device up yet
|
|
const getAdv = (name, key) => DeviceInfo.html(name, key);
|
|
const hwSpecs = name => {
|
|
const m = DeviceInfo.field(name, "Model"), r = DeviceInfo.field(name, "RAM");
|
|
if (m.state !== "value" && r.state !== "value") return getAdv(name, "Model");
|
|
return `${getAdv(name, "Model")}<br>${r.state === "value" ? r.text + " RAM" : getAdv(name, "RAM")}`;
|
|
};
|
|
const monitorsCell = name => {
|
|
const c = DeviceInfo.field(name, "MonitorCount");
|
|
if (c.state !== "value") return getAdv(name, "MonitorCount");
|
|
const list = DeviceInfo.field(name, "Monitors");
|
|
return `<b>${c.text}</b>${list.state === "value" && list.text !== "None" ? "<br>" + list.text : ""}`;
|
|
};
|
|
const pairing = match["Pairing"] === "Manual" ? "Manual pair (no SCCM association)"
|
|
: match["Pairing"] === "Skipped" ? "Skip USMT tag (inventory only)"
|
|
: match["Pairing"] === "None" ? "No SCCM association"
|
|
: match["Pairing"] === "Cached" ? "SCCM association (completed, network checks skipped)"
|
|
: match["Pairing"] === "SCCM" ? "SCCM association" : null;
|
|
const elapsed = e => (e && e !== "N/A" && e !== "Cached") ? '<br>Elapsed Time: ' + e : '';
|
|
const space = match["Source Free Space"] && match["Source Free Space"] !== "N/A" && match["Source Free Space"] !== "Cached"
|
|
? match["Source Free Space"] + ' / ' + match["Source Total Space"] : (match["Source Free Space"] || "N/A");
|
|
|
|
let mainRows = `
|
|
<div ${window.isAdvView ? scrollBoxStyle : ''}>
|
|
<table class="details-table" style="width:100%; border-collapse: collapse; table-layout: fixed;">
|
|
${window.isAdvView ? `<tr><th colspan="2" style="text-align: center; background: var(--table-header-bg); color: var(--table-header-text);">Migration Overview</th></tr>` : ''}
|
|
<tr><th width="40%">Source Device</th><td ${tdStyle}><b>${srcName}</b> ${inlineCopyBtn(srcName)} ${srcAppBtn}</td></tr>
|
|
<tr><th>Source Ping</th><td style="color: ${getPingColor(match["Source Online"])}; font-weight: bold; padding: 10px;">${match["Source Online"]}${DeviceInfo.pingMark(match, "Source")}</td></tr>
|
|
<tr><th>Space Available</th><td ${tdStyle}>${space}${match["Source Space Checked"] && !match["Source Snapshot"] ? `<br><span class="di-sub">Checked ${DeviceInfo.fmtWhen(match["Source Space Checked"])}</span>` : ''}${DeviceInfo.snapshotLine(match, "Source")}</td></tr>
|
|
<tr><th>Scanstate Line</th><td ${tdStyle}>${match["Source Scanstate Line"]}${elapsed(match["Source Scanstate Elapsed"])}${DeviceInfo.snapshotLine(match, "Source")}</td></tr>
|
|
<tr><th>Dest Device</th><td ${tdStyle}><b>${dstName}</b> ${inlineCopyBtn(dstName)} ${dstAppBtn}</td></tr>
|
|
<tr><th>Dest Ping</th><td style="color: ${getPingColor(match["Destination Online"])}; font-weight: bold; padding: 10px;">${match["Destination Online"]}${DeviceInfo.pingMark(match, "Destination")}</td></tr>
|
|
<tr><th>Loadstate Line</th><td ${tdStyle}>${match["Destination Loadstate Line"]}${elapsed(match["Destination Loadstate Elapsed"])}${DeviceInfo.snapshotLine(match, "Destination")}</td></tr>
|
|
<tr><th>Backup Status</th><td style="background-color: ${statusBg}; color: ${statusText}; padding: 10px;"><b>${match["Backup Status"]}</b></td></tr>
|
|
<tr><th>Specialty Software</th><td ${tdStyle}>${specialtySoftware}</td></tr>
|
|
<tr><th>USMT Status</th><td ${tdStyle}>${match["USMT Status"]}${pairing ? `<br><span class="di-sub">${pairing}</span>` : ''}</td></tr>
|
|
<tr><th>Tags</th><td ${tdStyle}>${match["Tags"] || 'None'}</td></tr>
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
let bodyHtml = "";
|
|
|
|
if (window.isAdvView) {
|
|
let advTableSrc = `
|
|
<div ${scrollBoxStyle}>
|
|
<table class="details-table" style="width:100%; border-collapse: collapse; table-layout: fixed;">
|
|
<tr><th colspan="2" style="text-align: center; background: #34495e; color: white;">Source Info</th></tr>
|
|
<tr><th width="40%">Device Name</th><td ${tdStyle}><b>${srcName}</b> ${inlineCopyBtn(srcName)}</td></tr>
|
|
<tr><th>Found In</th><td ${tdStyle}>${getAdv(srcName, "FoundIn")}</td></tr>
|
|
<tr><th>Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(srcName)}</td></tr>
|
|
${DeviceInfo.unknownEverywhere(srcName) ? `<tr><td colspan="2" ${tdStyle}><span class="di-missing">Not in AD, SCCM or DNS.</span><br><span class="di-sub">Nothing in the directory, the site database or DNS knows this name yet: a replacement that hasn't been imaged, or a typo in the tag.</span></td></tr>` : `
|
|
<tr><th>Source IP / MAC</th><td ${tdStyle}>${getAdv(srcName, "IP")}<br>${getAdv(srcName, "MAC")}</td></tr>
|
|
<tr><th>Source OU</th><td ${tdStyle}>${getAdv(srcName, "OU")}</td></tr>
|
|
<tr><th>Source Groups</th><td ${tdStyle}>${getAdv(srcName, "Groups")}</td></tr>
|
|
<tr><th>Hardware Specs</th><td ${tdStyle}>${hwSpecs(srcName)}</td></tr>
|
|
<tr><th>Physical Disk</th><td ${tdStyle}>${getAdv(srcName, "Disk")}</td></tr>
|
|
<tr><th>Monitors</th><td ${tdStyle}>${monitorsCell(srcName)}</td></tr>
|
|
<tr><th>SCCM Client</th><td ${tdStyle}>${getAdv(srcName, "ClientStatus")}</td></tr>
|
|
<tr><th>Dept / Bldg</th><td ${tdStyle}>${getAdv(srcName, "Department")} / ${getAdv(srcName, "Building")}</td></tr>
|
|
<tr><th>Location / Vanity</th><td ${tdStyle}>${getAdv(srcName, "Location")} / ${getAdv(srcName, "Vanity")}</td></tr>
|
|
<tr><th>Phone / Mount</th><td ${tdStyle}>Ext: ${getAdv(srcName, "PhoneExt")}<br>${getAdv(srcName, "Mount")}</td></tr>
|
|
<tr><th>Room Type</th><td ${tdStyle}>${getAdv(srcName, "RoomType")}</td></tr>
|
|
<tr><th>Users (Pri / Last)</th><td ${tdStyle}>${getAdv(srcName, "PrimaryUser")} / ${getAdv(srcName, "LastUser")}</td></tr>
|
|
<tr><th>Primary Users</th><td ${tdStyle}>${getAdv(srcName, "SCCMPrimaryUsers")}</td></tr>
|
|
<tr><th>Asset Info Entered</th><td ${tdStyle}>${getAdv(srcName, "Updated")}<br>(By: ${getAdv(srcName, "InfoBy")})</td></tr>
|
|
<tr><th>Notes</th><td ${tdStyle}>${getAdv(srcName, "Notes")}</td></tr>`}
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
let advTableDst = `
|
|
<div ${scrollBoxStyle}>
|
|
<table class="details-table" style="width:100%; border-collapse: collapse; table-layout: fixed;">
|
|
<tr><th colspan="2" style="text-align: center; background: #34495e; color: white;">Destination Info</th></tr>
|
|
<tr><th width="40%">Device Name</th><td ${tdStyle}><b>${dstName}</b> ${inlineCopyBtn(dstName)}</td></tr>
|
|
<tr><th>Found In</th><td ${tdStyle}>${getAdv(dstName, "FoundIn")}</td></tr>
|
|
<tr><th>Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(dstName)}</td></tr>
|
|
${DeviceInfo.unknownEverywhere(dstName) ? `<tr><td colspan="2" ${tdStyle}><span class="di-missing">Not in AD, SCCM or DNS.</span><br><span class="di-sub">Nothing in the directory, the site database or DNS knows this name yet: a replacement that hasn't been imaged, or a typo in the tag.</span></td></tr>` : `
|
|
<tr><th>Dest IP / MAC</th><td ${tdStyle}>${getAdv(dstName, "IP")}<br>${getAdv(dstName, "MAC")}</td></tr>
|
|
<tr><th>Dest OU</th><td ${tdStyle}>${getAdv(dstName, "OU")}</td></tr>
|
|
<tr><th>Dest Groups</th><td ${tdStyle}>${getAdv(dstName, "Groups")}</td></tr>
|
|
<tr><th>Hardware Specs</th><td ${tdStyle}>${hwSpecs(dstName)}</td></tr>
|
|
<tr><th>Physical Disk</th><td ${tdStyle}>${getAdv(dstName, "Disk")}</td></tr>
|
|
<tr><th>Monitors</th><td ${tdStyle}>${monitorsCell(dstName)}</td></tr>
|
|
<tr><th>SCCM Client</th><td ${tdStyle}>${getAdv(dstName, "ClientStatus")}</td></tr>
|
|
<tr><th>Dept / Bldg</th><td ${tdStyle}>${getAdv(dstName, "Department")} / ${getAdv(dstName, "Building")}</td></tr>
|
|
<tr><th>Location / Vanity</th><td ${tdStyle}>${getAdv(dstName, "Location")} / ${getAdv(dstName, "Vanity")}</td></tr>
|
|
<tr><th>Phone / Mount</th><td ${tdStyle}>Ext: ${getAdv(dstName, "PhoneExt")}<br>${getAdv(dstName, "Mount")}</td></tr>
|
|
<tr><th>Room Type</th><td ${tdStyle}>${getAdv(dstName, "RoomType")}</td></tr>
|
|
<tr><th>Users (Pri / Last)</th><td ${tdStyle}>${getAdv(dstName, "PrimaryUser")} / ${getAdv(dstName, "LastUser")}</td></tr>
|
|
<tr><th>Primary Users</th><td ${tdStyle}>${getAdv(dstName, "SCCMPrimaryUsers")}</td></tr>
|
|
<tr><th>Asset Info Entered</th><td ${tdStyle}>${getAdv(dstName, "Updated")}<br>(By: ${getAdv(dstName, "InfoBy")})</td></tr>
|
|
<tr><th>Notes</th><td ${tdStyle}>${getAdv(dstName, "Notes")}</td></tr>`}
|
|
</table>
|
|
</div>
|
|
`;
|
|
|
|
bodyHtml = `
|
|
<div style="display: flex; gap: 15px; flex: 1; min-height: 0; padding: 20px; align-items: stretch; justify-content: space-between;">
|
|
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column;">${advTableSrc}</div>
|
|
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column;">${mainRows}</div>
|
|
<div style="flex: 1; min-width: 0; display: flex; flex-direction: column;">${advTableDst}</div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
bodyHtml = mainRows;
|
|
}
|
|
|
|
document.getElementById('detailsBody').innerHTML = bodyHtml;
|
|
}
|
|
|
|
function addNewStack() {
|
|
const newId = Date.now();
|
|
myStacks.push({id: newId, name: `Stack ${myStacks.length + 1}`, list: ""});
|
|
saveAndRender();
|
|
}
|
|
|
|
function deleteStack(id) {
|
|
myStacks = myStacks.filter(s => s.id !== id);
|
|
myStacks.forEach((s, i) => s.name = `Stack ${i + 1}`);
|
|
saveAndRender();
|
|
}
|
|
|
|
function updateStackList(id, text) {
|
|
const stack = myStacks.find(s => s.id === id);
|
|
stack.list = text;
|
|
localStorage.setItem('usmt_workbench_stacks', JSON.stringify(myStacks));
|
|
refreshStackTable(id);
|
|
}
|
|
|
|
function refreshStackTable(stackId) {
|
|
const stack = myStacks.find(s => s.id === stackId);
|
|
const tableBody = document.getElementById(`tbody-${stackId}`);
|
|
if (!tableBody || !apiData.length) return;
|
|
|
|
const inputTerms = stack.list.split(/[\n,]+/).map(n => n.trim().toUpperCase()).filter(n => n);
|
|
|
|
const newRowsHtml = inputTerms.map(term => {
|
|
const match = apiData.find(x => {
|
|
return Object.values(x).some(val =>
|
|
val && typeof val === 'string' && val.toUpperCase().includes(term)
|
|
);
|
|
});
|
|
|
|
if (match) {
|
|
const rowColor = themes[currentTheme].groupColors[match["Backup Status"]] || "var(--bg-card)";
|
|
const pingStatus = match["Destination Online"] || "N/A";
|
|
|
|
const displayDev = (match["Destination Device"] && match["Destination Device"] !== "N/A") ? match["Destination Device"] : match["Source Device"] + " (Src)";
|
|
const destAppsBtn = (match["Destination Device"] && match["Destination Device"] !== "N/A") ?
|
|
`<button class="soft-btn" onclick="event.stopPropagation(); fetchSoftware('${match["Destination Device"]}')">Apps</button>` : `<span style="color:var(--text-muted);">---</span>`;
|
|
|
|
let missingSoftwareRaw = isSoftwareCacheLoaded ? "Waiting for Worker..." : "Loading...";
|
|
let missingSoftwareDisplay = missingSoftwareRaw;
|
|
let src = match["Source Device"];
|
|
let dest = match["Destination Device"];
|
|
|
|
if (Array.isArray(softwareCache[src]) && (dest === "N/A" || dest === "" || Array.isArray(softwareCache[dest]))) {
|
|
let sApps = [...new Set([
|
|
...softwareCache[src].filter(a => isSpecialtyApp(a.Name, src)).map(a => getAppDisplayName(a.Name)),
|
|
...getComboApps(softwareCache[src]).map(c => c.Name)
|
|
])].sort((a, b) => a.localeCompare(b));
|
|
let dApps = (dest === "N/A" || dest === "") ? [] : [
|
|
...softwareCache[dest].map(a => getAppDisplayName(a.Name)),
|
|
...getComboApps(softwareCache[dest]).map(c => c.Name)
|
|
];
|
|
let missing = sApps.filter(a => !dApps.includes(a));
|
|
|
|
missingSoftwareRaw = missing.length > 0 ? missing.join(", ") : "None";
|
|
|
|
let hyperlinkedMissing = missing.map(app => {
|
|
const linkMatch = availableDocs.find(d => d.toLowerCase() === app.toLowerCase());
|
|
return linkMatch
|
|
? `<a href="/docs/${encodeURIComponent(linkMatch)}" target="_blank" onclick="event.stopPropagation();" style="color: #3498db; font-weight: bold;">${app}</a>`
|
|
: app;
|
|
});
|
|
missingSoftwareDisplay = hyperlinkedMissing.length > 0 ? hyperlinkedMissing.join(", ") : "None";
|
|
}
|
|
|
|
return `<tr class="clickable-row" style="background-color: ${rowColor}; cursor: pointer; transition: 0.2s;" onclick="showDeviceDetails('${match["Source Device"]}')">
|
|
<td title="${displayDev}"><b>${displayDev}</b>${inlineCopyBtn(displayDev.replace(" (Src)", ""))}</td>
|
|
<td title="${match["Backup Status"]}"><b>${match["Backup Status"]}</b></td>
|
|
<td style="color: ${getPingColor(pingStatus)}; font-weight: bold;">${pingStatus}</td>
|
|
<td class="no-copy">${destAppsBtn}</td>
|
|
<td title="${match["Destination Loadstate Line"] || "N/A"}">${(match["Backup Status"] === "Restore In Progress" && match["Destination Loadstate Elapsed"] && match["Destination Loadstate Elapsed"] !== "N/A" && match["Destination Loadstate Elapsed"] !== "Cached") ? match["Destination Loadstate Elapsed"] + " " + String(match["Destination Loadstate Line"]).trim().split(/\s+/).pop() : (match["Destination Loadstate Line"] || "N/A")}</td>
|
|
<td title="${missingSoftwareRaw}">${missingSoftwareDisplay}</td>
|
|
<td title="${match["USMT Status"] || "N/A"}">${match["USMT Status"] || "N/A"}</td>
|
|
<td title="${match.Tags || 'None'}">${match.Tags || 'None'}</td>
|
|
<td title="${match["Source Device"]}"><b>${match["Source Device"]}</b>${inlineCopyBtn(match["Source Device"])}</td>
|
|
</tr>`;
|
|
} else {
|
|
return `<tr><td>${term}</td><td>---</td><td>---</td><td class="no-copy">---</td><td>N/A</td><td>N/A</td><td>N/A</td><td>N/A</td><td>N/A</td></tr>`;
|
|
}
|
|
}).join('');
|
|
|
|
if (tableBody.innerHTML !== newRowsHtml) {
|
|
tableBody.innerHTML = newRowsHtml;
|
|
}
|
|
}
|
|
|
|
function saveAndRender() {
|
|
localStorage.setItem('usmt_workbench_stacks', JSON.stringify(myStacks));
|
|
renderStacksOnly();
|
|
}
|
|
|
|
function renderStacksOnly() {
|
|
document.getElementById('stackGrid').innerHTML = myStacks.map(stack => `
|
|
<div class="stack-box table-wrapper" style="padding: 0; overflow-x: auto; border-top: none;">
|
|
<div class="group-header" style="margin-top: 0; border-left: 5px solid var(--table-header-bg); display: flex; justify-content: space-between; border-radius: 8px 8px 0 0;">
|
|
<input type="text" value="${stack.name}" onchange="renameStack(${stack.id}, this.value)" style="background: transparent; border: none; color: inherit; font-weight: bold; font-size: inherit; font-family: inherit; width: 60%; outline: none;" placeholder="Name your stack...">
|
|
<button class="del-stack" style="position: static; color: #e74c3c;" onclick="deleteStack(${stack.id})">Remove</button>
|
|
</div>
|
|
<div style="padding: 15px;">
|
|
<textarea class="auto-resize" oninput="updateStackList(${stack.id}, this.value); this.style.height = 'auto'; this.style.height = this.scrollHeight + 'px';" placeholder="Paste names or partial names...">${stack.list}</textarea>
|
|
<table class="master-table" id="table-${stack.id}">
|
|
<thead><tr>
|
|
<th class="sortable-th" width="12%">Device <span class="copy-col no-copy" onclick="copyColData('${stack.id}', 0, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
<th class="sortable-th" width="10%">Backup Status <span class="copy-col no-copy" onclick="copyColData('${stack.id}', 1, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
<th class="sortable-th" width="8%">Ping <span class="copy-col no-copy" onclick="copyColData('${stack.id}', 2, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
<th width="6%" class="no-copy">Apps</th>
|
|
<th class="sortable-th" width="17%">Loadstate Log <span class="copy-col no-copy" onclick="copyColData('${stack.id}', 4, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
<th class="sortable-th" width="16%">Missing Software <span class="copy-col no-copy" onclick="copyColData('${stack.id}', 5, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
<th class="sortable-th" width="10%">USMT Status <span class="copy-col no-copy" onclick="copyColData('${stack.id}', 6, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
<th class="sortable-th" width="9%">Tags <span class="copy-col no-copy" onclick="copyColData('${stack.id}', 7, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
<th class="sortable-th" width="12%">Source Device <span class="copy-col no-copy" onclick="copyColData('${stack.id}', 8, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
</tr></thead>
|
|
<tbody id="tbody-${stack.id}"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
myStacks.forEach(s => refreshStackTable(s.id));
|
|
|
|
setTimeout(() => {
|
|
document.querySelectorAll('.auto-resize').forEach(ta => {
|
|
ta.style.height = 'auto';
|
|
ta.style.height = ta.scrollHeight + 'px';
|
|
});
|
|
}, 0);
|
|
}
|
|
|
|
function toggleSort(safeG, colIndex) {
|
|
if (!sortStates[safeG] || sortStates[safeG].col !== colIndex) {
|
|
sortStates[safeG] = { col: colIndex, dir: 'asc' };
|
|
} else {
|
|
if (sortStates[safeG].dir === 'asc') sortStates[safeG].dir = 'desc';
|
|
else if (sortStates[safeG].dir === 'desc') sortStates[safeG].dir = 'none';
|
|
else sortStates[safeG].dir = 'asc';
|
|
}
|
|
|
|
if (safeG === 'software') renderSoftwareTable();
|
|
else renderMasterList();
|
|
}
|
|
|
|
function renderSoftwareTable() {
|
|
const tbody = document.getElementById(`tbody-software`);
|
|
if (!tbody || !apiData.length) return;
|
|
|
|
[0, 2, 4, 5, 6].forEach(c => {
|
|
const el = document.getElementById(`icon-software-${c}`);
|
|
if (el) { el.innerText = '↕'; el.classList.remove('active'); }
|
|
});
|
|
|
|
let currentMissingSet = new Set();
|
|
|
|
let displayData = apiData.map(row => {
|
|
let enriched = {...row};
|
|
let src = enriched["Source Device"];
|
|
let dest = enriched["Destination Device"];
|
|
|
|
if (Array.isArray(softwareCache[src]) && (dest === "N/A" || dest === "" || Array.isArray(softwareCache[dest]))) {
|
|
let sApps = [...new Set([
|
|
...softwareCache[src].filter(a => isSpecialtyApp(a.Name, src)).map(a => getAppDisplayName(a.Name)),
|
|
...getComboApps(softwareCache[src]).map(c => c.Name)
|
|
])].sort((a, b) => a.localeCompare(b));
|
|
let dApps = (dest === "N/A" || dest === "") ? [] : [
|
|
...softwareCache[dest].map(a => getAppDisplayName(a.Name)),
|
|
...getComboApps(softwareCache[dest]).map(c => c.Name)
|
|
];
|
|
let missing = sApps.filter(a => !dApps.includes(a));
|
|
|
|
enriched["Missing Software"] = missing.length > 0 ? missing.join(", ") : "None";
|
|
|
|
let hyperlinkedMissing = missing.map(app => {
|
|
const linkMatch = availableDocs.find(d => d.toLowerCase() === app.toLowerCase());
|
|
return linkMatch
|
|
? `<a href="/docs/${encodeURIComponent(linkMatch)}" target="_blank" onclick="event.stopPropagation();" style="color: #3498db; font-weight: bold;">${app}</a>`
|
|
: app;
|
|
});
|
|
enriched["MissingSoftwareDisplay"] = hyperlinkedMissing.length > 0 ? hyperlinkedMissing.join(", ") : "None";
|
|
|
|
missing.forEach(a => currentMissingSet.add(a));
|
|
} else {
|
|
enriched["Missing Software"] = isSoftwareCacheLoaded ? "Waiting for Worker..." : "Loading...";
|
|
enriched["MissingSoftwareDisplay"] = enriched["Missing Software"];
|
|
}
|
|
return enriched;
|
|
});
|
|
|
|
const filterContainer = document.getElementById('missing-options-software');
|
|
const previouslyChecked = Array.from(filterContainer.querySelectorAll('input:checked')).map(cb => cb.value);
|
|
|
|
let sortedMissingList = Array.from(currentMissingSet).sort();
|
|
let filterHtml = `<input type="text" class="multi-search" oninput="filterMultiSelect(this)" placeholder="Search apps...">`;
|
|
const isNoneChecked = previouslyChecked.includes("None") ? 'checked' : '';
|
|
filterHtml += `<label class="option-label" style="border-bottom: 2px solid var(--border-light); font-style: italic;"><input type="checkbox" value="None" onchange="filterSoftwareTab()" ${isNoneChecked}> None</label>`;
|
|
|
|
sortedMissingList.forEach(app => {
|
|
const isChecked = previouslyChecked.includes(app) ? 'checked' : '';
|
|
filterHtml += `<label class="option-label"><input type="checkbox" value="${app}" onchange="filterSoftwareTab()" ${isChecked}> ${app}</label>`;
|
|
});
|
|
filterContainer.innerHTML = filterHtml;
|
|
|
|
const box = document.querySelector(`#missing-filter-software .select-box`);
|
|
if(box) {
|
|
const checked = filterContainer.querySelectorAll('input:checked');
|
|
if(checked.length === 0) box.innerText = "Select Missing Apps...";
|
|
else if(checked.length === 1) box.innerText = checked[0].value;
|
|
else box.innerText = `${checked.length} selected`;
|
|
}
|
|
|
|
if (sortStates['software'] && sortStates['software'].dir !== 'none') {
|
|
const { col, dir } = sortStates['software'];
|
|
const iconEl = document.getElementById(`icon-software-${col}`);
|
|
if (iconEl) {
|
|
iconEl.innerText = dir === 'asc' ? '↑' : '↓';
|
|
iconEl.classList.add('active');
|
|
}
|
|
|
|
const colKeyMap = {
|
|
0: "Source Device", 2: "Destination Device", 4: "Backup Status", 5: "Missing Software", 6: "Tags"
|
|
};
|
|
const key = colKeyMap[col];
|
|
|
|
displayData.sort((a, b) => {
|
|
let valA = (a[key] || "").toString().toUpperCase();
|
|
let valB = (b[key] || "").toString().toUpperCase();
|
|
|
|
if (valA === "LOADING...") return 1;
|
|
if (valB === "LOADING...") return -1;
|
|
|
|
if (valA < valB) return dir === 'asc' ? -1 : 1;
|
|
if (valA > valB) return dir === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
}
|
|
|
|
let rowsHtml = "";
|
|
displayData.forEach(row => {
|
|
const dstName = row["Destination Device"];
|
|
const destAppsBtn = (dstName && dstName !== "N/A") ?
|
|
`<button class="soft-btn" onclick="event.stopPropagation(); fetchSoftware('${dstName}')">Apps</button>` : `<span style="color:var(--text-muted);">---</span>`;
|
|
|
|
rowsHtml += `<tr class="clickable-row" style='background-color:${themes[currentTheme].groupColors[row["Backup Status"]] || "var(--bg-card)"}; cursor: pointer;' onclick="showDeviceDetails('${row["Source Device"]}')">
|
|
<td title="${row["Source Device"]}"><b>${row["Source Device"]}</b>${inlineCopyBtn(row["Source Device"])}</td>
|
|
<td class="no-copy"><button class="soft-btn" onclick="event.stopPropagation(); fetchSoftware('${row["Source Device"]}')">Apps</button></td>
|
|
<td title="${dstName}"><b>${dstName}</b>${inlineCopyBtn(dstName)}</td>
|
|
<td class="no-copy">${destAppsBtn}</td>
|
|
<td title="${row["Backup Status"]}"><b>${row["Backup Status"]}</b></td>
|
|
<td title="${row["Missing Software"]}">${row["MissingSoftwareDisplay"]}</td>
|
|
<td title="${row.Tags || 'None'}">${row.Tags || 'None'}</td>
|
|
<td style="text-align:center;" class="no-copy"><span class="copy-row-btn" title="Copy Row Data" onclick="copyRowData(this, event)">${COPY_SVG}</span></td>
|
|
</tr>`;
|
|
});
|
|
|
|
if (tbody.innerHTML !== rowsHtml) {
|
|
tbody.innerHTML = rowsHtml;
|
|
filterSoftwareTab();
|
|
}
|
|
}
|
|
|
|
// ===== CUSTOM VIEW =====
|
|
// Default base columns (id = catalog id). Specialty Software is source-only (no comparison).
|
|
const CUSTOM_DEFAULT_COLS = ["Source Device", "Destination Device", "Backup Status", "__special", "Tags"];
|
|
|
|
// Friendly labels for known advanced fields. Unknown keys fall back to the raw key.
|
|
const ADV_FIELD_LABELS = {
|
|
IP: "IP", MAC: "MAC", OU: "OU", Groups: "Groups",
|
|
Model: "Hardware Model", RAM: "RAM", Disk: "Physical Disk", ClientStatus: "SCCM Client",
|
|
Department: "Department", Building: "Building", Location: "Location", Vanity: "Vanity",
|
|
PhoneExt: "Phone Ext", Mount: "Mount", RoomType: "Room Type",
|
|
PrimaryUser: "Primary User", LastUser: "Last User", SCCMPrimaryUsers: "SCCM Primary Users",
|
|
Updated: "Asset Info Updated", InfoBy: "Info Entered By", Notes: "Notes",
|
|
MonitorCount: "Monitor Count", Monitors: "Monitors",
|
|
DaysOffline: "Days Offline", LastOnline: "Last Online", LastChecked: "Last Ping Check", FoundIn: "Found In (AD/SCCM/DNS)"
|
|
};
|
|
const ADV_FIELD_ORDER = ["DaysOffline","LastOnline","LastChecked","FoundIn","IP","MAC","OU","Groups","Model","RAM","Disk","MonitorCount","Monitors","ClientStatus","Department","Building","Location","Vanity","PhoneExt","Mount","RoomType","PrimaryUser","LastUser","SCCMPrimaryUsers","Updated","InfoBy","Notes"];
|
|
|
|
let customColumns = JSON.parse(localStorage.getItem('usmt_custom_columns')) || [...CUSTOM_DEFAULT_COLS]; // ordered list of column ids
|
|
let customColWidths = JSON.parse(localStorage.getItem('usmt_custom_widths')) || {}; // id -> px width
|
|
let customWidthsLocked = localStorage.getItem('usmt_custom_widths_locked') === '1'; // true once user manually resizes
|
|
const MIN_COL_W = 110; // minimum column width (fits the header icon row)
|
|
// Per-column filter: { text:"", excluded:[values] }. Excluded stored as arrays in localStorage.
|
|
let customFilters = JSON.parse(localStorage.getItem('usmt_custom_filters')) || {}; // id -> {text, excluded:[]}
|
|
const GRIP_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640"><path d="M152 160C174.1 160 192 177.9 192 200L192 248C192 270.1 174.1 288 152 288L104 288C81.9 288 64 270.1 64 248L64 200C64 177.9 81.9 160 104 160L152 160zM344 288L296 288C273.9 288 256 270.1 256 248L256 200C256 177.9 273.9 160 296 160L344 160C366.1 160 384 177.9 384 200L384 248C384 270.1 366.1 288 344 288zM536 288L488 288C465.9 288 448 270.1 448 248L448 200C448 177.9 465.9 160 488 160L536 160C558.1 160 576 177.9 576 200L576 248C576 270.1 558.1 288 536 288zM536 480L488 480C465.9 480 448 462.1 448 440L448 392C448 369.9 465.9 352 488 352L536 352C558.1 352 576 369.9 576 392L576 440C576 462.1 558.1 480 536 480zM344 352C366.1 352 384 369.9 384 392L384 440C384 462.1 366.1 480 344 480L296 480C273.9 480 256 462.1 256 440L256 392C256 369.9 273.9 352 296 352L344 352zM152 480L104 480C81.9 480 64 462.1 64 440L64 392C64 369.9 81.9 352 104 352L152 352C174.1 352 192 369.9 192 392L192 440C192 462.1 174.1 480 152 480z"/></svg>`;
|
|
const FILTER_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M3.9 54.9C10.5 40.9 24.5 32 40 32L472 32c15.5 0 29.5 8.9 36.1 22.9s4.6 30.5-5.2 42.5L320 320.9 320 448c0 12.1-6.8 23.2-17.7 28.6s-23.8 4.3-33.5-3l-64-48c-8.1-6-12.8-15.5-12.8-25.6l0-78.9L9 97.3C-.7 85.4-2.8 68.8 3.9 54.9z"/></svg>`;
|
|
|
|
function saveCustomState() {
|
|
localStorage.setItem('usmt_custom_columns', JSON.stringify(customColumns));
|
|
localStorage.setItem('usmt_custom_widths', JSON.stringify(customColWidths));
|
|
localStorage.setItem('usmt_custom_filters', JSON.stringify(customFilters));
|
|
localStorage.setItem('usmt_custom_widths_locked', customWidthsLocked ? '1' : '0');
|
|
}
|
|
|
|
// Returns the full column definition for a given catalog id.
|
|
function getCustomColDef(id) {
|
|
const base = {
|
|
"Source Device": { id, label: "Source Device", kind: "apiKey", key: "Source Device" },
|
|
"Destination Device": { id, label: "Dest Device", kind: "apiKey", key: "Destination Device" },
|
|
"Backup Status": { id, label: "Backup Status", kind: "apiKey", key: "Backup Status" },
|
|
"Tags": { id, label: "Tags", kind: "apiKey", key: "Tags" },
|
|
"__special": { id, label: "Specialty Software", kind: "special" },
|
|
"__srcping": { id, label: "Source Ping", kind: "apiKey", key: "Source Online" },
|
|
"__destping": { id, label: "Dest Ping", kind: "apiKey", key: "Destination Online" },
|
|
"__space": { id, label: "Space Available", kind: "migration", fn: "space" },
|
|
"__scanstate": { id, label: "Scanstate Line", kind: "migration", fn: "scanstate" },
|
|
"__loadstate": { id, label: "Loadstate Line", kind: "migration", fn: "loadstate" },
|
|
"__usmt": { id, label: "USMT Status", kind: "apiKey", key: "USMT Status" }
|
|
};
|
|
if (base[id]) return base[id];
|
|
if (id.startsWith("dst:")) { const k = id.slice(4); return { id, label: "Dest " + (ADV_FIELD_LABELS[k] || k), kind: "advDst", key: k }; }
|
|
if (id.startsWith("src:")) { const k = id.slice(4); return { id, label: ADV_FIELD_LABELS[k] || k, kind: "adv", key: k }; }
|
|
return { id, label: id, kind: "apiKey", key: id };
|
|
}
|
|
|
|
// Union of advanced keys across the cache, so new worker fields auto-appear.
|
|
function getAdvKeys() {
|
|
let keys = new Set(ADV_FIELD_ORDER);
|
|
Object.values(advancedDataCache || {}).forEach(obj => {
|
|
if (obj && typeof obj === 'object') Object.keys(obj).forEach(k => { if (!DeviceInfo.HIDDEN.has(k)) keys.add(k); });
|
|
});
|
|
return Array.from(keys).sort((a, b) => {
|
|
let ia = ADV_FIELD_ORDER.indexOf(a), ib = ADV_FIELD_ORDER.indexOf(b);
|
|
if (ia === -1 && ib === -1) return a.localeCompare(b);
|
|
if (ia === -1) return 1; if (ib === -1) return -1; return ia - ib;
|
|
});
|
|
}
|
|
|
|
// Full catalog of selectable column ids, grouped.
|
|
function getCustomCatalog() {
|
|
const advKeys = getAdvKeys();
|
|
return [
|
|
{ group: "Device", ids: ["Source Device", "Destination Device", "Backup Status", "Tags", "__special"] },
|
|
{ group: "Migration", ids: ["__srcping", "__destping", "__space", "__scanstate", "__loadstate", "__usmt"] },
|
|
{ group: "Source Advanced", ids: advKeys.map(k => "src:" + k) },
|
|
{ group: "Destination Advanced", ids: advKeys.map(k => "dst:" + k) }
|
|
];
|
|
}
|
|
|
|
function toggleAddColMenu(event) {
|
|
event.stopPropagation();
|
|
const cont = document.getElementById('addcol-options-custom');
|
|
const isOpening = cont.style.display === 'none' || cont.style.display === '';
|
|
document.querySelectorAll('.table-wrapper, .stack-box').forEach(w => { w.style.paddingBottom = ''; w.style.overflow = ''; });
|
|
document.querySelectorAll('.options-container').forEach(c => c.style.display = 'none');
|
|
if (!isOpening) return;
|
|
let html = `<input type="text" class="multi-search" oninput="filterMultiSelect(this)" placeholder="Search fields...">`;
|
|
getCustomCatalog().forEach(grp => {
|
|
html += `<div class="option-label" style="font-weight:bold; opacity:0.7; pointer-events:none; border-bottom:1px solid var(--border-light);">${grp.group}</div>`;
|
|
grp.ids.forEach(id => {
|
|
const def = getCustomColDef(id);
|
|
const on = customColumns.includes(id);
|
|
html += `<label class="option-label" style="${on ? 'opacity:0.5;' : ''}">
|
|
<input type="checkbox" ${on ? 'checked' : ''} onchange="toggleCustomColumn('${id}')"> ${def.label}</label>`;
|
|
});
|
|
});
|
|
cont.innerHTML = html;
|
|
cont.style.display = 'block';
|
|
}
|
|
|
|
function toggleCustomColumn(id) {
|
|
const i = customColumns.indexOf(id);
|
|
if (i === -1) customColumns.push(id); else customColumns.splice(i, 1);
|
|
customWidthsLocked = false; // re-fit to fill after column set changes
|
|
saveCustomState();
|
|
renderCustomTable();
|
|
}
|
|
|
|
function resetCustomColumns() {
|
|
customColumns = [...CUSTOM_DEFAULT_COLS];
|
|
customColWidths = {};
|
|
customWidthsLocked = false;
|
|
saveCustomState();
|
|
renderCustomTable();
|
|
}
|
|
|
|
// Auto-fit every column to the width of its longest content (header + visible cells).
|
|
function shrinkCustomColumns() {
|
|
const table = document.getElementById('table-custom');
|
|
const tbody = document.getElementById('tbody-custom');
|
|
if (!table || !tbody) return;
|
|
const cols = customColumns.map(getCustomColDef);
|
|
const MIN_W = 110, PAD = 24, ICONS_MIN = 132; // room for header icon row
|
|
|
|
// Measure text width via canvas using the table's computed font.
|
|
const canvas = shrinkCustomColumns._canvas || (shrinkCustomColumns._canvas = document.createElement('canvas'));
|
|
const ctx = canvas.getContext('2d');
|
|
const cs = window.getComputedStyle(table);
|
|
ctx.font = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
|
|
const measure = (txt) => ctx.measureText(String(txt || "")).width;
|
|
|
|
const rows = Array.from(tbody.querySelectorAll('tr')).filter(tr => tr.style.display !== 'none');
|
|
const boldFont = `700 ${cs.fontSize} ${cs.fontFamily}`;
|
|
const normalFont = `${cs.fontWeight} ${cs.fontSize} ${cs.fontFamily}`;
|
|
cols.forEach((c, idx) => {
|
|
// Header label is bold — measure with bold font.
|
|
ctx.font = boldFont;
|
|
let maxTxt = measure(c.label);
|
|
ctx.font = normalFont;
|
|
rows.forEach(tr => {
|
|
const cell = tr.cells[idx];
|
|
if (!cell) return;
|
|
// Split multi-value cells into individual lines: <br>, newlines, and commas.
|
|
const raw = cell.getAttribute('data-val') || cell.textContent || "";
|
|
const lines = raw.split(/<br\s*\/?>|\r?\n|,\s*/i);
|
|
lines.forEach(line => { const w = measure(line.trim()); if (w > maxTxt) maxTxt = w; });
|
|
});
|
|
let w = Math.ceil(Math.max(maxTxt + PAD, ICONS_MIN, MIN_W));
|
|
customColWidths[c.id] = w;
|
|
});
|
|
customWidthsLocked = true; // respect these exact widths (no auto-fill)
|
|
saveCustomState();
|
|
renderCustomTable();
|
|
}
|
|
|
|
function resetCustomFilters() {
|
|
customFilters = {};
|
|
const gs = document.getElementById('searchCustomInput');
|
|
if (gs) gs.value = "";
|
|
closeColFilterPopup();
|
|
saveCustomState();
|
|
renderCustomTable();
|
|
}
|
|
|
|
function resetCustomSort() {
|
|
sortStates['custom'] = { col: -1, dir: 'none' };
|
|
renderCustomTable();
|
|
}
|
|
|
|
// Source-only specialty list (no source-vs-dest comparison).
|
|
function getCustomSpecialty(srcName) {
|
|
if (!Array.isArray(softwareCache[srcName])) return isSoftwareCacheLoaded ? "None" : "Loading...";
|
|
let apps = [...new Set([
|
|
...softwareCache[srcName].filter(a => isSpecialtyApp(a.Name, srcName)).map(a => getAppDisplayName(a.Name)),
|
|
...getComboApps(softwareCache[srcName]).map(c => c.Name)
|
|
])].sort((a, b) => a.localeCompare(b));
|
|
return apps.length > 0 ? apps.join(", ") : "None";
|
|
}
|
|
|
|
function getCustomCellValue(row, col) {
|
|
const blank = v => (v === undefined || v === null || v === "") ? "N/A" : v;
|
|
if (col.kind === "apiKey") return blank(row[col.key]);
|
|
// multi-line inventory values (groups, monitors, disk) read as a comma list in a cell
|
|
if (col.kind === "adv") return DeviceInfo.field(row["Source Device"], col.key).text.replace(/<br\s*\/?>/gi, ", ");
|
|
if (col.kind === "advDst") return DeviceInfo.field(row["Destination Device"], col.key).text.replace(/<br\s*\/?>/gi, ", ");
|
|
if (col.kind === "special") return getCustomSpecialty(row["Source Device"]);
|
|
if (col.kind === "migration") {
|
|
if (col.fn === "space") return (row["Source Free Space"] && row["Source Free Space"] !== "N/A") ? row["Source Free Space"] + " / " + row["Source Total Space"] : "N/A";
|
|
if (col.fn === "scanstate") { let l = blank(row["Source Scanstate Line"]); let e = row["Source Scanstate Elapsed"]; return (e && e !== "N/A" && e !== "Cached") ? l + " (Elapsed " + e + ")" : l; }
|
|
if (col.fn === "loadstate") { let l = blank(row["Destination Loadstate Line"]); let e = row["Destination Loadstate Elapsed"]; return (e && e !== "N/A" && e !== "Cached") ? l + " (Elapsed " + e + ")" : l; }
|
|
}
|
|
return "N/A";
|
|
}
|
|
|
|
// Returns {td-inner-html, cellStyle} for a column, replicating All Devices styling.
|
|
function renderCustomCell(row, col, val) {
|
|
const safe = String(val).replace(/"/g, '"');
|
|
// Ping columns: colored + bold
|
|
if (col.id === "__srcping" || col.id === "__destping") {
|
|
return { html: `<b>${val}</b>`, style: `color:${getPingColor(val)}; font-weight:bold;` };
|
|
}
|
|
// Device names: bold + inline copy
|
|
if (col.kind === "apiKey" && (col.key === "Source Device" || col.key === "Destination Device")) {
|
|
return { html: `<b>${val}</b>${inlineCopyBtn(String(val))}`, style: "" };
|
|
}
|
|
// Backup / USMT status: bold
|
|
if (col.key === "Backup Status" || col.id === "__usmt") {
|
|
return { html: `<b>${val}</b>`, style: "" };
|
|
}
|
|
// Specialty Software: hyperlink any app that has documentation
|
|
if (col.kind === "special") {
|
|
if (!val || val === "None" || val === "Loading...") return { html: val, style: "" };
|
|
const linked = val.split(", ").map(app => {
|
|
const m = availableDocs.find(d => d.toLowerCase() === app.toLowerCase());
|
|
return m
|
|
? `<a href="?doc=${encodeURIComponent(m)}" target="_blank" onclick="event.stopPropagation();" style="color:#3498db; font-weight:bold;">${app}</a>`
|
|
: app;
|
|
});
|
|
return { html: linked.join(", "), style: "" };
|
|
}
|
|
return { html: safe, style: "" };
|
|
}
|
|
|
|
function renderCustomTable() {
|
|
const headerRow = document.getElementById('custom-header-row');
|
|
const tbody = document.getElementById('tbody-custom');
|
|
const wrapper = document.getElementById('wrapper-custom');
|
|
const table = document.getElementById('table-custom');
|
|
if (!headerRow || !tbody || !apiData.length) return;
|
|
|
|
const cols = customColumns.map(getCustomColDef);
|
|
|
|
const DEFAULT_COL_W = 180;
|
|
wrapper.classList.add('has-extra');
|
|
table.style.tableLayout = 'fixed';
|
|
|
|
// Base widths (user-set or default).
|
|
const baseW = cols.map(c => customColWidths[c.id] || DEFAULT_COL_W);
|
|
const baseSum = baseW.reduce((s, w) => s + w, 0);
|
|
// Available inner width of the scroll area (minus a small margin so borders don't overflow).
|
|
const scrollArea = document.getElementById('scroll-custom');
|
|
let avail = scrollArea ? scrollArea.clientWidth : 0;
|
|
if (avail < 50 && scrollArea) avail = Math.round(scrollArea.getBoundingClientRect().width);
|
|
avail = Math.max(0, avail - 2);
|
|
// If columns underfill, scale them up proportionally to fill; else keep set widths (scroll).
|
|
let renderW = baseW.slice();
|
|
if (!customWidthsLocked && avail > baseSum && baseSum > 0) {
|
|
const scale = avail / baseSum;
|
|
renderW = baseW.map(w => Math.floor(w * scale));
|
|
// Give rounding remainder to the last column so it fills exactly.
|
|
const used = renderW.reduce((s, w) => s + w, 0);
|
|
renderW[renderW.length - 1] += (avail - used);
|
|
}
|
|
const tableW = Math.max(avail, baseSum);
|
|
table.style.width = tableW + 'px';
|
|
table.style.minWidth = baseSum + 'px';
|
|
|
|
// Header — grip strip | icons row | full-width label row
|
|
let headHtml = "";
|
|
cols.forEach((c, idx) => {
|
|
const wpx = renderW[idx];
|
|
const grip = `<span class="col-grip no-copy" title="Drag to reorder" draggable="true" data-idx="${idx}">${GRIP_SVG}</span>`;
|
|
const fActive = isColFilterActive(c.id) ? ' active' : '';
|
|
headHtml += `<th class="sortable-th custom-th" style="width:${wpx}px;" data-colid="${c.id}" data-idx="${idx}" title="${String(c.label).replace(/"/g,'"')}">
|
|
<div class="col-grip-row">${grip}</div>
|
|
<div class="th-icons">
|
|
<span class="col-copy copy-col no-copy" onclick="event.stopPropagation(); copyColData('custom', ${idx}, this, event);" title="Copy Column">${COPY_SVG}</span>
|
|
<span class="col-filter-btn no-copy${fActive}" title="Filter" onclick="event.stopPropagation(); openColFilter('${c.id}', ${idx}, this);">${FILTER_SVG}</span>
|
|
<span class="sort-icon-inline no-copy" id="icon-custom-${idx}" title="Sort" onclick="event.stopPropagation(); toggleCustomSort(${idx});">↕</span>
|
|
<span class="col-remove no-copy" title="Remove Column" onclick="event.stopPropagation(); toggleCustomColumn('${c.id}');">✕</span>
|
|
</div>
|
|
<div class="col-label-row" onclick="toggleCustomSort(${idx})"><span class="label-text">${c.label}</span></div></th>`;
|
|
});
|
|
headerRow.innerHTML = headHtml;
|
|
attachColDragHandlers();
|
|
|
|
// Sorting
|
|
let displayData = apiData.slice();
|
|
const st = sortStates['custom'];
|
|
if (st && st.dir !== 'none' && st.col < cols.length) {
|
|
const col = cols[st.col];
|
|
displayData.sort((a, b) => {
|
|
let va = getCustomCellValue(a, col).toString().toUpperCase();
|
|
let vb = getCustomCellValue(b, col).toString().toUpperCase();
|
|
if (va < vb) return st.dir === 'asc' ? -1 : 1;
|
|
if (va > vb) return st.dir === 'asc' ? 1 : -1;
|
|
return 0;
|
|
});
|
|
const ic = document.getElementById(`icon-custom-${st.col}`);
|
|
if (ic) { ic.innerText = st.dir === 'asc' ? '↑' : '↓'; ic.classList.add('active'); }
|
|
}
|
|
|
|
// Body — store each cell's plain value on the row for filtering.
|
|
let rowsHtml = "";
|
|
displayData.forEach(row => {
|
|
const bg = themes[currentTheme].groupColors[row["Backup Status"]] || "var(--bg-card)";
|
|
rowsHtml += `<tr class="clickable-row" style="background-color:${bg}; cursor:pointer;" onclick="showDeviceDetails('${row["Source Device"]}')">`;
|
|
cols.forEach(c => {
|
|
let val = getCustomCellValue(row, c);
|
|
let safe = String(val).replace(/"/g, '"');
|
|
const cell = renderCustomCell(row, c, val);
|
|
rowsHtml += `<td title="${safe}" data-val="${safe}"${cell.style ? ` style="${cell.style}"` : ''}>${cell.html}</td>`;
|
|
});
|
|
rowsHtml += `</tr>`;
|
|
});
|
|
tbody.innerHTML = rowsHtml;
|
|
|
|
const cnt = document.getElementById('customCount');
|
|
if (cnt) cnt.innerText = `(${displayData.length})`;
|
|
|
|
filterCustomTab();
|
|
requestAnimationFrame(() => {
|
|
fitCustomTableWidth();
|
|
requestAnimationFrame(() => { fitCustomTableWidth(); buildResizeHandles(); });
|
|
});
|
|
}
|
|
|
|
// Recompute column widths to fill the scroll area (called after layout settles / on resize).
|
|
function fitCustomTableWidth() {
|
|
const table = document.getElementById('table-custom');
|
|
const scrollArea = document.getElementById('scroll-custom');
|
|
if (!table || !scrollArea) return;
|
|
const ths = Array.from(table.querySelectorAll('#custom-header-row > th.custom-th'));
|
|
if (!ths.length) return;
|
|
const cols = customColumns.map(getCustomColDef);
|
|
const DEFAULT_COL_W = 180;
|
|
const baseW = cols.map(c => customColWidths[c.id] || DEFAULT_COL_W);
|
|
const baseSum = baseW.reduce((s, w) => s + w, 0);
|
|
let avail = scrollArea.clientWidth;
|
|
if (avail < 50) avail = Math.round(scrollArea.getBoundingClientRect().width);
|
|
avail = Math.max(0, avail - 2);
|
|
let renderW = baseW.slice();
|
|
if (!customWidthsLocked && avail > baseSum && baseSum > 0) {
|
|
const scale = avail / baseSum;
|
|
renderW = baseW.map(w => Math.floor(w * scale));
|
|
const used = renderW.reduce((s, w) => s + w, 0);
|
|
renderW[renderW.length - 1] += (avail - used);
|
|
}
|
|
ths.forEach((th, i) => { if (renderW[i]) th.style.width = renderW[i] + 'px'; });
|
|
table.style.width = Math.max(avail, baseSum) + 'px';
|
|
table.style.minWidth = baseSum + 'px';
|
|
|
|
// Post-layout correction: if still overflowing horizontally in fill mode, trim the last column.
|
|
if (!customWidthsLocked) {
|
|
const overflow = table.offsetWidth - scrollArea.clientWidth;
|
|
if (overflow > 0 && ths.length) {
|
|
const lastTh = ths[ths.length - 1];
|
|
const lastW = parseInt(lastTh.style.width) || lastTh.offsetWidth;
|
|
const trimmed = Math.max(MIN_COL_W, lastW - overflow - 1);
|
|
lastTh.style.width = trimmed + 'px';
|
|
table.style.width = (table.offsetWidth - (lastW - trimmed)) + 'px';
|
|
}
|
|
}
|
|
if (!window._customFitBound) {
|
|
window.addEventListener('resize', () => {
|
|
if (document.getElementById('custom').style.display !== 'none') { fitCustomTableWidth(); buildResizeHandles(); }
|
|
});
|
|
window._customFitBound = true;
|
|
}
|
|
}
|
|
|
|
// Build full-height resize handles overlaid at each column's right boundary.
|
|
function buildResizeHandles() {
|
|
const overlay = document.getElementById('custom-resize-overlay');
|
|
const table = document.getElementById('table-custom');
|
|
const host = document.getElementById('custom-table-host');
|
|
if (!overlay || !table || !host) return;
|
|
if (!window._customResizeBound) {
|
|
window.addEventListener('resize', () => { if (document.getElementById('custom').style.display !== 'none') buildResizeHandles(); });
|
|
window._customResizeBound = true;
|
|
}
|
|
overlay.innerHTML = "";
|
|
overlay.style.height = table.offsetHeight + 'px';
|
|
const ths = Array.from(table.querySelectorAll('#custom-header-row > th.custom-th'));
|
|
ths.forEach(th => {
|
|
const colId = th.getAttribute('data-colid');
|
|
const rect = th.getBoundingClientRect();
|
|
const hostRect = host.getBoundingClientRect();
|
|
const x = rect.right - hostRect.left; // boundary at column's right edge
|
|
const handle = document.createElement('div');
|
|
handle.className = 'rz-handle';
|
|
handle.style.left = x + 'px';
|
|
handle.dataset.colid = colId;
|
|
handle.addEventListener('mousedown', e => startColResize(e, colId, th));
|
|
overlay.appendChild(handle);
|
|
});
|
|
}
|
|
|
|
function startColResize(e, colId, th) {
|
|
e.preventDefault(); e.stopPropagation();
|
|
window._customResizing = true;
|
|
const table = document.getElementById('table-custom');
|
|
// Snapshot every column's current rendered width as its base, then lock (no auto-scaling).
|
|
const ths = Array.from(table.querySelectorAll('#custom-header-row > th.custom-th'));
|
|
ths.forEach(h => { const id = h.getAttribute('data-colid'); customColWidths[id] = Math.round(h.getBoundingClientRect().width); });
|
|
customWidthsLocked = true;
|
|
const startX = e.pageX;
|
|
const startW = th.getBoundingClientRect().width;
|
|
const startTableW = table.getBoundingClientRect().width;
|
|
document.body.style.cursor = 'col-resize';
|
|
function onMove(ev) {
|
|
const delta = ev.pageX - startX; // rightward = wider
|
|
const newW = Math.max(MIN_COL_W, Math.round(startW + delta));
|
|
customColWidths[colId] = newW;
|
|
th.style.width = newW + 'px';
|
|
const newTableW = Math.max(200, Math.round(startTableW + (newW - startW)));
|
|
table.style.width = newTableW + 'px';
|
|
table.style.minWidth = newTableW + 'px';
|
|
buildResizeHandles(); // keep handles aligned while dragging
|
|
}
|
|
function onUp() {
|
|
document.removeEventListener('mousemove', onMove);
|
|
document.removeEventListener('mouseup', onUp);
|
|
document.body.style.cursor = '';
|
|
saveCustomState();
|
|
setTimeout(() => { window._customResizing = false; }, 50);
|
|
}
|
|
document.addEventListener('mousemove', onMove);
|
|
document.addEventListener('mouseup', onUp);
|
|
}
|
|
|
|
function toggleCustomSort(col) {
|
|
let st = sortStates['custom'] || { col: -1, dir: 'none' };
|
|
if (st.col === col) {
|
|
st.dir = st.dir === 'asc' ? 'desc' : (st.dir === 'desc' ? 'none' : 'asc');
|
|
} else {
|
|
st.col = col; st.dir = 'asc';
|
|
}
|
|
sortStates['custom'] = st;
|
|
renderCustomTable();
|
|
}
|
|
|
|
function moveCustomColumn(fromIdx, toIdx) {
|
|
if (fromIdx === toIdx || fromIdx < 0 || toIdx < 0) return;
|
|
const moved = customColumns.splice(fromIdx, 1)[0];
|
|
customColumns.splice(toIdx, 0, moved);
|
|
sortStates['custom'] = { col: -1, dir: 'none' }; // indices shifted; reset sort
|
|
saveCustomState();
|
|
renderCustomTable();
|
|
}
|
|
|
|
function attachColDragHandlers() {
|
|
let dragFromIdx = null;
|
|
// Only the grip starts a drag; the th itself is not draggable.
|
|
document.querySelectorAll('#table-custom .col-grip').forEach(grip => {
|
|
grip.addEventListener('dragstart', e => {
|
|
dragFromIdx = parseInt(grip.dataset.idx);
|
|
const th = grip.closest('th'); if (th) th.classList.add('dragging');
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
try { e.dataTransfer.setData('text/plain', String(dragFromIdx)); } catch (_) {}
|
|
});
|
|
grip.addEventListener('dragend', () => {
|
|
document.querySelectorAll('#table-custom th.dragging').forEach(x => x.classList.remove('dragging'));
|
|
document.querySelectorAll('#table-custom th.drag-over').forEach(x => x.classList.remove('drag-over'));
|
|
});
|
|
});
|
|
document.querySelectorAll('#table-custom th.custom-th').forEach(th => {
|
|
th.addEventListener('dragover', e => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; th.classList.add('drag-over'); });
|
|
th.addEventListener('dragleave', () => th.classList.remove('drag-over'));
|
|
th.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
th.classList.remove('drag-over');
|
|
let from = dragFromIdx;
|
|
if (from === null) { let d = parseInt(e.dataTransfer.getData('text/plain')); from = isNaN(d) ? null : d; }
|
|
const to = parseInt(th.dataset.idx);
|
|
if (from !== null && !isNaN(to)) moveCustomColumn(from, to);
|
|
dragFromIdx = null;
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---- Excel-style per-column filtering ----
|
|
function isColFilterActive(colId) {
|
|
const f = customFilters[colId];
|
|
return !!(f && ((f.text && f.text.trim() !== "") || (f.excluded && f.excluded.length > 0)));
|
|
}
|
|
|
|
// Does a value pass a column's filter? text = OR of space-separated terms; excluded = hidden values.
|
|
function valuePassesColFilter(colId, val) {
|
|
const f = customFilters[colId];
|
|
if (!f) return true;
|
|
const v = String(val);
|
|
if (f.excluded && f.excluded.length && f.excluded.includes(v)) return false;
|
|
if (f.text && f.text.trim() !== "") {
|
|
const terms = f.text.toUpperCase().split(/\s+/).filter(Boolean);
|
|
if (terms.length && !terms.some(t => v.toUpperCase().includes(t))) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function filterCustomTab() {
|
|
const tbody = document.getElementById('tbody-custom');
|
|
if (!tbody) return;
|
|
const globalQ = (document.getElementById('searchCustomInput').value || "").toUpperCase();
|
|
const cols = customColumns.map(getCustomColDef);
|
|
let visible = 0;
|
|
tbody.querySelectorAll('tr').forEach(tr => {
|
|
let show = true;
|
|
if (globalQ) show = tr.textContent.toUpperCase().includes(globalQ);
|
|
if (show) {
|
|
for (let i = 0; i < cols.length; i++) {
|
|
const cell = tr.cells[i];
|
|
const val = cell ? (cell.getAttribute('data-val') || cell.textContent) : "";
|
|
if (!valuePassesColFilter(cols[i].id, val)) { show = false; break; }
|
|
}
|
|
}
|
|
tr.style.display = show ? '' : 'none';
|
|
if (show) visible++;
|
|
});
|
|
const cnt = document.getElementById('customCount');
|
|
if (cnt) cnt.innerText = `(${visible})`;
|
|
if (typeof buildResizeHandles === 'function') requestAnimationFrame(buildResizeHandles);
|
|
}
|
|
|
|
let _activeFilterCol = null;
|
|
function closeColFilterPopup() {
|
|
const p = document.getElementById('colFilterPopup');
|
|
if (p) p.classList.remove('show');
|
|
_activeFilterCol = null;
|
|
}
|
|
|
|
function openColFilter(colId, idx, anchor) {
|
|
let popup = document.getElementById('colFilterPopup');
|
|
if (!popup) {
|
|
popup = document.createElement('div');
|
|
popup.id = 'colFilterPopup';
|
|
popup.className = 'col-filter-popup';
|
|
popup.addEventListener('click', e => e.stopPropagation());
|
|
document.body.appendChild(popup);
|
|
}
|
|
if (_activeFilterCol === colId && popup.classList.contains('show')) { closeColFilterPopup(); return; }
|
|
_activeFilterCol = colId;
|
|
|
|
const col = getCustomColDef(colId);
|
|
const f = customFilters[colId] || { text: "", excluded: [] };
|
|
// Distinct values among rows that pass ALL OTHER columns' filters + global search.
|
|
const globalQ = (document.getElementById('searchCustomInput').value || "").toUpperCase();
|
|
const cols = customColumns.map(getCustomColDef);
|
|
const colIndex = customColumns.indexOf(colId);
|
|
const tbody = document.getElementById('tbody-custom');
|
|
const valueSet = new Map(); // value -> true
|
|
tbody.querySelectorAll('tr').forEach(tr => {
|
|
if (globalQ && !tr.textContent.toUpperCase().includes(globalQ)) return;
|
|
for (let i = 0; i < cols.length; i++) {
|
|
if (i === colIndex) continue;
|
|
const cell = tr.cells[i];
|
|
const v = cell ? (cell.getAttribute('data-val') || cell.textContent) : "";
|
|
if (!valuePassesColFilter(cols[i].id, v)) return;
|
|
}
|
|
const cell = tr.cells[colIndex];
|
|
const v = cell ? (cell.getAttribute('data-val') || cell.textContent) : "";
|
|
valueSet.set(v, true);
|
|
});
|
|
const values = Array.from(valueSet.keys()).sort((a, b) => a.localeCompare(b));
|
|
|
|
let listHtml = "";
|
|
values.forEach(v => {
|
|
const checked = !(f.excluded && f.excluded.includes(v)) ? 'checked' : '';
|
|
const disp = v === "" ? "(blank)" : v;
|
|
listHtml += `<label class="cfp-opt" title="${String(disp).replace(/"/g,'"')}"><input type="checkbox" data-val="${String(v).replace(/"/g,'"')}" ${checked} onchange="onColFilterCheck('${colId}')"> <span>${disp}</span></label>`;
|
|
});
|
|
|
|
popup.innerHTML = `
|
|
<div class="cfp-section">
|
|
<input type="text" class="cfp-text" id="cfpText" placeholder="Filter text (space = OR)..." value="${(f.text||'').replace(/"/g,'"')}" oninput="onColFilterText('${colId}')">
|
|
<div class="cfp-hint">Type terms to match; multiple terms are OR'd.</div>
|
|
</div>
|
|
<div class="cfp-section">
|
|
<input type="text" class="cfp-text" id="cfpSearch" placeholder="Search the values below..." oninput="onColFilterSearch()">
|
|
</div>
|
|
<div class="cfp-list" id="cfpList">${listHtml || '<div class="cfp-opt">No values</div>'}</div>
|
|
<div class="cfp-actions">
|
|
<button onclick="colFilterSelectAll('${colId}', true)">Select All</button>
|
|
<button onclick="colFilterSelectAll('${colId}', false)">Clear</button>
|
|
</div>`;
|
|
|
|
// Position under the anchor icon.
|
|
const r = anchor.getBoundingClientRect();
|
|
popup.style.top = (r.bottom + 6) + 'px';
|
|
let left = r.right - 280;
|
|
if (left < 8) left = 8;
|
|
popup.style.left = left + 'px';
|
|
popup.classList.add('show');
|
|
const sc = document.getElementById('scroll-custom');
|
|
if (sc && !sc._cfpScrollBound) { sc.addEventListener('scroll', closeColFilterPopup); sc._cfpScrollBound = true; }
|
|
setTimeout(() => { const t = document.getElementById('cfpText'); if (t) t.focus(); }, 0);
|
|
}
|
|
|
|
function ensureFilter(colId) {
|
|
if (!customFilters[colId]) customFilters[colId] = { text: "", excluded: [] };
|
|
return customFilters[colId];
|
|
}
|
|
|
|
function onColFilterText(colId) {
|
|
const t = document.getElementById('cfpText');
|
|
ensureFilter(colId).text = t ? t.value : "";
|
|
saveCustomState(); filterCustomTab(); refreshFilterIcon(colId);
|
|
}
|
|
|
|
function onColFilterSearch() {
|
|
const q = (document.getElementById('cfpSearch').value || "").toUpperCase();
|
|
document.querySelectorAll('#cfpList .cfp-opt').forEach(lbl => {
|
|
lbl.style.display = lbl.textContent.toUpperCase().includes(q) ? '' : 'none';
|
|
});
|
|
}
|
|
|
|
function onColFilterCheck(colId) {
|
|
const f = ensureFilter(colId);
|
|
const excluded = [];
|
|
document.querySelectorAll('#cfpList input[type=checkbox]').forEach(cb => {
|
|
if (!cb.checked) excluded.push(cb.getAttribute('data-val'));
|
|
});
|
|
// Preserve exclusions for values not currently listed (filtered out by other columns).
|
|
const listed = new Set(Array.from(document.querySelectorAll('#cfpList input[type=checkbox]')).map(cb => cb.getAttribute('data-val')));
|
|
(f.excluded || []).forEach(v => { if (!listed.has(v) && !excluded.includes(v)) excluded.push(v); });
|
|
f.excluded = excluded;
|
|
saveCustomState(); filterCustomTab(); refreshFilterIcon(colId);
|
|
}
|
|
|
|
function colFilterSelectAll(colId, selectAll) {
|
|
const f = ensureFilter(colId);
|
|
document.querySelectorAll('#cfpList input[type=checkbox]').forEach(cb => { cb.checked = selectAll; });
|
|
onColFilterCheck(colId);
|
|
}
|
|
|
|
function refreshFilterIcon(colId) {
|
|
const idx = customColumns.indexOf(colId);
|
|
if (idx < 0) return;
|
|
const btn = document.querySelector(`#table-custom th[data-idx="${idx}"] .col-filter-btn`);
|
|
if (btn) btn.classList.toggle('active', isColFilterActive(colId));
|
|
}
|
|
|
|
function filterSoftwareTab() {
|
|
const globalSearch = document.getElementById('searchSoftwareInput').value.toUpperCase();
|
|
const tbody = document.getElementById(`tbody-software`);
|
|
if (!tbody) return;
|
|
|
|
const inputs = document.querySelectorAll(`.filter-row .col-filter-sw`);
|
|
|
|
const tagContainer = document.getElementById(`tags-options-software`);
|
|
let selectedTags = [];
|
|
if(tagContainer) selectedTags = Array.from(tagContainer.querySelectorAll('input:checked')).map(cb => cb.value.toUpperCase());
|
|
|
|
const missingContainer = document.getElementById(`missing-options-software`);
|
|
let selectedMissing = [];
|
|
if(missingContainer) selectedMissing = Array.from(missingContainer.querySelectorAll('input:checked')).map(cb => cb.value.toUpperCase());
|
|
const statusContainer = document.getElementById(`status-options-software`);
|
|
let selectedStatus = [];
|
|
if(statusContainer) selectedStatus = Array.from(statusContainer.querySelectorAll('input:checked')).map(cb => cb.value.toUpperCase());
|
|
|
|
const rows = tbody.getElementsByTagName('tr');
|
|
|
|
for (let i = 0; i < rows.length; i++) {
|
|
let show = true;
|
|
const tr = rows[i];
|
|
|
|
inputs.forEach(input => {
|
|
if (!show) return;
|
|
const val = input.value.toUpperCase();
|
|
if (val) {
|
|
const colIdx = parseInt(input.dataset.col);
|
|
const cellText = tr.cells[colIdx].textContent.toUpperCase();
|
|
if (!cellText.includes(val)) show = false;
|
|
}
|
|
});
|
|
|
|
if (show && selectedMissing.length > 0) {
|
|
const cellText = tr.cells[5].textContent.toUpperCase();
|
|
const hasApp = selectedMissing.some(selected => cellText.includes(selected));
|
|
if(!hasApp) show = false;
|
|
}
|
|
|
|
if (show && selectedTags.length > 0) {
|
|
const cellText = tr.cells[6].textContent.toUpperCase();
|
|
const rowTags = cellText.split(',').map(t => t.trim());
|
|
const hasTag = selectedTags.some(selected => rowTags.includes(selected));
|
|
if(!hasTag) show = false;
|
|
}
|
|
|
|
if (show && selectedStatus.length > 0) {
|
|
const cellText = tr.cells[4].textContent.toUpperCase();
|
|
if(!selectedStatus.includes(cellText)) show = false;
|
|
}
|
|
|
|
if (show && globalSearch) {
|
|
const srcDevice = tr.cells[0].textContent.trim();
|
|
const apiRow = apiData.find(d => d["Source Device"] === srcDevice);
|
|
let rowText = tr.textContent.toUpperCase();
|
|
|
|
if (apiRow) {
|
|
const advData = advancedDataCache[srcDevice] || {};
|
|
// Dynamically grab all advanced fields to make everything searchable
|
|
const searchVals = Object.values(advData).filter(v => v !== null && v !== undefined);
|
|
rowText += ' ' + searchVals.join(' ').toUpperCase();
|
|
}
|
|
|
|
const searchTerms = globalSearch.split(',').map(term => term.trim()).filter(term => term !== "");
|
|
|
|
if (searchTerms.length > 0) {
|
|
const hasMatch = searchTerms.some(term => rowText.includes(term));
|
|
if (!hasMatch) show = false;
|
|
}
|
|
}
|
|
|
|
tr.style.display = show ? '' : 'none';
|
|
}
|
|
|
|
const missingBox = document.querySelector(`#missing-filter-software .select-box`);
|
|
if (missingContainer && missingBox) {
|
|
const checkedMissing = missingContainer.querySelectorAll('input:checked');
|
|
if(checkedMissing.length === 0) missingBox.innerText = "Select Missing Apps...";
|
|
else if(checkedMissing.length === 1) missingBox.innerText = checkedMissing[0].value;
|
|
else missingBox.innerText = `${checkedMissing.length} selected`;
|
|
}
|
|
|
|
const statusBox = document.querySelector(`#status-filter-software .select-box`);
|
|
if (statusContainer && statusBox) {
|
|
const checkedStatus = statusContainer.querySelectorAll('input:checked');
|
|
if(checkedStatus.length === 0) statusBox.innerText = "Select Status...";
|
|
else if(checkedStatus.length === 1) statusBox.innerText = checkedStatus[0].value;
|
|
else statusBox.innerText = `${checkedStatus.length} selected`;
|
|
}
|
|
|
|
updateTotalCounts();
|
|
}
|
|
|
|
function initMasterTables() {
|
|
const listDiv = document.getElementById('masterList');
|
|
let html = "";
|
|
|
|
groupsArray.forEach(g => {
|
|
let safeG = g.replace(/ /g, '-');
|
|
html += `
|
|
<div id="group-container-${safeG}" style="display:none; margin-bottom: 25px;">
|
|
<div class='group-header' style="border-radius: 8px 8px 0 0;">
|
|
<span id="group-header-${safeG}">${g} (0)</span>
|
|
<div style="display: flex; gap: 8px;">
|
|
<button class="copy-table-btn no-copy" onclick="copyLocationInfo('${safeG}', this)">${LOCATION_SVG} Copy Location Info</button>
|
|
<button class="copy-table-btn no-copy" onclick="copyTableRich('${safeG}', this)">${COPY_SVG} Copy Table Data</button>
|
|
</div>
|
|
</div>
|
|
<div class="table-wrapper" style="overflow-x: auto; background: var(--bg-card); border-radius: 0 0 8px 8px; box-shadow: 0 4px 6px rgba(0,0,0,0.3); transition: padding-bottom 0.2s;">
|
|
<table class='master-table' id="table-${safeG}">
|
|
<thead>
|
|
<tr>
|
|
<th class="sortable-th" width='10%' onclick="toggleSort('${safeG}', 0)">Source Device <span class="sort-icon" id="icon-${safeG}-0">↕</span> <span class="copy-col no-copy" onclick="copyColData('${safeG}', 0, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
<th class="sortable-th" width='6%' onclick="toggleSort('${safeG}', 1)">Src Ping <span class="sort-icon" id="icon-${safeG}-1">↕</span> <span class="copy-col no-copy" onclick="copyColData('${safeG}', 1, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
<th width='6%' class="no-copy">Src Apps</th>
|
|
<th class="sortable-th" width='10%' onclick="toggleSort('${safeG}', 3)">Scanstate Line <span class="sort-icon" id="icon-${safeG}-3">↕</span> <span class="copy-col no-copy" onclick="copyColData('${safeG}', 3, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
<th class="sortable-th" width='7%' onclick="toggleSort('${safeG}', 4)">Space Avail <span class="sort-icon" id="icon-${safeG}-4">↕</span> <span class="copy-col no-copy" onclick="copyColData('${safeG}', 4, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
<th class="sortable-th" width='10%' onclick="toggleSort('${safeG}', 5)">Dest Device <span class="sort-icon" id="icon-${safeG}-5">↕</span> <span class="copy-col no-copy" onclick="copyColData('${safeG}', 5, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
<th class="sortable-th" width='6%' onclick="toggleSort('${safeG}', 6)">Dest Ping <span class="sort-icon" id="icon-${safeG}-6">↕</span> <span class="copy-col no-copy" onclick="copyColData('${safeG}', 6, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
<th width='6%' class="no-copy">Dest Apps</th>
|
|
<th class="sortable-th" width='10%' onclick="toggleSort('${safeG}', 8)">Loadstate Line <span class="sort-icon" id="icon-${safeG}-8">↕</span> <span class="copy-col no-copy" onclick="copyColData('${safeG}', 8, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
<th class="sortable-th" width='8%' onclick="toggleSort('${safeG}', 9)">USMT Status <span class="sort-icon" id="icon-${safeG}-9">↕</span> <span class="copy-col no-copy" onclick="copyColData('${safeG}', 9, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
<th class="sortable-th" width='17%' onclick="toggleSort('${safeG}', 10)">Tags <span class="sort-icon" id="icon-${safeG}-10">↕</span> <span class="copy-col no-copy" onclick="copyColData('${safeG}', 10, this, event)" title="Copy Column">${COPY_SVG}</span></th>
|
|
<th width='4%' style="text-align:center; cursor:pointer;" title="Copy Headers" onclick="copyTableHeaders('${safeG}', this, event)"><span class="copy-row-btn">${COPY_SVG}</span></th>
|
|
</tr>
|
|
<tr class="filter-row">
|
|
<th><input type="text" class="col-filter" data-group="${safeG}" data-col="0" oninput="applyFilters('${safeG}')" placeholder="Filter..."></th>
|
|
<th>
|
|
<div class="multi-select" id="srcping-filter-${safeG}">
|
|
<div class="select-box" onclick="toggleMultiSelect('srcping-${safeG}')">Select Ping...</div>
|
|
<div class="options-container" id="srcping-options-${safeG}">
|
|
<label class="option-label"><input type="checkbox" value="Online" onchange="applyFilters('${safeG}')"> Online</label>
|
|
<label class="option-label"><input type="checkbox" value="Timed Out" onchange="applyFilters('${safeG}')"> Timed Out</label>
|
|
<label class="option-label"><input type="checkbox" value="Not Found" onchange="applyFilters('${safeG}')"> Not Found</label>
|
|
<label class="option-label"><input type="checkbox" value="Cached" onchange="applyFilters('${safeG}')"> Cached</label>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
<th class="no-copy"></th>
|
|
<th><input type="text" class="col-filter" data-group="${safeG}" data-col="3" oninput="applyFilters('${safeG}')" placeholder="Filter..."></th>
|
|
<th><input type="text" class="col-filter" data-group="${safeG}" data-col="4" oninput="applyFilters('${safeG}')" placeholder="Filter..."></th>
|
|
<th><input type="text" class="col-filter" data-group="${safeG}" data-col="5" oninput="applyFilters('${safeG}')" placeholder="Filter..."></th>
|
|
<th>
|
|
<div class="multi-select" id="destping-filter-${safeG}">
|
|
<div class="select-box" onclick="toggleMultiSelect('destping-${safeG}')">Select Ping...</div>
|
|
<div class="options-container" id="destping-options-${safeG}">
|
|
<label class="option-label"><input type="checkbox" value="Online" onchange="applyFilters('${safeG}')"> Online</label>
|
|
<label class="option-label"><input type="checkbox" value="Timed Out" onchange="applyFilters('${safeG}')"> Timed Out</label>
|
|
<label class="option-label"><input type="checkbox" value="Not Found" onchange="applyFilters('${safeG}')"> Not Found</label>
|
|
<label class="option-label"><input type="checkbox" value="Cached" onchange="applyFilters('${safeG}')"> Cached</label>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
<th class="no-copy"></th>
|
|
<th><input type="text" class="col-filter" data-group="${safeG}" data-col="8" oninput="applyFilters('${safeG}')" placeholder="Filter..."></th>
|
|
<th>
|
|
<div class="multi-select" id="usmt-filter-${safeG}">
|
|
<div class="select-box" onclick="toggleMultiSelect('usmt-${safeG}')">Select Status...</div>
|
|
<div class="options-container" id="usmt-options-${safeG}">
|
|
<label class="option-label"><input type="checkbox" value="Not Started" onchange="applyFilters('${safeG}')"> Not Started</label>
|
|
<label class="option-label"><input type="checkbox" value="In Progress" onchange="applyFilters('${safeG}')"> In Progress</label>
|
|
<label class="option-label"><input type="checkbox" value="Completed" onchange="applyFilters('${safeG}')"> Completed</label>
|
|
<label class="option-label"><input type="checkbox" value="Not Associated" onchange="applyFilters('${safeG}')"> Not Associated</label>
|
|
</div>
|
|
</div>
|
|
</th>
|
|
<th>
|
|
<div class="multi-select" id="tags-filter-${safeG}">
|
|
<div class="select-box" onclick="toggleMultiSelect('${safeG}')">Select Tags...</div>
|
|
<div class="options-container" id="tags-options-${safeG}"></div>
|
|
</div>
|
|
</th>
|
|
<th class="no-copy"></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody id="tbody-${safeG}"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>`;
|
|
});
|
|
listDiv.innerHTML = html;
|
|
tablesInitialized = true;
|
|
}
|
|
|
|
function renderMasterList() {
|
|
if (!tablesInitialized) return;
|
|
|
|
groupsArray.forEach(g => {
|
|
let safeG = g.replace(/ /g, '-');
|
|
const groupData = apiData.filter(d => d["Backup Status"] === g);
|
|
|
|
const container = document.getElementById(`group-container-${safeG}`);
|
|
const header = document.getElementById(`group-header-${safeG}`);
|
|
const tbody = document.getElementById(`tbody-${safeG}`);
|
|
|
|
if (groupData.length === 0) {
|
|
container.style.display = 'none';
|
|
tbody.innerHTML = "";
|
|
return;
|
|
}
|
|
|
|
container.style.display = 'block';
|
|
header.innerText = `${g} (${groupData.length})`;
|
|
|
|
[0, 1, 3, 4, 5, 6, 8, 9, 10].forEach(c => {
|
|
const el = document.getElementById(`icon-${safeG}-${c}`);
|
|
if (el) { el.innerText = '↕'; el.classList.remove('active'); }
|
|
});
|
|
|
|
let displayData = [...groupData];
|
|
|
|
if (sortStates[safeG] && sortStates[safeG].dir !== 'none') {
|
|
const { col, dir } = sortStates[safeG];
|
|
|
|
const iconEl = document.getElementById(`icon-${safeG}-${col}`);
|
|
if (iconEl) {
|
|
iconEl.innerText = dir === 'asc' ? '↑' : '↓';
|
|
iconEl.classList.add('active');
|
|
}
|
|
|
|
const colKeyMap = {
|
|
0: "Source Device", 1: "Source Online", 3: "Source Scanstate Line",
|
|
4: "Source Free Space", 5: "Destination Device", 6: "Destination Online",
|
|
8: "Destination Loadstate Line", 9: "USMT Status", 10: "Tags"
|
|
};
|
|
const key = colKeyMap[col];
|
|
|
|
displayData.sort((a, b) => {
|
|
if (col === 4) {
|
|
const parseVal = (str) => {
|
|
if (!str || String(str).includes("N/A") || String(str).includes("Cached")) return -1;
|
|
return parseFloat(String(str).replace(/[^\d.-]/g, ''));
|
|
};
|
|
let valA = parseVal(a[key]);
|
|
let valB = parseVal(b[key]);
|
|
return dir === 'asc' ? valA - valB : valB - valA;
|
|
} else {
|
|
let valA = (a[key] || "").toString().toUpperCase();
|
|
let valB = (b[key] || "").toString().toUpperCase();
|
|
if (valA < valB) return dir === 'asc' ? -1 : 1;
|
|
if (valA > valB) return dir === 'asc' ? 1 : -1;
|
|
return 0;
|
|
}
|
|
});
|
|
}
|
|
|
|
let rowsHtml = "";
|
|
displayData.forEach(row => {
|
|
const srcPingStatus = row["Source Online"] || "N/A";
|
|
|
|
const dstPingStatus = row["Destination Online"] || "N/A";
|
|
|
|
const spaceStr = (row["Source Free Space"] !== "N/A" && row["Source Free Space"] !== "Cached" && row["Source Free Space"]) ?
|
|
`${row["Source Free Space"]} / ${row["Source Total Space"]}` : (row["Source Free Space"] || "N/A");
|
|
|
|
const destAppsBtn = (row["Destination Device"] && row["Destination Device"] !== "N/A") ?
|
|
`<button class="soft-btn" onclick="event.stopPropagation(); fetchSoftware('${row["Destination Device"]}')">Apps</button>` : `<span style="color:var(--text-muted);">---</span>`;
|
|
|
|
rowsHtml += `<tr class="clickable-row" style='background-color:${themes[currentTheme].groupColors[g]}; cursor: pointer;' onclick="showDeviceDetails('${row["Source Device"]}')">
|
|
<td title="${row["Source Device"]}"><b>${row["Source Device"]}</b>${inlineCopyBtn(row["Source Device"])}</td>
|
|
<td style="color: ${getPingColor(srcPingStatus)}; font-weight: bold;">${srcPingStatus}${DeviceInfo.pingMark(row, "Source")}</td>
|
|
<td class="no-copy"><button class="soft-btn" onclick="event.stopPropagation(); fetchSoftware('${row["Source Device"]}')">Apps</button></td>
|
|
<td title="${row["Source Scanstate Line"]}">${(g === "Backup In Progress" && row["Source Scanstate Elapsed"] && row["Source Scanstate Elapsed"] !== "N/A" && row["Source Scanstate Elapsed"] !== "Cached") ? row["Source Scanstate Elapsed"] + " " + String(row["Source Scanstate Line"]).trim().split(/\s+/).pop() : row["Source Scanstate Line"]}${DeviceInfo.snapshotMark(row, "Source")}</td>
|
|
<td title="${spaceStr}">${spaceStr}${DeviceInfo.snapshotMark(row, "Source")}</td>
|
|
<td title="${row["Destination Device"]}"><b>${row["Destination Device"]}</b>${inlineCopyBtn(row["Destination Device"])}</td>
|
|
<td style="color: ${getPingColor(dstPingStatus)}; font-weight: bold;">${dstPingStatus}${DeviceInfo.pingMark(row, "Destination")}</td>
|
|
<td class="no-copy">${destAppsBtn}</td>
|
|
<td title="${row["Destination Loadstate Line"]}">${(g === "Restore In Progress" && row["Destination Loadstate Elapsed"] && row["Destination Loadstate Elapsed"] !== "N/A" && row["Destination Loadstate Elapsed"] !== "Cached") ? row["Destination Loadstate Elapsed"] + " " + String(row["Destination Loadstate Line"]).trim().split(/\s+/).pop() : row["Destination Loadstate Line"]}${DeviceInfo.snapshotMark(row, "Destination")}</td>
|
|
<td title="${row["USMT Status"] || "N/A"}">${row["USMT Status"] || "N/A"}</td>
|
|
<td title="${row.Tags || 'None'}">${row.Tags || 'None'}</td>
|
|
<td style="text-align:center;" class="no-copy"><span class="copy-row-btn" title="Copy Row Data" onclick="copyRowData(this, event)">${COPY_SVG}</span></td>
|
|
</tr>`;
|
|
});
|
|
|
|
if (tbody.innerHTML !== rowsHtml) {
|
|
tbody.innerHTML = rowsHtml;
|
|
applyFilters(safeG);
|
|
}
|
|
});
|
|
updateTotalCounts();
|
|
}
|
|
|
|
function filterMasterList() {
|
|
// Loop through all groups and apply the global search filter to each table
|
|
groupsArray.forEach(g => {
|
|
let safeG = g.replace(/ /g, '-');
|
|
applyFilters(safeG);
|
|
});
|
|
}
|
|
|
|
function applyFilters(safeG) {
|
|
updateMultiSelectText(safeG);
|
|
|
|
const globalSearch = document.getElementById('searchInput').value.toUpperCase();
|
|
const tbody = document.getElementById(`tbody-${safeG}`);
|
|
if (!tbody) return;
|
|
|
|
const inputs = document.querySelectorAll(`.filter-row .col-filter[data-group="${safeG}"]`);
|
|
|
|
const tagContainer = document.getElementById(`tags-options-${safeG}`);
|
|
let selectedTags = [];
|
|
if(tagContainer) {
|
|
selectedTags = Array.from(tagContainer.querySelectorAll('input:checked')).map(cb => cb.value.toUpperCase());
|
|
}
|
|
|
|
const srcpingContainer = document.getElementById(`srcping-options-${safeG}`);
|
|
let selectedSrcPing = srcpingContainer ? Array.from(srcpingContainer.querySelectorAll('input:checked')).map(cb => cb.value.toUpperCase()) : [];
|
|
|
|
const destpingContainer = document.getElementById(`destping-options-${safeG}`);
|
|
let selectedDestPing = destpingContainer ? Array.from(destpingContainer.querySelectorAll('input:checked')).map(cb => cb.value.toUpperCase()) : [];
|
|
|
|
const usmtContainer = document.getElementById(`usmt-options-${safeG}`);
|
|
let selectedUsmt = usmtContainer ? Array.from(usmtContainer.querySelectorAll('input:checked')).map(cb => cb.value.toUpperCase()) : [];
|
|
|
|
const rows = tbody.getElementsByTagName('tr');
|
|
let visibleCount = 0;
|
|
|
|
for (let i = 0; i < rows.length; i++) {
|
|
let show = true;
|
|
const tr = rows[i];
|
|
|
|
inputs.forEach(input => {
|
|
if (!show) return;
|
|
const val = input.value.toUpperCase();
|
|
if (val) {
|
|
const colIdx = parseInt(input.dataset.col);
|
|
const cellText = tr.cells[colIdx].textContent.toUpperCase();
|
|
if (!cellText.includes(val)) {
|
|
show = false;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (show && selectedTags.length > 0) {
|
|
const cellText = tr.cells[10].textContent.toUpperCase();
|
|
const rowTags = cellText.split(',').map(t => t.trim());
|
|
|
|
const hasTag = selectedTags.some(selected => rowTags.includes(selected));
|
|
if(!hasTag) { show = false; }
|
|
}
|
|
|
|
if (show && selectedSrcPing.length > 0) {
|
|
const cellText = tr.cells[1].textContent.toUpperCase();
|
|
if(!selectedSrcPing.includes(cellText)) show = false;
|
|
}
|
|
|
|
if (show && selectedDestPing.length > 0) {
|
|
const cellText = tr.cells[6].textContent.toUpperCase();
|
|
if(!selectedDestPing.includes(cellText)) show = false;
|
|
}
|
|
|
|
if (show && selectedUsmt.length > 0) {
|
|
const cellText = tr.cells[9].textContent.toUpperCase();
|
|
if(!selectedUsmt.includes(cellText)) show = false;
|
|
}
|
|
|
|
if (show && globalSearch) {
|
|
// without the copy icon SVG or whitespace for the cache lookup.
|
|
const srcDevice = tr.cells[0].innerText.replace(/[\n\r]/g, '').trim();
|
|
const apiRow = apiData.find(d => d["Source Device"] === srcDevice);
|
|
let rowText = tr.innerText.toUpperCase();
|
|
|
|
if (apiRow) {
|
|
const advData = advancedDataCache[srcDevice] || {};
|
|
// Dynamically grab all advanced fields to make everything searchable
|
|
const searchVals = Object.values(advData).filter(v => v !== null && v !== undefined);
|
|
rowText += ' ' + searchVals.join(' ').toUpperCase();
|
|
}
|
|
|
|
const searchTerms = globalSearch.split(',').map(term => term.trim()).filter(term => term !== "");
|
|
if (searchTerms.length > 0) {
|
|
const hasMatch = searchTerms.some(term => rowText.includes(term));
|
|
if (!hasMatch) show = false;
|
|
}
|
|
}
|
|
|
|
tr.style.display = show ? '' : 'none';
|
|
if (show) visibleCount++;
|
|
}
|
|
|
|
const header = document.getElementById(`group-header-${safeG}`);
|
|
if (header) {
|
|
const groupName = safeG.replace(/-/g, ' ');
|
|
header.innerText = `${groupName} (${visibleCount})`;
|
|
}
|
|
|
|
const updateBoxText = (prefix) => {
|
|
const container = document.getElementById(`${prefix}-options-${safeG}`);
|
|
const box = document.querySelector(`#${prefix}-filter-${safeG} .select-box`);
|
|
if (container && box) {
|
|
const checked = container.querySelectorAll('input:checked');
|
|
if(checked.length === 0) box.innerText = prefix === 'usmt' ? "Select Status..." : "Select Ping...";
|
|
else if(checked.length === 1) box.innerText = checked[0].value;
|
|
else box.innerText = `${checked.length} selected`;
|
|
}
|
|
};
|
|
|
|
updateBoxText('srcping');
|
|
updateBoxText('destping');
|
|
updateBoxText('usmt');
|
|
updateTotalCounts();
|
|
}
|
|
|
|
async function pullData() {
|
|
try {
|
|
// 1. FAST STATE POLLING (the tracked list is fetched alongside it)
|
|
const trackFetch = fetch('Tracked-Devices.json?t=' + Date.now()).catch(() => null);
|
|
const stateRes = await fetch('Worker-State.json?t=' + Date.now());
|
|
if (!stateRes.ok) return;
|
|
const state = await stateRes.json();
|
|
|
|
const btn = document.getElementById('status-btn');
|
|
const forceBtn = document.getElementById('forceSyncBtn');
|
|
const forceFullBtn = document.getElementById('forceFullBtn');
|
|
|
|
btn.innerHTML = `<span class="st-pre">Data Updated At: </span>${globalDataLastUpdated}`;
|
|
|
|
if (state.Status === "Cooldown") {
|
|
document.getElementById('syncStatusText').innerText = "System is Idle (On Cooldown)";
|
|
document.getElementById('syncDetailsText').innerText = `Next cycle will start at: ${state.NextCycle}`;
|
|
document.getElementById('syncProgressFill').style.width = "100%";
|
|
document.getElementById('syncProgressFill').innerText = "Ready";
|
|
document.getElementById('syncProgressFill').style.backgroundColor = "#27ae60";
|
|
|
|
if (state.LastDuration && state.LastDuration !== "N/A") {
|
|
document.getElementById('syncDurationText').innerText = `Last cycle finished in ${state.LastDuration} with ${state.Total} source devices`;
|
|
}
|
|
|
|
forceBtn.disabled = false;
|
|
forceFullBtn.disabled = false;
|
|
} else {
|
|
let percentage = 0;
|
|
if (state.Total > 0) percentage = Math.round((state.Current / state.Total) * 100);
|
|
document.getElementById('syncStatusText').innerText = "Live Sync in Progress...";
|
|
document.getElementById('syncDetailsText').innerText = state.Status === "Presence Sweep..."
|
|
? `Presence sweep: pinging device ${state.Current} of ${state.Total}`
|
|
: `${state.Status}: Device ${state.Current} of ${state.Total}`;
|
|
document.getElementById('syncProgressFill').style.width = `${percentage}%`;
|
|
document.getElementById('syncProgressFill').innerText = `${percentage}%`;
|
|
document.getElementById('syncProgressFill').style.backgroundColor = "#3498db";
|
|
|
|
if (state.LastDuration && state.LastDuration !== "N/A") {
|
|
document.getElementById('syncDurationText').innerText = `Previous cycle took ${state.LastDuration}${state.LastCycle ? ` with ${state.LastCycle.Sources} source devices` : ''}`;
|
|
}
|
|
|
|
forceBtn.disabled = true;
|
|
forceFullBtn.disabled = true;
|
|
}
|
|
|
|
document.getElementById('syncBreakdown').innerHTML = DeviceInfo.syncBreakdownHtml(state);
|
|
|
|
// Check if Tracked-Devices was updated by you or a teammate
|
|
const trackRes = await trackFetch;
|
|
let currentTrackedHash = "";
|
|
let trackData = null;
|
|
if (trackRes && trackRes.ok) {
|
|
const rawText = await trackRes.clone().text();
|
|
currentTrackedHash = rawText; // Store raw text to detect any changes
|
|
trackData = await trackRes.json();
|
|
}
|
|
|
|
// Early return ONLY if the Worker AND the Tracked Devices list are both completely unchanged
|
|
// The worker publishes rows several times a cycle (DataVersion); older workers only
|
|
// once per cycle (LastUpdated)
|
|
const dataUnchanged = state.DataVersion ? state.DataVersion === globalDataVersion : state.LastUpdated === globalDataLastUpdated;
|
|
if (dataUnchanged && apiData.length > 0 && currentTrackedHash === globalTrackedHash) {
|
|
return;
|
|
}
|
|
|
|
// If we get here, something changed! Update the tracker so we don't loop endlessly
|
|
globalTrackedHash = currentTrackedHash;
|
|
|
|
// 2. FETCH CORE DATA AND ADVANCED DATA
|
|
const advFetch = fetch('Advanced-Cache.json?t=' + Date.now()).catch(() => null);
|
|
const dataRes = await fetch('Data.json?t=' + Date.now()).catch(() => null);
|
|
if (dataRes && dataRes.ok) {
|
|
const payload = await dataRes.json();
|
|
if (payload.LastUpdated && payload.Devices) {
|
|
apiData = payload.Devices;
|
|
globalDataVersion = payload.DataVersion || state.DataVersion || "";
|
|
globalDataLastUpdated = payload.LastUpdated;
|
|
btn.innerHTML = `<span class="st-pre">Data Updated At: </span>${globalDataLastUpdated}`;
|
|
} else {
|
|
apiData = payload;
|
|
}
|
|
}
|
|
|
|
const advRes = await advFetch;
|
|
if (advRes && advRes.ok) {
|
|
advancedDataCache = await advRes.json();
|
|
}
|
|
|
|
if (trackData) {
|
|
loadAllTags(); // Update the Active Tags UI just in case a teammate added a brand new tag!
|
|
let tagMap = {};
|
|
let manualPairs = {};
|
|
for (const [tag, tagData] of Object.entries(trackData)) {
|
|
|
|
// --- NEW: Legacy Guard for JSON Schema Change ---
|
|
const deviceList = Array.isArray(tagData) ? tagData : (tagData.devices || []);
|
|
|
|
deviceList.forEach(d => {
|
|
if (!d) return;
|
|
let upperD = d.trim().toUpperCase();
|
|
|
|
// --- NEW: Front-end Manual Association Parser ---
|
|
if (upperD.includes(',')) {
|
|
let parts = upperD.split(',');
|
|
let src = parts[0].trim();
|
|
let dst = parts[1].trim();
|
|
|
|
manualPairs[src] = dst; // Store the exact mapping
|
|
|
|
if (!tagMap[src]) tagMap[src] = [];
|
|
if (!tagMap[src].includes(tag)) tagMap[src].push(tag);
|
|
|
|
if (!tagMap[dst]) tagMap[dst] = [];
|
|
if (!tagMap[dst].includes(tag)) tagMap[dst].push(tag);
|
|
} else {
|
|
// --- Original Logic ---
|
|
if (!tagMap[upperD]) tagMap[upperD] = [];
|
|
if (!tagMap[upperD].includes(tag)) tagMap[upperD].push(tag);
|
|
}
|
|
});
|
|
}
|
|
|
|
// A manual pair whose destination changed (or a device that became / stopped being a
|
|
// manual pair) drops its stale row right away; a "Waiting for Worker..." row for
|
|
// the new pairing is added below until the worker sweeps it
|
|
apiData = apiData.filter(row => {
|
|
let src = (row["Source Device"] || "").toUpperCase();
|
|
let dest = (row["Destination Device"] || "").toUpperCase();
|
|
let isManualRow = row["Pairing"] ? row["Pairing"] === "Manual" : (row["USMT Status"] === "N/A" && row["Backup Status"] === "N/A" && dest !== "N/A");
|
|
if (manualPairs[src]) return isManualRow && dest === manualPairs[src];
|
|
if (isManualRow && !manualPairs[src]) return false;
|
|
return true;
|
|
});
|
|
|
|
// Surgically strip old devices from apiData before the backend worker finishes purging them
|
|
apiData = apiData.filter(row => {
|
|
let src = (row["Source Device"] || "").toUpperCase();
|
|
let dest = (row["Destination Device"] || "").toUpperCase();
|
|
return tagMap[src] || tagMap[dest];
|
|
});
|
|
|
|
let knownDevices = new Set();
|
|
apiData.forEach(row => {
|
|
let src = (row["Source Device"] || "").toUpperCase();
|
|
let dest = (row["Destination Device"] || "").toUpperCase();
|
|
|
|
if (src && src !== "N/A") knownDevices.add(src);
|
|
if (dest && dest !== "N/A") knownDevices.add(dest);
|
|
|
|
let activeTags = [];
|
|
if (tagMap[src]) activeTags.push(...tagMap[src]);
|
|
if (tagMap[dest]) activeTags.push(...tagMap[dest]);
|
|
|
|
let uniqueTags = [...new Set(activeTags)];
|
|
row.Tags = uniqueTags.length > 0 ? uniqueTags.join(", ") : "None";
|
|
});
|
|
|
|
let manualDests = new Set(Object.values(manualPairs));
|
|
|
|
for (const [dev, tags] of Object.entries(tagMap)) {
|
|
if (!knownDevices.has(dev) && !manualDests.has(dev)) {
|
|
let destDev = manualPairs[dev] || "N/A";
|
|
let isManual = (destDev !== "N/A");
|
|
|
|
// Normal associations use "No Data" (to show in table) and "Waiting..." placeholders
|
|
// Manual associations use "N/A" (to hide from table) and "N/A" placeholders
|
|
let status = isManual ? "N/A" : "No Data";
|
|
let placeholder = isManual ? "N/A" : "Waiting for Worker...";
|
|
|
|
apiData.push({
|
|
"Source Device": dev,
|
|
"Destination Device": destDev,
|
|
"Pairing": isManual ? "Manual" : undefined,
|
|
"Tags": tags.join(", "),
|
|
"Source Total Space": placeholder,
|
|
"Source Free Space": placeholder,
|
|
"USMT Status": placeholder,
|
|
"Backup Status": status,
|
|
"Source Online": "Waiting for Worker...",
|
|
"Destination Online": isManual ? "Waiting for Worker..." : "-",
|
|
"Source Scanstate Line": placeholder,
|
|
"Destination Loadstate Line": placeholder
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
myStacks.forEach(s => refreshStackTable(s.id));
|
|
renderMasterList();
|
|
renderSoftwareTable(); // <-- Forces the Software tab to update instantly!
|
|
renderCustomTable();
|
|
refreshOpenDialogs();
|
|
document.dispatchEvent(new Event('usmt:data'));
|
|
if (window.Overview) Overview.refresh(); // draw the Overview now, not on its next timer tick
|
|
|
|
// 3. LAZY LOAD SOFTWARE LAST
|
|
fetch('Software-Cache.json?t=' + Date.now())
|
|
.then(res => res.ok ? res.text() : "")
|
|
.then(cacheText => {
|
|
let firstLoad = !isSoftwareCacheLoaded;
|
|
isSoftwareCacheLoaded = true;
|
|
let cacheUpdated = false;
|
|
|
|
if (cacheText.trim() !== '') {
|
|
const cacheJson = JSON.parse(cacheText);
|
|
for (const [device, entry] of Object.entries(cacheJson)) {
|
|
let rawData = null;
|
|
if (Array.isArray(entry)) {
|
|
rawData = entry;
|
|
} else if (entry && entry.Data) {
|
|
rawData = Array.isArray(entry.Data) ? entry.Data : [entry.Data];
|
|
}
|
|
// Re-read an entry whenever the worker refreshed it (nightly sweep, new device)
|
|
const stamp = (entry && entry.Timestamp) ? String(entry.Timestamp) : (rawData ? String(rawData.length) : '');
|
|
if (rawData && (!softwareCache[device] || softwareStamps[device] !== stamp)) {
|
|
softwareCache[device] = rawData.filter(s => s && s.Name && s.Name.trim() !== '' && s.Name.trim() !== '--');
|
|
softwareStamps[device] = stamp;
|
|
cacheUpdated = true;
|
|
}
|
|
}
|
|
if (cacheUpdated || firstLoad) {
|
|
myStacks.forEach(s => refreshStackTable(s.id));
|
|
renderMasterList();
|
|
renderSoftwareTable();
|
|
renderCustomTable();
|
|
refreshOpenDialogs(true);
|
|
if (window.Overview) Overview.refresh();
|
|
}
|
|
}
|
|
}).catch(() => null);
|
|
|
|
} catch (e) {}
|
|
}
|
|
|
|
// Open dialogs follow the data: when the worker finishes a cycle (or the software cache
|
|
// changes) the device details and software dialogs re-render in place, keeping scroll
|
|
// position, unless the user is selecting text in them.
|
|
let softwareStamps = {};
|
|
let lastDetailsSig = "", lastSoftwareSig = "";
|
|
function refreshOpenDialogs(force) {
|
|
const selecting = () => { const sel = window.getSelection(); return sel && sel.toString().length > 0; };
|
|
const dm = document.getElementById('detailsModal');
|
|
if (dm && dm.style.display === 'block' && window.currentDetailsKey && !selecting()) {
|
|
const key = window.currentDetailsKey;
|
|
const m = apiData.find(x => x["Source Device"] === key) || apiData.find(x => x["Destination Device"] === key);
|
|
if (m) {
|
|
const sig = JSON.stringify([m, advancedDataCache[m["Source Device"]], advancedDataCache[m["Destination Device"]],
|
|
(softwareCache[m["Source Device"]] || []).length, (softwareCache[m["Destination Device"]] || []).length]);
|
|
if (force || sig !== lastDetailsSig) {
|
|
lastDetailsSig = sig;
|
|
window.currentDetailsMatch = m;
|
|
const mc = dm.querySelector('.modal-content');
|
|
const boxes = Array.from(dm.querySelectorAll('#detailsBody div')).map(d => d.scrollTop);
|
|
const top = mc.scrollTop;
|
|
renderDetailsHTML();
|
|
mc.scrollTop = top;
|
|
Array.from(dm.querySelectorAll('#detailsBody div')).forEach((d, i) => { if (boxes[i]) d.scrollTop = boxes[i]; });
|
|
}
|
|
}
|
|
}
|
|
const sm = document.getElementById('modal');
|
|
const dev = window.currentSoftwareDevice;
|
|
if (sm && sm.style.display === 'block' && dev && Array.isArray(softwareCache[dev]) && !selecting()) {
|
|
const sig = dev + JSON.stringify(softwareCache[dev]);
|
|
if (sig !== lastSoftwareSig) {
|
|
lastSoftwareSig = sig;
|
|
drawModalTables(softwareCache[dev], true, dev);
|
|
const search = document.getElementById('softSearch');
|
|
if (search && search.value) filterSoftwareModal();
|
|
}
|
|
}
|
|
}
|
|
|
|
async function startDashboard() {
|
|
renderStacksOnly();
|
|
await pullData();
|
|
|
|
setTimeout(async function pollLoop() {
|
|
await pullData();
|
|
setTimeout(pollLoop, 3500);
|
|
}, 3500);
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|