Page routes, Home link, documented-app filters, Overview colors and list fixes

- Every nav page has its own address (/tracked, /status, /devices, ...);
  the logo and name go to the Overview; back/forward work.
- List filters gain an "Only ticked" mode; software columns get a one-click
  "devices with only documented apps" pick and an easiest-first sort.
- Overview: the device list uses the dashboard's table header (sticky, with
  Excel filters); what opened it is clearly highlighted; locations colored by
  how much is done (red to green); stages read red / amber / green.
- Details: one-line title bar; presence and monitors only in Advanced View.
- Presence sweep pings only completed pairs (everything else is pinged by
  the sweep itself), hourly and at midnight.
- Custom View scrollbar smaller, in the table header blue.
- Docs: no sideways scroll in the article list; no blank lines around code.
This commit is contained in:
Johan Sandoval committed 2026-10-06 20:27:37 +00:00
1 parent e2125c760a
commit f72e39892c
13 files changed
+332 -117

No files matched your search

+11 -1
View File
@@ -96,6 +96,15 @@
return { online, last, lastIso: a.LastOnline, checked: a.LastChecked, ping: a.LastPing, first: a.FirstChecked, days, source: a.LastOnlineSource };
}
// Skip USMT tags are inventory only: the worker never pings them
function notPinged(name) {
try {
const rows = typeof apiData !== 'undefined' && Array.isArray(apiData) ? apiData : [];
return rows.some(r => r['Source Device'] === name && r.Pairing === 'Skipped');
} catch (e) { return false; }
}
const NOT_PINGED = 'Not pinged (Skip USMT tag)';
// Whole days offline (0 when online or offline less than a day), null if never seen
function daysOffline(name) {
const p = presence(name);
@@ -121,6 +130,7 @@
function computed(name, key) {
const a = entry(name);
if (!a) return { text: WAITING, state: 'waiting' };
if (['DaysOffline', 'LastOnline', 'LastChecked'].includes(key) && !a.LastChecked && notPinged(name)) return { text: NOT_PINGED, state: 'na' };
if (key === 'DaysOffline') {
const d = daysOffline(name);
if (d === undefined) return { text: WAITING, state: 'waiting' };
@@ -164,7 +174,7 @@
function presenceHtml(name) {
if (!isName(name)) return 'N/A';
const p = presence(name);
if (!p) return `<span class="di-waiting">${WAITING}</span>`;
if (!p) return notPinged(name) ? `<span class="di-sub">${NOT_PINGED}</span>` : `<span class="di-waiting">${WAITING}</span>`;
const txt = offlineText(name);
const cls = p.online ? 'di-online' : (p.days !== null && p.days >= 3 ? 'di-offline-long' : 'di-offline');
let sub = '';
+9 -2
View File
@@ -10,8 +10,8 @@
/* groups + items */
.kb-group-title { font-size: 0.72em; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); margin: 2px 0 6px; display: flex; justify-content: space-between; }
.kb-group-title span { font-weight: 600; }
.kb-item { display: flex; align-items: flex-start; gap: 6px; padding: 6px 8px; margin: 0 -8px 2px; border-radius: 6px; border: 1px solid transparent; transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; }
.kb-item:hover { background: rgba(52,152,219,0.08); border-color: rgba(52,152,219,0.45); box-shadow: 0 0 10px rgba(52,152,219,0.35); }
.kb-item { display: flex; align-items: flex-start; gap: 6px; padding: 6px 8px; margin: 0 0 2px; min-width: 0; border-radius: 6px; border: 1px solid transparent; transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; }
.kb-item:hover { background: rgba(52,152,219,0.08); border-color: rgba(52,152,219,0.45); box-shadow: 0 0 6px rgba(52,152,219,0.35); }
.kb-item.active { background: rgba(52,152,219,0.14); border-color: #3498db; }
.kb-item-main { min-width: 0; flex: 1; }
.kb-item a { color: #3498db; text-decoration: none; font-weight: 500; overflow-wrap: anywhere; }
@@ -64,3 +64,10 @@
@media (max-width: 1100px) {
.kb-tool span { display: none; }
}
/* Code blocks: the theme's own padding doubled up with the block's, which looked like an
empty line above and below the code */
#docContent pre code.hljs, #docContent pre > code { padding: 0 !important; display: block; }
/* Article list: never scrolls sideways */
#docList { overflow-x: hidden !important; padding: 0 6px; margin: 0 -6px; }
+9 -9
View File
@@ -324,6 +324,13 @@
// ---------------------------------------------------------------------------------
const ALERTS = { NOTE: 'Note', TIP: 'Tip', IMPORTANT: 'Important', WARNING: 'Warning', CAUTION: 'Caution' };
function decorate() {
// no trailing blank line at the end of a code block
document.querySelectorAll('#docContent pre code').forEach(code => {
if (code.dataset.kbTrim) return;
code.dataset.kbTrim = '1';
const last = code.lastChild;
if (last && last.nodeType === 3) last.nodeValue = last.nodeValue.replace(/\s+$/, '');
});
document.querySelectorAll('#docContent blockquote').forEach(bq => {
if (bq.dataset.kb) return;
bq.dataset.kb = '1';
@@ -405,14 +412,7 @@
if (!m) ed.value = `<!-- category: ${sel.value} -->\n` + ed.value;
}
});
wrap('switchPage', id => {
if (id !== 'docs' && location.pathname.startsWith('/docs')) setUrl('/', false);
if (id === 'docs' && currentTitle() && !location.pathname.startsWith('/docs/')) setUrl(permalink(currentTitle()), false);
});
window.addEventListener('popstate', () => {
const doc = findDoc(titleFromPath());
if (doc) openDoc(doc);
else if (location.pathname === '/' && document.getElementById('docs').style.display !== 'none') switchPage('overview');
});
// Page routing (including /docs) lives in router.js; it hands /docs/<title> back here
window.KB = { open: name => { const d = findDoc(name); if (d) { openDoc(d); return true; } return false; } };
});
})();
+74 -14
View File
@@ -69,6 +69,17 @@
sortDir: i => { const s = sortStates['software']; return s && s.col === i ? s.dir : 'none'; }
};
}
if (id === 'ov-table' && window.Overview) {
const key = Overview.listKey();
return {
key, native: true,
colKey: i => i,
rerun: () => Overview.renderList(),
sortable: () => true,
sort: (i, dir) => { nativeSort[key] = { col: i, dir }; Overview.renderList(); },
sortDir: i => { const s = nativeSort[key]; return s && s.col === i ? s.dir : 'none'; }
};
}
if (table.closest('#stackGrid')) {
const sid = id.replace(/^table-/, '');
const key = 'stack-' + sid;
@@ -166,7 +177,11 @@
if (allowed.free.op === 'ge' ? sp.free < allowed.free.gb : sp.free >= allowed.free.gb) return false;
}
const vals = cellValues(td, col);
if (col.list && allowed.only) {
if (col.list && allowed.only === 'subset') {
// only ticked: nothing outside the ticked items (some of them is fine)
if (!vals.every(v => allowed.has(v))) return false;
}
else if (col.list && allowed.only) {
// exact set: the row's items are precisely the ticked ones, no more, no fewer
const own = new Set(vals);
if (own.size !== allowed.size || !vals.every(v => allowed.has(v))) return false;
@@ -206,12 +221,29 @@
decorate(table, ad, cols);
}
function isDocumentedApp(v) {
const docs = (typeof availableDocs !== 'undefined' && Array.isArray(availableDocs)) ? availableDocs : [];
const l = String(v).toLowerCase();
return docs.some(d => d.toLowerCase() === l);
}
function nativeSortRows(table, cols, s) {
const col = cols[s.col];
if (!col) return;
const body = table.tBodies[0];
const rows = dataRows(table);
const num = tr => { const sp = parseSpace(tr.cells[s.col]); return sp ? sp[s.by] : null; };
if (s.by === 'docs') {
// undocumented apps, then all apps: a device with only documented apps sorts first
const key = tr => {
const vals = cellValues(tr.cells[s.col], col).filter(v => v !== 'None' && v !== BLANK);
return [vals.filter(v => !isDocumentedApp(v)).length, vals.length];
};
const keyed = rows.map(r => [r, key(r)]);
keyed.sort((a, b) => (a[1][0] - b[1][0]) * (s.dir === 'desc' ? -1 : 1) || a[1][1] - b[1][1]);
keyed.forEach(([r]) => body.appendChild(r));
return;
}
rows.sort((a, b) => {
if (s.by) { // disk size / free space: no reading always last
const x = num(a), y = num(b);
@@ -422,6 +454,15 @@
menu.appendChild(el('div', 'xf-sep'));
} else if (col.sortable) {
const dir = sortDirOf(ad, idx);
if (col.list && /software/i.test(col.label)) {
// "Easy" devices first: fewest apps without a documentation article
const ns = nativeSort[ad.key];
const cur = ns && ns.col === idx && ns.by === 'docs' && ns.dir !== 'none' ? ns.dir : '';
item(SORT_ASC_SVG, 'Easiest first (fewest undocumented apps)', () => { closeMenu(); applySort(ad, idx, cur === 'asc' ? 'none' : 'asc', 'docs'); });
if (cur === 'asc') menu.lastChild.classList.add('xf-checked');
item(SORT_DESC_SVG, 'Hardest first (most undocumented apps)', () => { closeMenu(); applySort(ad, idx, cur === 'desc' ? 'none' : 'desc', 'docs'); });
if (cur === 'desc') menu.lastChild.classList.add('xf-checked');
}
item(SORT_ASC_SVG, 'Sort A to Z', () => { closeMenu(); applySort(ad, idx, dir === 'asc' ? 'none' : 'asc'); });
if (dir === 'asc') menu.lastChild.classList.add('xf-checked');
item(SORT_DESC_SVG, 'Sort Z to A', () => { closeMenu(); applySort(ad, idx, dir === 'desc' ? 'none' : 'desc'); });
@@ -472,20 +513,22 @@
};
}
// List columns: match rows having ANY ticked item, or ONLY ticked items
let onlyToggle = null;
// List columns: rows having ANY ticked item, ONLY ticked items (nothing else), or
// EXACTLY the ticked items
let listMode = null, setMode = null;
if (col.list) {
const seg = el('div', 'xf-seg');
seg.title = 'Any: devices with at least one ticked item. Exactly: devices whose list is exactly the ticked items, nothing more and nothing less.';
const mk = (text, only) => {
const b = el('button', 'xf-seg-btn', text); b.type = 'button';
b.onclick = () => { seg.querySelectorAll('.xf-seg-btn').forEach(x => x.classList.toggle('on', x === b)); };
if (!!(current && current.only) === only) b.classList.add('on');
const seg = el('div', 'xf-seg xf-seg3');
seg.title = 'Any: at least one ticked item. Only: nothing except ticked items. Exactly: precisely the ticked items, nothing more and nothing less.';
const curMode = !current || !current.only ? 'any' : (current.only === 'subset' ? 'subset' : 'exact');
const btns = {};
setMode = m => { listMode = m; Object.entries(btns).forEach(([k, b]) => b.classList.toggle('on', k === m)); };
[['any', 'Any ticked', 'At least one ticked item'], ['subset', 'Only ticked', 'Nothing except ticked items'], ['exact', 'Exactly ticked', 'Precisely the ticked items']].forEach(([m, text, tip]) => {
const b = el('button', 'xf-seg-btn', text); b.type = 'button'; b.title = tip;
b.onclick = () => setMode(m);
btns[m] = b;
seg.appendChild(b);
return b;
};
mk('Has any ticked', false);
onlyToggle = mk('Exactly ticked', true);
});
setMode(curMode);
menu.appendChild(seg);
}
@@ -553,6 +596,20 @@
};
pick('Documented', isDocumented);
pick('Undocumented', v => !isDocumented(v));
// One click: devices whose apps ALL have an article ("easy" devices)
if (setMode) {
const b = el('button', 'xf-pick xf-pick-main'); b.type = 'button';
b.textContent = 'Devices with only documented apps';
b.title = 'Show devices where every app in this column has a documentation article (and devices with none)';
b.onclick = () => {
search.value = '';
opts.forEach(o => { o.row.hidden = false; o.cb.checked = o.v === 'None' || (o.v !== BLANK && isDocumented(o.v)); });
setMode('subset');
sync();
ok.click();
};
pickBar.appendChild(b);
}
}
search.oninput = () => {
const q = search.value.trim().toLowerCase();
@@ -570,7 +627,7 @@
const chosen = new Set(visibleOpts().filter(o => o.cb.checked).map(o => o.v));
const free = freeRule ? freeRule() : null;
if (free) chosen.free = free;
if (onlyToggle && onlyToggle.classList.contains('on')) chosen.only = true;
if (listMode === 'subset' || listMode === 'exact') chosen.only = listMode;
filters[ad.key] = filters[ad.key] || {};
// everything ticked and no extra rule = no filter
if (!q && chosen.size === values.length && !free) delete filters[ad.key][key];
@@ -749,6 +806,9 @@
schedule();
}
// Tables rendered outside the dashboard's own filter passes (the Overview list) call this
window.ExcelFilter = { refresh: table => { try { afterFilterPass(table); } catch (e) { console.error('[excel-filter]', e); } } };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
+4 -6
View File
@@ -359,6 +359,7 @@
<script src="/device-info.js"></script>
<script src="/overview.js"></script>
<script src="/docs.js"></script>
<script src="/router.js"></script>
<script src="/excel-filter.js" defer></script>
</head>
<body>
@@ -372,10 +373,10 @@
</svg>
</button>
<div class="nav-brand">
<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>
</div>
</a>
<div class="nav-links" id="nav-links-menu">
<button class="nav-btn active" id="btn-overview" onclick="switchPage('overview')">Overview</button>
@@ -2829,7 +2830,7 @@
if (!match) return;
const btnText = window.isAdvView ? "Simple View" : "Advanced View";
document.getElementById('detailsTitle').innerHTML = `Details: ${match["Source Device"]} ${inlineCopyBtn(match["Source Device"])} <button onclick="toggleAdvView()" style="float:right; margin-right: 15px; cursor: pointer; padding: 6px 12px; border-radius: 4px; background: #34495e; color: white; border: none; font-size: 0.6em; font-weight: bold; vertical-align: middle; transition: 0.2s;">${btnText}</button>`;
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";
@@ -2923,17 +2924,14 @@
${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>Source Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(srcName)}</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>
${hasDst ? `<tr><th>Dest Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(dstName)}</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>
${window.isAdvView ? '' : `<tr><th>Monitors</th><td ${tdStyle}>${monitorsCell(srcName)}</td></tr>`}
<tr><th>Tags</th><td ${tdStyle}>${match["Tags"] || 'None'}</td></tr>
</table>
</div>
+39 -20
View File
@@ -42,36 +42,35 @@ table.xf-table thead tr.filter-row { display: none !important; }
#table-custom thead th { position: sticky !important; top: var(--sticky-top, 112px) !important; z-index: 20; }
.custom-hscroll {
position: sticky; bottom: var(--dock-h, 40px); z-index: 15;
display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 8px;
background: var(--bg-card); border-top: 2px solid #3498db; border-radius: 0 0 8px 8px;
box-shadow: 0 -3px 10px rgba(0,0,0,0.18); user-select: none; touch-action: none;
display: flex; align-items: center; gap: 6px; height: 24px; padding: 0 8px;
background: var(--bg-card); border-top: 2px solid var(--table-header-bg); border-radius: 0 0 8px 8px;
box-shadow: 0 -2px 8px rgba(0,0,0,0.15); user-select: none; touch-action: none;
}
.custom-hscroll[hidden] { display: none; }
.chs-btn {
flex: 0 0 30px; height: 22px; border: 1px solid var(--border-color, #ccc); border-radius: 5px;
flex: 0 0 24px; height: 18px; border: 1px solid var(--border-color, #ccc); border-radius: 4px;
background: var(--bg-body, #f0f2f5); color: var(--text-main, #333); cursor: pointer;
font-size: 11px; line-height: 1; padding: 0;
font-size: 9px; line-height: 1; padding: 0;
}
.chs-btn:hover:not(:disabled) { background: #3498db; border-color: #3498db; color: #fff; }
.chs-btn:hover:not(:disabled) { background: var(--table-header-bg); border-color: var(--table-header-bg); color: #fff; }
.chs-btn:disabled { opacity: 0.35; cursor: default; }
.chs-track {
position: relative; flex: 1 1 auto; height: 14px; border-radius: 7px; cursor: pointer;
background: rgba(127,127,127,0.22); box-shadow: inset 0 1px 2px rgba(0,0,0,0.25); outline: none;
position: relative; flex: 1 1 auto; height: 10px; border-radius: 5px; cursor: pointer;
background: rgba(127,127,127,0.2); box-shadow: inset 0 1px 2px rgba(0,0,0,0.2); outline: none;
}
.chs-track:focus-visible { box-shadow: 0 0 0 2px #3498db; }
.chs-track:focus-visible { box-shadow: 0 0 0 2px var(--table-header-bg); }
.chs-thumb {
position: absolute; top: -3px; left: 0; height: 20px; min-width: 48px; border-radius: 10px;
background: #3498db; cursor: grab; box-shadow: 0 1px 4px rgba(0,0,0,0.35);
display: flex; align-items: center; justify-content: center; transition: background 0.15s;
position: absolute; top: -2px; left: 0; height: 14px; min-width: 40px; border-radius: 7px;
background: var(--table-header-bg); cursor: grab; box-shadow: 0 1px 3px rgba(0,0,0,0.35);
border: 1px solid rgba(255,255,255,0.18); box-sizing: border-box;
display: flex; align-items: center; justify-content: center; transition: filter 0.15s;
}
.chs-thumb span { width: 14px; height: 8px; border-left: 2px solid rgba(255,255,255,0.8); border-right: 2px solid rgba(255,255,255,0.8); }
.chs-thumb:hover, .custom-hscroll.dragging .chs-thumb { background: #1d7fc4; }
.chs-thumb span { width: 10px; height: 6px; border-left: 2px solid rgba(255,255,255,0.7); border-right: 2px solid rgba(255,255,255,0.7); }
.chs-thumb:hover, .custom-hscroll.dragging .chs-thumb { filter: brightness(1.3); }
.custom-hscroll.dragging .chs-thumb { cursor: grabbing; }
.chs-info { flex: 0 0 auto; min-width: 34px; text-align: right; font-size: 0.75em; color: var(--text-muted, #666); font-variant-numeric: tabular-nums; }
[data-theme="dark"] .chs-track { background: rgba(255,255,255,0.16); }
[data-theme="dark"] .chs-thumb { background: #5dade2; }
[data-theme="dark"] .chs-thumb:hover, [data-theme="dark"] .custom-hscroll.dragging .chs-thumb { background: #85c1e9; }
[data-theme="dark"] .chs-thumb span { border-color: rgba(0,0,0,0.55); }
.chs-info { flex: 0 0 auto; min-width: 30px; text-align: right; font-size: 0.7em; color: var(--text-muted, #666); font-variant-numeric: tabular-nums; }
[data-theme="dark"] .chs-track { background: rgba(255,255,255,0.12); }
[data-theme="dark"] .chs-thumb { border-color: rgba(255,255,255,0.35); }
/* Custom View header on one line like the other tables: drag grip, name, then the
Excel-style button (sort + filter) and copy icon; remove (x) appears on hover */
@@ -372,7 +371,7 @@ table.xf-table thead th:has(> .xf-btn):not(.sortable-th) { position: relative; p
.xf-clear-all svg { width: 14px; height: 14px; flex: none; }
/* Software menus: documented / undocumented quick picks, doc marker, "None" pinned first */
.xf-picks { display: flex; gap: 6px; padding: 0 12px 6px; }
.xf-picks { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px 6px; }
.xf-pick {
flex: 1; padding: 5px 6px; cursor: pointer; font: inherit; font-size: 0.82em; white-space: nowrap;
background: transparent; color: #3498db; border: 1px solid rgba(52,152,219,0.55); border-radius: 5px;
@@ -470,3 +469,23 @@ table.sync-bd tbody tr { cursor: help; }
.nav-logo { display: none; }
.top-nav .nav-btn { padding: 8px 4px; }
}
/* Logo + name: the Home link (Overview) */
a.nav-brand { text-decoration: none; color: inherit; cursor: pointer; border-radius: 6px; }
a.nav-brand:hover h2 { color: #d6eaf8; }
a.nav-brand:focus-visible { outline: 2px solid #3498db; outline-offset: 3px; }
/* Device details title: name on the left, Simple / Advanced View toggle on the right, one line */
#detailsTitle { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding-right: 10px; min-width: 0; }
#detailsTitle .dt-name { min-width: 0; overflow-wrap: anywhere; }
#detailsTitle .dt-toggle {
margin-left: auto; cursor: pointer; padding: 6px 12px; border-radius: 4px; background: #34495e; color: #fff;
border: none; font-size: 0.55em; font-weight: bold; white-space: nowrap; transition: background 0.2s;
}
#detailsTitle .dt-toggle:hover { background: #3498db; }
@media (max-width: 700px) { #detailsTitle { padding-right: 32px; } #detailsTitle .dt-toggle { margin-left: 0; } }
/* Excel filter: three list modes, and the one-click "only documented" pick */
.xf-seg3 .xf-seg-btn { font-size: 0.78em; padding-left: 4px; padding-right: 4px; }
.xf-pick.xf-pick-main { flex-basis: 100%; background: #27ae60; border-color: #27ae60; color: #fff; font-weight: 600; }
.xf-pick.xf-pick-main:hover { background: #219150; border-color: #219150; }
+31 -20
View File
@@ -2,23 +2,39 @@
/* Chart colors: an ordinal blue ramp for migration stage (not started -> in progress ->
completed) and a sequential blue ramp for counts, each stepped separately for dark mode
(validated with the dataviz palette checker against the card surfaces). */
/* Stage colors read as status: not started = red, in progress = amber, completed = green
(the dashboard's status palette; every colored mark also carries a number or a label) */
#overview {
--ov-todo: #86b6ef; --ov-doing: #2a78d6; --ov-done: #104281;
--ov-bar: #2a78d6; --ov-track: rgba(127,127,127,0.12);
--ov-todo: #d03b3b; --ov-doing: #f0a20c; --ov-done: #0ca30c; --ov-other: #8a8f98;
--ov-track: rgba(127,127,127,0.12);
--ov-good: #0ca30c; --ov-serious: #ec835a;
}
[data-theme="dark"] #overview {
--ov-todo: #184f95; --ov-doing: #3987e5; --ov-done: #9ec5f4;
--ov-bar: #3987e5; --ov-track: rgba(255,255,255,0.07);
}
.ov-s0 { background: #cde2fb; color: #0b0b0b; } .ov-s1 { background: #b7d3f6; color: #0b0b0b; }
.ov-s2 { background: #9ec5f4; color: #0b0b0b; } .ov-s3 { background: #6da7ec; color: #0b0b0b; }
.ov-s4 { background: #3987e5; color: #fff; } .ov-s5 { background: #256abf; color: #fff; }
.ov-s6 { background: #184f95; color: #fff; }
[data-theme="dark"] .ov-s0 { background: #104281; color: #fff; } [data-theme="dark"] .ov-s1 { background: #184f95; color: #fff; }
[data-theme="dark"] .ov-s2 { background: #1c5cab; color: #fff; } [data-theme="dark"] .ov-s3 { background: #256abf; color: #fff; }
[data-theme="dark"] .ov-s4 { background: #3987e5; color: #fff; } [data-theme="dark"] .ov-s5 { background: #6da7ec; color: #0b0b0b; }
[data-theme="dark"] .ov-s6 { background: #9ec5f4; color: #0b0b0b; }
[data-theme="dark"] #overview { --ov-todo: #e05555; --ov-doing: #e8a317; --ov-done: #2fb344; --ov-other: #6b7280; --ov-track: rgba(255,255,255,0.07); }
/* Location bands: how much of a location is done, red -> green */
.ov-h0 { background: #d03b3b; color: #fff; }
.ov-h1 { background: #ec835a; color: #1f2328; }
.ov-h2 { background: #fab219; color: #1f2328; }
.ov-h3 { background: #8fd16a; color: #1f2328; }
.ov-h4 { background: #0ca30c; color: #fff; }
.ov-heat-key { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 10px; }
.ov-heat-key-item { display: inline-flex; align-items: center; gap: 6px; font-size: 0.8em; color: var(--text-muted); }
.ov-heat-key-item i { width: 14px; height: 10px; border-radius: 3px; }
/* What opened the list below: clearly marked */
.ov-tile.ov-selected { border: 2px solid #3498db !important; background: rgba(52,152,219,0.12); box-shadow: 0 0 0 3px rgba(52,152,219,0.25); transform: none; position: relative; }
.ov-showing { display: inline-block; margin-top: 6px; font-size: 0.72em; font-weight: 700; color: #fff; background: #3498db; border-radius: 999px; padding: 2px 8px; }
.ov-bar-row.ov-selected { background: rgba(52,152,219,0.16); box-shadow: inset 3px 0 0 #3498db; }
.ov-legend-item.ov-selected { color: var(--text-main); text-decoration: underline; text-decoration-color: #3498db; text-decoration-thickness: 2px; text-underline-offset: 4px; }
.ov-heat-cell.ov-selected, .ov-heat-sum.ov-selected { box-shadow: 0 0 0 2px var(--bg-card), 0 0 0 5px #3498db !important; }
.ov-heat-sum.ov-selected { background: rgba(52,152,219,0.16); }
/* The list: same look as the dashboard's tables, header sticks under the nav */
.ov-drill[hidden] { display: none; }
.ov-drill { padding: 0 !important; overflow: visible !important; border-radius: 10px; }
.ov-drill-head { border-left-color: #3498db !important; border-radius: 10px 10px 0 0; }
.ov-drill-title { font-weight: 700; }
.ov-drill .ov-head-btns { display: flex; gap: 8px; margin-left: auto; }
.ov-drill-empty, .ov-drill-note { padding: 12px 16px; margin: 0; }
#overview-root { max-width: 1500px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; }
.ov-title { margin: 0; color: var(--text-main); }
@@ -75,7 +91,7 @@ span.ov-legend-item { cursor: default; }
.ov-bar-row:hover, .ov-bar-row:focus-visible { background: var(--ov-track); }
.ov-bar-label { font-size: 0.88em; color: var(--text-main); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ov-bar-track { height: 14px; }
.ov-bar { display: block; height: 14px; background: var(--ov-bar); border-radius: 0 4px 4px 0; }
.ov-bar { display: block; height: 14px; border-radius: 0 4px 4px 0; }
.ov-bar-value { text-align: right; font-weight: 700; color: var(--text-main); font-variant-numeric: tabular-nums; }
/* heatmap */
@@ -109,9 +125,6 @@ table.ov-heat td { padding: 0; text-align: center; border: 0; background: none;
.ov-today { box-shadow: inset 0 0 0 2px #3498db; }
.ov-today .ov-day-num { color: #3498db; font-weight: 700; }
/* drill-down */
.ov-drill[hidden] { display: none; }
.ov-table-wrap { max-height: 520px; overflow: auto; border-top: none; }
/* tooltip */
.ov-tip { position: fixed; z-index: 4000; display: none; pointer-events: none; background: var(--bg-card); color: var(--text-main); border: 1px solid var(--border-color); border-radius: 8px; padding: 8px 10px; box-shadow: 0 6px 20px rgba(0,0,0,0.25); font-size: 0.85em; max-width: 260px; }
@@ -147,5 +160,3 @@ table.ov-heat td { padding: 0; text-align: center; border: 0; background: none;
.ov-bar-row { grid-template-columns: 128px 1fr 32px; }
.ov-bar-label { font-size: 0.82em; }
}
/* the drill-down list scrolls inside its card, so its header sticks to the card's top */
#overview .ov-table thead th { top: 0 !important; z-index: 2; }
+55 -32
View File
@@ -258,6 +258,7 @@
const k = el('i', 'ov-key'); k.style.background = `var(--ov-${s.key})`;
item.appendChild(k); item.appendChild(el('span', null, `${s.label} `)); item.appendChild(el('b', null, String(counts[s.key])));
item.onclick = () => openList('stage', s.key);
if (state.kind === 'stage' && state.arg === s.key) item.classList.add('ov-selected');
legend.appendChild(item);
});
const other = all.length - usmt.length;
@@ -282,6 +283,7 @@
t.appendChild(top);
t.appendChild(el('div', 'ov-tile-num', waiting ? '–' : String(n)));
t.appendChild(el('div', 'ov-tile-sub', waiting ? 'waiting for worker' : (n ? issue.sub(list) : 'clear')));
if (state.kind === 'issue' && state.arg === issue.key) t.appendChild(el('span', 'ov-showing', 'Listed below \u2193'));
t.setAttribute('aria-label', `${issue.label}: ${n}. ${issue.hint}`);
clickable(t, () => openList('issue', issue.key));
issuesWrap.appendChild(t);
@@ -303,6 +305,9 @@
row.appendChild(el('span', 'ov-bar-label', s));
const track = el('span', 'ov-bar-track');
const bar = el('span', 'ov-bar');
const st = s === 'Completed' ? 'done' : IN_PROGRESS.includes(s) ? 'doing' : ['Not Started', 'Not Associated'].includes(s) ? 'todo' : null;
bar.style.background = st ? `var(--ov-${st})` : 'var(--ov-other)';
if (state.kind === 'status' && state.arg === s) row.classList.add('ov-selected');
bar.style.width = n ? `max(4px, ${(100 * n / max).toFixed(1)}%)` : '0';
track.appendChild(bar);
row.appendChild(track);
@@ -325,7 +330,7 @@
frag.appendChild(calendarCard(all));
// Drill-down
const drill = el('section', 'ov-card ov-drill');
const drill = el('section', 'table-wrapper ov-drill');
drill.id = 'ov-drill';
frag.appendChild(drill);
@@ -354,17 +359,19 @@
function locationCard(all) {
const c = card('Where the work is', null);
const toggle = el('div', 'ov-toggle');
[['left', 'Still to migrate'], ['all', 'All devices']].forEach(([k, label]) => {
[['left', 'Still to migrate'], ['all', 'All migrations']].forEach(([k, label]) => {
const b = el('button', state.map === k ? 'on' : '', label); b.type = 'button';
b.onclick = () => { state.map = k; render(); };
toggle.appendChild(b);
});
c.querySelector('.ov-card-head').appendChild(toggle);
// Every USMT migration, by source location. The cell shows how many are left (or all of
// them) and its color shows how far along the location is: red = little done, green = done.
const cells = {}, buildings = new Set(), floors = new Set();
let unknown = 0;
all.forEach(r => {
if (state.map === 'left' && (stageOf(r) === 'done' || stageOf(r) === null)) return;
if (stageOf(r) === null) return;
const a = adv(r['Source Device']);
const b = a && a.Building && a.Building !== 'N/A' ? a.Building : null;
const f = floorOf(a);
@@ -381,40 +388,46 @@
c.appendChild(el('p', 'ov-empty-text', all.length ? 'No location data yet (waiting for inventory).' : 'Waiting for the worker...'));
return c;
}
const max = Math.max(1, ...Object.values(cells).map(v => v.length));
const BANDS = ['Under 20% done', '20-39% done', '40-59% done', '60-79% done', '80% or more done'];
const band = pct => Math.min(4, Math.floor(pct / 20));
const wrap = el('div', 'ov-heat-wrap');
const table = el('table', 'ov-heat');
const thead = el('thead'), hr = el('tr');
hr.appendChild(el('th', 'ov-heat-corner', 'Building'));
fList.forEach(f => hr.appendChild(el('th', null, f === '?' ? 'Unknown' : 'Floor ' + f)));
hr.appendChild(el('th', 'ov-heat-total', 'Total'));
hr.appendChild(el('th', 'ov-heat-total', state.map === 'left' ? 'Left' : 'Total'));
thead.appendChild(hr); table.appendChild(thead);
const tb = el('tbody');
const sel = state.kind === 'place' ? state.arg : null;
bList.forEach(b => {
const tr = el('tr');
tr.appendChild(el('th', null, b));
let total = 0;
let total = 0, totalDone = 0;
fList.forEach(f => {
const list = cells[b + '|' + f] || [];
total += list.length;
const td = el('td');
if (list.length) {
const cell = el('span', 'ov-heat-cell', String(list.length));
const t = list.length / max;
// sequential blue: 7 steps, light -> dark; text flips to white on dark steps
const step = Math.min(6, Math.floor(t * 6.999));
cell.classList.add('ov-s' + step);
const c3 = { todo: 0, doing: 0, done: 0 };
list.forEach(r => { const s = stageOf(r); if (s) c3[s]++; });
hover(cell, e => showTip(e, String(list.length), `${b}, ${f === '?' ? 'floor unknown' : 'floor ' + f}`,
STAGES.filter(s => c3[s.key]).map(s => [s.key, `${c3[s.key]} ${s.label.toLowerCase()}`])));
list.forEach(r => c3[stageOf(r)]++);
const left = list.length - c3.done;
total += list.length; totalDone += c3.done;
const pct = Math.round(100 * c3.done / list.length);
const shown = state.map === 'left' ? left : list.length;
const cell = el('span', 'ov-heat-cell ov-h' + band(pct), String(shown));
if (sel && sel.b === b && sel.f === f) cell.classList.add('ov-selected');
const where = `${b}, ${f === '?' ? 'floor unknown' : 'floor ' + f}`;
cell.setAttribute('aria-label', `${where}: ${left} of ${list.length} left, ${pct}% done`);
hover(cell, e => showTip(e, `${pct}% done`, where,
[[null, `${left} of ${list.length} still to migrate`]].concat(STAGES.filter(s => c3[s.key]).map(s => [s.key, `${c3[s.key]} ${s.label.toLowerCase()}`]))));
clickable(cell, () => openList('place', { b, f }));
td.appendChild(cell);
} else td.appendChild(el('span', 'ov-heat-none', '·'));
} else td.appendChild(el('span', 'ov-heat-none', '\u00b7'));
tr.appendChild(td);
});
const tt = el('td', 'ov-heat-total');
const tbtn = el('span', 'ov-heat-sum', String(total));
const tbtn = el('span', 'ov-heat-sum', String(state.map === 'left' ? total - totalDone : total));
tbtn.title = total ? `${Math.round(100 * totalDone / total)}% done` : '';
if (sel && sel.b === b && sel.f === null) tbtn.classList.add('ov-selected');
clickable(tbtn, () => openList('place', { b, f: null }));
tt.appendChild(tbtn);
tr.appendChild(tt);
@@ -423,8 +436,11 @@
table.appendChild(tb);
wrap.appendChild(table);
c.appendChild(wrap);
const note = el('p', 'ov-note', (state.map === 'left' ? 'Devices not yet completed, by source location. ' : 'Every tracked source, by location. ') +
'The stronger the shade, the more devices.' + (unknown ? ` ${plural(unknown, 'device')} without a building yet.` : ''));
const key = el('div', 'ov-heat-key');
BANDS.forEach((label, i) => { const k = el('span', 'ov-heat-key-item'); const sw = el('i', 'ov-h' + i); k.appendChild(sw); k.appendChild(el('span', null, label)); key.appendChild(k); });
c.appendChild(key);
const note = el('p', 'ov-note', (state.map === 'left' ? 'Numbers are migrations still to finish' : 'Numbers are all USMT migrations') +
' at each source location; the color is how much of that location is done.' + (unknown ? ` ${plural(unknown, 'device')} without a building yet.` : ''));
c.appendChild(note);
return c;
}
@@ -528,7 +544,7 @@
const { b, f } = state.arg;
title = f === null ? b : `${b}, ${f === '?' ? 'floor unknown' : 'floor ' + f}`;
items = all.filter(r => {
if (state.map === 'left' && (stageOf(r) === 'done' || stageOf(r) === null)) return false;
if (stageOf(r) === null || (state.map === 'left' && stageOf(r) === 'done')) return false;
const a = adv(r['Source Device']);
return a && a.Building === b && (f === null || (floorOf(a) || '?') === f);
}).map(r => { const a = adv(r['Source Device']); return { r, detail: a ? a.Location : '' }; });
@@ -540,9 +556,9 @@
}
box.appendChild(listHead(title, items.length));
if (!items.length) { box.appendChild(el('p', 'ov-empty-text', 'Nothing here. That one is at zero.')); return; }
const wrap = el('div', 'table-wrapper ov-table-wrap');
if (!items.length) { box.appendChild(el('p', 'ov-empty-text ov-drill-empty', 'Nothing here. That one is at zero.')); return; }
const table = el('table', 'master-table ov-table');
table.id = 'ov-table';
const thead = el('thead'), tr = el('tr');
['Source Device', 'Destination', 'Backup Status', detailLabel, 'Tags'].forEach(h => tr.appendChild(el('th', null, h)));
thead.appendChild(tr); table.appendChild(thead);
@@ -559,22 +575,24 @@
tb.appendChild(row);
});
table.appendChild(tb);
wrap.appendChild(table);
box.appendChild(wrap);
box.appendChild(table);
if (window.ExcelFilter) ExcelFilter.refresh(table);
}
// Same header as the dashboard's other tables (sticky, with the Excel filter "Clear" button)
function listHead(title, n) {
const h = el('div', 'ov-card-head');
h.appendChild(el('h2', null, `${title} (${n})`));
const h = el('div', 'group-header ov-drill-head');
h.appendChild(el('span', 'ov-drill-title', `${title} (${n})`));
const copy = el('button', 'ov-btn', 'Copy names'); copy.type = 'button';
copy.onclick = () => {
const names = Array.from(document.querySelectorAll('#ov-drill tbody tr td:first-child')).map(td => td.textContent.trim());
const names = Array.from(document.querySelectorAll('#ov-drill tbody tr')).filter(tr => tr.style.display !== 'none').map(tr => tr.cells[0].textContent.trim());
if (typeof fallbackCopyTextToClipboard === 'function') fallbackCopyTextToClipboard(names.join('\n'));
copy.textContent = 'Copied'; setTimeout(() => { copy.textContent = 'Copy names'; }, 1500);
};
const close = el('button', 'ov-btn', 'Close'); close.type = 'button';
close.onclick = () => { state.kind = null; state.arg = null; render(); };
const btns = el('div', 'ov-head-btns'); btns.appendChild(copy); btns.appendChild(close);
copy.className = close.className = 'copy-table-btn no-copy';
h.appendChild(btns);
return h;
}
@@ -582,9 +600,9 @@
function renderApps(box, title, items) {
box.appendChild(listHead(title, items.length));
if (!items.length) { box.appendChild(el('p', 'ov-empty-text', 'Every specialty app has an article.')); return; }
box.appendChild(el('p', 'ov-note', 'Specialty apps found on source devices that have no knowledge-base article with the same name. Write one so techs know how to install it.'));
const wrap = el('div', 'table-wrapper ov-table-wrap');
box.appendChild(el('p', 'ov-note ov-drill-note', 'Specialty apps found on source devices that have no knowledge-base article with the same name. Write one so techs know how to install it.'));
const table = el('table', 'master-table ov-table');
table.id = 'ov-table';
const thead = el('thead'), tr = el('tr');
['App', 'Devices', 'Sources with it', ''].forEach(h => tr.appendChild(el('th', null, h)));
thead.appendChild(tr); table.appendChild(thead);
@@ -609,8 +627,8 @@
tb.appendChild(row);
});
table.appendChild(tb);
wrap.appendChild(table);
box.appendChild(wrap);
box.appendChild(table);
if (window.ExcelFilter) ExcelFilter.refresh(table);
}
// ---------------------------------------------------------------------------------
@@ -630,7 +648,12 @@
render();
}
window.Overview = { render: () => maybeRender(true), refresh: () => maybeRender(false) };
window.Overview = {
render: () => maybeRender(true), refresh: () => maybeRender(false),
// used by the Excel-style filters: filters belong to the list that's open
listKey: () => 'overview:' + state.kind + ':' + JSON.stringify(state.arg),
renderList: () => renderList()
};
document.addEventListener('DOMContentLoaded', () => {
const orig = window.switchPage;
+76
View File
@@ -0,0 +1,76 @@
/*
* USMT Dash - page routes
*
* Every page in the nav has its own address, so links, bookmarks, new tabs and the
* browser's back / forward buttons land on the right page without loading the home page
* first: / (Overview) /tracked /workbench /status /devices /custom /docs /docs/<title>
* The server answers all of these with the dashboard itself.
*/
(function () {
'use strict';
const ROUTES = {
'overview': '/',
'tracked-devices': '/tracked',
'workbench': '/workbench',
'all-devices': '/status',
'software': '/devices',
'custom': '/custom',
'docs': '/docs'
};
const ALIASES = { '/overview': 'overview', '/home': 'overview' };
function pageFor(path) {
path = (path || '/').replace(/\/+$/, '') || '/';
if (path === '/docs' || path.startsWith('/docs/')) return 'docs';
if (ALIASES[path]) return ALIASES[path];
return Object.keys(ROUTES).find(id => ROUTES[id] === path) || null;
}
const docTitle = () => (typeof currentDocTitle !== 'undefined' && currentDocTitle) ? currentDocTitle : '';
function pathFor(id) {
if (id === 'docs') {
if (location.pathname.startsWith('/docs/')) return location.pathname; // docs.js already set the article
return docTitle() ? '/docs/' + encodeURIComponent(docTitle()) : '/docs';
}
return ROUTES[id] || '/';
}
let quiet = false; // true while following the address bar (no new history entries)
function go(id) {
if (!ROUTES[id]) return;
const path = pathFor(id);
if (quiet || location.pathname === path) return;
try { history.pushState({ page: id }, '', path + location.hash); } catch (e) {}
}
function follow() {
const id = pageFor(location.pathname);
if (!id) return;
quiet = true;
try {
if (id === 'docs') {
const m = location.pathname.match(/^\/docs\/(.+)$/);
let title = null;
if (m) { try { title = decodeURIComponent(m[1]); } catch (e) {} }
if (!(title && window.KB && KB.open(title))) switchPage('docs');
} else {
switchPage(id);
}
} finally { quiet = false; }
}
document.addEventListener('DOMContentLoaded', () => {
const orig = window.switchPage;
if (typeof orig !== 'function') return;
window.switchPage = function (id) {
const r = orig.apply(this, arguments);
go(id);
return r;
};
// Open the page in the address bar right away (articles open once the list has loaded)
const id = pageFor(location.pathname);
if (id && id !== 'overview' && id !== 'docs') { quiet = true; try { orig(id); } finally { quiet = false; } }
if (id === 'docs' && location.pathname === '/docs') { quiet = true; try { orig('docs'); } finally { quiet = false; } }
window.addEventListener('popstate', follow);
});
})();