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

+3 -3
View File
@@ -52,9 +52,9 @@
# A task-sequence log modified within this window counts as "active" # A task-sequence log modified within this window counts as "active"
ActiveLogHours = 24 ActiveLogHours = 24
# Every tracked device (including completed pairs and destinations the sweep skips) # Completed pairs skip network checks in the sweep, so they're pinged this often to keep
# is pinged this often to keep "last online" / "days offline" current. Cycles only run # "last online" / "days offline" current (every other device is pinged every cycle).
# in business hours plus the midnight pitstop, so 60 = hourly by day, plus midnight. # Cycles only run in business hours plus the midnight pitstop: 60 = hourly, plus midnight.
PresenceSweepMinutes = 60 PresenceSweepMinutes = 60
} }
+8 -3
View File
@@ -52,12 +52,16 @@ a 1,300-device scope.
location, department, users, for both sides of a migration. A blank says why: *Not in AD*, location, department, users, for both sides of a migration. A blank says why: *Not in AD*,
*Not in SCCM*, *No DNS record*, or *Waiting for worker...* if it hasn't been looked up yet. *Not in SCCM*, *No DNS record*, or *Waiting for worker...* if it hasn't been looked up yet.
The dialog refreshes in place when the worker finishes a cycle. The dialog refreshes in place when the worker finishes a cycle.
- **Presence**: every tracked device (completed pairs and destinations included) is pinged - **Presence**: every ping is recorded, so each device shows when it was last online and how
hourly during business hours and at midnight, so each one shows when it was last online and many days it has been offline. Completed pairs, which skip network checks in the sweep, are
how many days it has been offline. pinged hourly during business hours and at midnight to keep theirs current.
- **Snapshots**: the last free space and scanstate / loadstate lines seen while a device was - **Snapshots**: the last free space and scanstate / loadstate lines seen while a device was
reachable are kept with their timestamps, and shown for completed migrations and offline reachable are kept with their timestamps, and shown for completed migrations and offline
devices instead of blanks. devices instead of blanks.
- **Excel-style filters** on every table: sort, search and tick values; list columns match
*any*, *only* or *exactly* the ticked items. On software columns, one click shows devices
whose apps all have a documentation article, and *Easiest first* sorts by how many apps
still lack one.
- **Custom View**: build your own table from any field (including days offline, last online, - **Custom View**: build your own table from any field (including days offline, last online,
found in AD/SCCM/DNS and monitors), with drag-to-reorder, resizable and Excel-style found in AD/SCCM/DNS and monitors), with drag-to-reorder, resizable and Excel-style
filterable columns. Saved per browser. filterable columns. Saved per browser.
@@ -106,6 +110,7 @@ refresh" buttons drop flag files that the worker watches for.
| Method & path | Purpose | | Method & path | Purpose |
|---|---| |---|---|
| `GET /` and static files | Dashboard, assets, `Data\` state files, `Documentation\` | | `GET /` and static files | Dashboard, assets, `Data\` state files, `Documentation\` |
| `GET /tracked` · `/workbench` · `/status` · `/devices` · `/custom` · `/docs` | The dashboard, opened on that page (the logo and name go to `/`, the Overview) |
| `GET /docs/<title>` | The dashboard, opened on that knowledge-base article | | `GET /docs/<title>` | The dashboard, opened on that knowledge-base article |
| `GET /get-config` | Mode flags for the front end | | `GET /get-config` | Mode flags for the front end |
| `GET /get-logs?source=server\|worker&after=<id>` | Tail of the service console output (terminal panel) | | `GET /get-logs?source=server\|worker&after=<id>` | Tail of the service console output (terminal panel) |
+4 -2
View File
@@ -472,8 +472,10 @@ while ($listener.IsListening) {
# --- STATIC FILES --- # --- STATIC FILES ---
if ([string]::IsNullOrWhiteSpace($localPath)) { $localPath = "index.html" } if ([string]::IsNullOrWhiteSpace($localPath)) { $localPath = "index.html" }
# Article permalinks (/docs/Adobe Acrobat Pro) are pages of the dashboard itself # Page routes (/tracked, /status, ...) and article permalinks (/docs/Adobe Acrobat Pro)
if ($localPath -eq "docs" -or ($localPath -like "docs/*" -and $localPath -notmatch '\.(md|png|jpe?g|gif|webp|svg|css|js)$')) { $localPath = "index.html" } # are pages of the dashboard itself
$pageRoutes = @("overview", "home", "tracked", "workbench", "status", "devices", "custom", "docs")
if ($pageRoutes -contains $localPath.TrimEnd('/') -or ($localPath -like "docs/*" -and $localPath -notmatch '\.(md|png|jpe?g|gif|webp|svg|css|js)$')) { $localPath = "index.html" }
# Data/ state files, Documentation/ articles + images, and web/ assets for everything else # Data/ state files, Documentation/ articles + images, and web/ assets for everything else
if ($publicDataFiles -contains $localPath) { if ($publicDataFiles -contains $localPath) {
+9 -5
View File
@@ -735,16 +735,20 @@ while ($true) {
$sweepSeconds = $phaseWatch.Elapsed.TotalSeconds $sweepSeconds = $phaseWatch.Elapsed.TotalSeconds
$phaseWatch.Restart() $phaseWatch.Restart()
# --- PRESENCE SWEEP: ping everything the sweep didn't (cached pairs, skipped tags...) --- # --- PRESENCE SWEEP: completed pairs ---
# Every other device is pinged by the sweep itself on every cycle. Completed pairs skip
# their network checks, so they're pinged here: hourly during business hours (cycles only
# run then) and on the midnight pitstop.
$presenceCount = 0 $presenceCount = 0
$presenceDue = ((Get-Date) - $global:LastPresenceSweep).TotalMinutes -ge ($presenceMinutes - 0.5) -or $runFullSoftwareSweep $presenceDue = ((Get-Date) - $global:LastPresenceSweep).TotalMinutes -ge ($presenceMinutes - 0.5) -or $runFullSoftwareSweep
if ($presenceDue) { if ($presenceDue) {
$everyone = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) $completedDevs = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
foreach ($r in $deviceTable) { foreach ($r in $deviceTable) {
foreach ($n in @($r."Source Device", $r."Destination Device")) { if ($n -and $n -ne "N/A") { $null = $everyone.Add($n) } } if ($r.Pairing -ne "Cached") { continue }
foreach ($n in @($r."Source Device", $r."Destination Device")) { if ($n -and $n -ne "N/A") { $null = $completedDevs.Add($n) } }
} }
$toPing = @($everyone | Where-Object { -not $global:PingedThisCycle.Contains($_) } | Sort-Object) $toPing = @($completedDevs | Where-Object { -not $global:PingedThisCycle.Contains($_) } | Sort-Object)
Write-Host "`n[Presence Sweep: $($toPing.Count) device(s) not pinged this cycle]" -ForegroundColor Cyan Write-Host "`n[Presence Sweep: pinging $($toPing.Count) device(s) from completed migrations]" -ForegroundColor Cyan
$i = 0 $i = 0
foreach ($n in $toPing) { foreach ($n in $toPing) {
$i++ $i++
+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 }; 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 // Whole days offline (0 when online or offline less than a day), null if never seen
function daysOffline(name) { function daysOffline(name) {
const p = presence(name); const p = presence(name);
@@ -121,6 +130,7 @@
function computed(name, key) { function computed(name, key) {
const a = entry(name); const a = entry(name);
if (!a) return { text: WAITING, state: 'waiting' }; 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') { if (key === 'DaysOffline') {
const d = daysOffline(name); const d = daysOffline(name);
if (d === undefined) return { text: WAITING, state: 'waiting' }; if (d === undefined) return { text: WAITING, state: 'waiting' };
@@ -164,7 +174,7 @@
function presenceHtml(name) { function presenceHtml(name) {
if (!isName(name)) return 'N/A'; if (!isName(name)) return 'N/A';
const p = presence(name); 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 txt = offlineText(name);
const cls = p.online ? 'di-online' : (p.days !== null && p.days >= 3 ? 'di-offline-long' : 'di-offline'); const cls = p.online ? 'di-online' : (p.days !== null && p.days >= 3 ? 'di-offline-long' : 'di-offline');
let sub = ''; let sub = '';
+9 -2
View File
@@ -10,8 +10,8 @@
/* groups + items */ /* 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 { 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-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 { 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 10px rgba(52,152,219,0.35); } .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.active { background: rgba(52,152,219,0.14); border-color: #3498db; }
.kb-item-main { min-width: 0; flex: 1; } .kb-item-main { min-width: 0; flex: 1; }
.kb-item a { color: #3498db; text-decoration: none; font-weight: 500; overflow-wrap: anywhere; } .kb-item a { color: #3498db; text-decoration: none; font-weight: 500; overflow-wrap: anywhere; }
@@ -64,3 +64,10 @@
@media (max-width: 1100px) { @media (max-width: 1100px) {
.kb-tool span { display: none; } .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' }; const ALERTS = { NOTE: 'Note', TIP: 'Tip', IMPORTANT: 'Important', WARNING: 'Warning', CAUTION: 'Caution' };
function decorate() { 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 => { document.querySelectorAll('#docContent blockquote').forEach(bq => {
if (bq.dataset.kb) return; if (bq.dataset.kb) return;
bq.dataset.kb = '1'; bq.dataset.kb = '1';
@@ -405,14 +412,7 @@
if (!m) ed.value = `<!-- category: ${sel.value} -->\n` + ed.value; if (!m) ed.value = `<!-- category: ${sel.value} -->\n` + ed.value;
} }
}); });
wrap('switchPage', id => { // Page routing (including /docs) lives in router.js; it hands /docs/<title> back here
if (id !== 'docs' && location.pathname.startsWith('/docs')) setUrl('/', false); window.KB = { open: name => { const d = findDoc(name); if (d) { openDoc(d); return true; } return 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');
});
}); });
})(); })();
+74 -14
View File
@@ -69,6 +69,17 @@
sortDir: i => { const s = sortStates['software']; return s && s.col === i ? s.dir : 'none'; } 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')) { if (table.closest('#stackGrid')) {
const sid = id.replace(/^table-/, ''); const sid = id.replace(/^table-/, '');
const key = 'stack-' + sid; 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; if (allowed.free.op === 'ge' ? sp.free < allowed.free.gb : sp.free >= allowed.free.gb) return false;
} }
const vals = cellValues(td, col); 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 // exact set: the row's items are precisely the ticked ones, no more, no fewer
const own = new Set(vals); const own = new Set(vals);
if (own.size !== allowed.size || !vals.every(v => allowed.has(v))) return false; if (own.size !== allowed.size || !vals.every(v => allowed.has(v))) return false;
@@ -206,12 +221,29 @@
decorate(table, ad, cols); 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) { function nativeSortRows(table, cols, s) {
const col = cols[s.col]; const col = cols[s.col];
if (!col) return; if (!col) return;
const body = table.tBodies[0]; const body = table.tBodies[0];
const rows = dataRows(table); const rows = dataRows(table);
const num = tr => { const sp = parseSpace(tr.cells[s.col]); return sp ? sp[s.by] : null; }; 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) => { rows.sort((a, b) => {
if (s.by) { // disk size / free space: no reading always last if (s.by) { // disk size / free space: no reading always last
const x = num(a), y = num(b); const x = num(a), y = num(b);
@@ -422,6 +454,15 @@
menu.appendChild(el('div', 'xf-sep')); menu.appendChild(el('div', 'xf-sep'));
} else if (col.sortable) { } else if (col.sortable) {
const dir = sortDirOf(ad, idx); 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'); }); 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'); 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'); }); 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 // List columns: rows having ANY ticked item, ONLY ticked items (nothing else), or
let onlyToggle = null; // EXACTLY the ticked items
let listMode = null, setMode = null;
if (col.list) { if (col.list) {
const seg = el('div', 'xf-seg'); const seg = el('div', 'xf-seg xf-seg3');
seg.title = 'Any: devices with at least one ticked item. Exactly: devices whose list is exactly the ticked items, nothing more and nothing less.'; seg.title = 'Any: at least one ticked item. Only: nothing except ticked items. Exactly: precisely the ticked items, nothing more and nothing less.';
const mk = (text, only) => { const curMode = !current || !current.only ? 'any' : (current.only === 'subset' ? 'subset' : 'exact');
const b = el('button', 'xf-seg-btn', text); b.type = 'button'; const btns = {};
b.onclick = () => { seg.querySelectorAll('.xf-seg-btn').forEach(x => x.classList.toggle('on', x === b)); }; setMode = m => { listMode = m; Object.entries(btns).forEach(([k, b]) => b.classList.toggle('on', k === m)); };
if (!!(current && current.only) === only) b.classList.add('on'); [['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); seg.appendChild(b);
return b; });
}; setMode(curMode);
mk('Has any ticked', false);
onlyToggle = mk('Exactly ticked', true);
menu.appendChild(seg); menu.appendChild(seg);
} }
@@ -553,6 +596,20 @@
}; };
pick('Documented', isDocumented); pick('Documented', isDocumented);
pick('Undocumented', v => !isDocumented(v)); 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 = () => { search.oninput = () => {
const q = search.value.trim().toLowerCase(); 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 chosen = new Set(visibleOpts().filter(o => o.cb.checked).map(o => o.v));
const free = freeRule ? freeRule() : null; const free = freeRule ? freeRule() : null;
if (free) chosen.free = free; 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] || {}; filters[ad.key] = filters[ad.key] || {};
// everything ticked and no extra rule = no filter // everything ticked and no extra rule = no filter
if (!q && chosen.size === values.length && !free) delete filters[ad.key][key]; if (!q && chosen.size === values.length && !free) delete filters[ad.key][key];
@@ -749,6 +806,9 @@
schedule(); 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); if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init(); else init();
})(); })();
+4 -6
View File
@@ -359,6 +359,7 @@
<script src="/device-info.js"></script> <script src="/device-info.js"></script>
<script src="/overview.js"></script> <script src="/overview.js"></script>
<script src="/docs.js"></script> <script src="/docs.js"></script>
<script src="/router.js"></script>
<script src="/excel-filter.js" defer></script> <script src="/excel-filter.js" defer></script>
</head> </head>
<body> <body>
@@ -372,10 +373,10 @@
</svg> </svg>
</button> </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"> <img src="/favicon.png" alt="USMT Logo" class="nav-logo">
<h2>USMT Dash</h2> <h2>USMT Dash</h2>
</div> </a>
<div class="nav-links" id="nav-links-menu"> <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 active" id="btn-overview" onclick="switchPage('overview')">Overview</button>
@@ -2829,7 +2830,7 @@
if (!match) return; if (!match) return;
const btnText = window.isAdvView ? "Simple View" : "Advanced View"; 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 statusBg = themes[currentTheme].groupColors[match["Backup Status"]] || "#ffffff";
const statusText = themes[currentTheme].textColors[match["Backup Status"]] || "#333333"; 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>` : ''} ${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 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 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>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>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 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>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>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>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>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>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> <tr><th>Tags</th><td ${tdStyle}>${match["Tags"] || 'None'}</td></tr>
</table> </table>
</div> </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; } #table-custom thead th { position: sticky !important; top: var(--sticky-top, 112px) !important; z-index: 20; }
.custom-hscroll { .custom-hscroll {
position: sticky; bottom: var(--dock-h, 40px); z-index: 15; position: sticky; bottom: var(--dock-h, 40px); z-index: 15;
display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 8px; display: flex; align-items: center; gap: 6px; height: 24px; padding: 0 8px;
background: var(--bg-card); border-top: 2px solid #3498db; border-radius: 0 0 8px 8px; background: var(--bg-card); border-top: 2px solid var(--table-header-bg); border-radius: 0 0 8px 8px;
box-shadow: 0 -3px 10px rgba(0,0,0,0.18); user-select: none; touch-action: none; box-shadow: 0 -2px 8px rgba(0,0,0,0.15); user-select: none; touch-action: none;
} }
.custom-hscroll[hidden] { display: none; } .custom-hscroll[hidden] { display: none; }
.chs-btn { .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; 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-btn:disabled { opacity: 0.35; cursor: default; }
.chs-track { .chs-track {
position: relative; flex: 1 1 auto; height: 14px; border-radius: 7px; cursor: pointer; position: relative; flex: 1 1 auto; height: 10px; border-radius: 5px; cursor: pointer;
background: rgba(127,127,127,0.22); box-shadow: inset 0 1px 2px rgba(0,0,0,0.25); outline: none; 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 { .chs-thumb {
position: absolute; top: -3px; left: 0; height: 20px; min-width: 48px; border-radius: 10px; position: absolute; top: -2px; left: 0; height: 14px; min-width: 40px; border-radius: 7px;
background: #3498db; cursor: grab; box-shadow: 0 1px 4px rgba(0,0,0,0.35); background: var(--table-header-bg); cursor: grab; box-shadow: 0 1px 3px rgba(0,0,0,0.35);
display: flex; align-items: center; justify-content: center; transition: background 0.15s; 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 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 { background: #1d7fc4; } .chs-thumb:hover, .custom-hscroll.dragging .chs-thumb { filter: brightness(1.3); }
.custom-hscroll.dragging .chs-thumb { cursor: grabbing; } .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; } .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.16); } [data-theme="dark"] .chs-track { background: rgba(255,255,255,0.12); }
[data-theme="dark"] .chs-thumb { background: #5dade2; } [data-theme="dark"] .chs-thumb { border-color: rgba(255,255,255,0.35); }
[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); }
/* Custom View header on one line like the other tables: drag grip, name, then the /* 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 */ 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; } .xf-clear-all svg { width: 14px; height: 14px; flex: none; }
/* Software menus: documented / undocumented quick picks, doc marker, "None" pinned first */ /* 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 { .xf-pick {
flex: 1; padding: 5px 6px; cursor: pointer; font: inherit; font-size: 0.82em; white-space: nowrap; 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; 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; } .nav-logo { display: none; }
.top-nav .nav-btn { padding: 8px 4px; } .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 -> /* 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 completed) and a sequential blue ramp for counts, each stepped separately for dark mode
(validated with the dataviz palette checker against the card surfaces). */ (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 { #overview {
--ov-todo: #86b6ef; --ov-doing: #2a78d6; --ov-done: #104281; --ov-todo: #d03b3b; --ov-doing: #f0a20c; --ov-done: #0ca30c; --ov-other: #8a8f98;
--ov-bar: #2a78d6; --ov-track: rgba(127,127,127,0.12); --ov-track: rgba(127,127,127,0.12);
--ov-good: #0ca30c; --ov-serious: #ec835a; --ov-good: #0ca30c; --ov-serious: #ec835a;
} }
[data-theme="dark"] #overview { [data-theme="dark"] #overview { --ov-todo: #e05555; --ov-doing: #e8a317; --ov-done: #2fb344; --ov-other: #6b7280; --ov-track: rgba(255,255,255,0.07); }
--ov-todo: #184f95; --ov-doing: #3987e5; --ov-done: #9ec5f4; /* Location bands: how much of a location is done, red -> green */
--ov-bar: #3987e5; --ov-track: rgba(255,255,255,0.07); .ov-h0 { background: #d03b3b; color: #fff; }
} .ov-h1 { background: #ec835a; color: #1f2328; }
.ov-s0 { background: #cde2fb; color: #0b0b0b; } .ov-s1 { background: #b7d3f6; color: #0b0b0b; } .ov-h2 { background: #fab219; color: #1f2328; }
.ov-s2 { background: #9ec5f4; color: #0b0b0b; } .ov-s3 { background: #6da7ec; color: #0b0b0b; } .ov-h3 { background: #8fd16a; color: #1f2328; }
.ov-s4 { background: #3987e5; color: #fff; } .ov-s5 { background: #256abf; color: #fff; } .ov-h4 { background: #0ca30c; color: #fff; }
.ov-s6 { background: #184f95; color: #fff; } .ov-heat-key { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 10px; }
[data-theme="dark"] .ov-s0 { background: #104281; color: #fff; } [data-theme="dark"] .ov-s1 { background: #184f95; color: #fff; } .ov-heat-key-item { display: inline-flex; align-items: center; gap: 6px; font-size: 0.8em; color: var(--text-muted); }
[data-theme="dark"] .ov-s2 { background: #1c5cab; color: #fff; } [data-theme="dark"] .ov-s3 { background: #256abf; color: #fff; } .ov-heat-key-item i { width: 14px; height: 10px; border-radius: 3px; }
[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; } /* 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; } #overview-root { max-width: 1500px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; }
.ov-title { margin: 0; color: var(--text-main); } .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-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-label { font-size: 0.88em; color: var(--text-main); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ov-bar-track { height: 14px; } .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; } .ov-bar-value { text-align: right; font-weight: 700; color: var(--text-main); font-variant-numeric: tabular-nums; }
/* heatmap */ /* 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 { box-shadow: inset 0 0 0 2px #3498db; }
.ov-today .ov-day-num { color: #3498db; font-weight: 700; } .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 */ /* 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; } .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-row { grid-template-columns: 128px 1fr 32px; }
.ov-bar-label { font-size: 0.82em; } .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})`; 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.appendChild(k); item.appendChild(el('span', null, `${s.label} `)); item.appendChild(el('b', null, String(counts[s.key])));
item.onclick = () => openList('stage', s.key); item.onclick = () => openList('stage', s.key);
if (state.kind === 'stage' && state.arg === s.key) item.classList.add('ov-selected');
legend.appendChild(item); legend.appendChild(item);
}); });
const other = all.length - usmt.length; const other = all.length - usmt.length;
@@ -282,6 +283,7 @@
t.appendChild(top); t.appendChild(top);
t.appendChild(el('div', 'ov-tile-num', waiting ? '–' : String(n))); 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'))); 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}`); t.setAttribute('aria-label', `${issue.label}: ${n}. ${issue.hint}`);
clickable(t, () => openList('issue', issue.key)); clickable(t, () => openList('issue', issue.key));
issuesWrap.appendChild(t); issuesWrap.appendChild(t);
@@ -303,6 +305,9 @@
row.appendChild(el('span', 'ov-bar-label', s)); row.appendChild(el('span', 'ov-bar-label', s));
const track = el('span', 'ov-bar-track'); const track = el('span', 'ov-bar-track');
const bar = el('span', 'ov-bar'); 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'; bar.style.width = n ? `max(4px, ${(100 * n / max).toFixed(1)}%)` : '0';
track.appendChild(bar); track.appendChild(bar);
row.appendChild(track); row.appendChild(track);
@@ -325,7 +330,7 @@
frag.appendChild(calendarCard(all)); frag.appendChild(calendarCard(all));
// Drill-down // Drill-down
const drill = el('section', 'ov-card ov-drill'); const drill = el('section', 'table-wrapper ov-drill');
drill.id = 'ov-drill'; drill.id = 'ov-drill';
frag.appendChild(drill); frag.appendChild(drill);
@@ -354,17 +359,19 @@
function locationCard(all) { function locationCard(all) {
const c = card('Where the work is', null); const c = card('Where the work is', null);
const toggle = el('div', 'ov-toggle'); 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'; const b = el('button', state.map === k ? 'on' : '', label); b.type = 'button';
b.onclick = () => { state.map = k; render(); }; b.onclick = () => { state.map = k; render(); };
toggle.appendChild(b); toggle.appendChild(b);
}); });
c.querySelector('.ov-card-head').appendChild(toggle); 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(); const cells = {}, buildings = new Set(), floors = new Set();
let unknown = 0; let unknown = 0;
all.forEach(r => { 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 a = adv(r['Source Device']);
const b = a && a.Building && a.Building !== 'N/A' ? a.Building : null; const b = a && a.Building && a.Building !== 'N/A' ? a.Building : null;
const f = floorOf(a); 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...')); c.appendChild(el('p', 'ov-empty-text', all.length ? 'No location data yet (waiting for inventory).' : 'Waiting for the worker...'));
return c; 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 wrap = el('div', 'ov-heat-wrap');
const table = el('table', 'ov-heat'); const table = el('table', 'ov-heat');
const thead = el('thead'), hr = el('tr'); const thead = el('thead'), hr = el('tr');
hr.appendChild(el('th', 'ov-heat-corner', 'Building')); hr.appendChild(el('th', 'ov-heat-corner', 'Building'));
fList.forEach(f => hr.appendChild(el('th', null, f === '?' ? 'Unknown' : 'Floor ' + f))); 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); thead.appendChild(hr); table.appendChild(thead);
const tb = el('tbody'); const tb = el('tbody');
const sel = state.kind === 'place' ? state.arg : null;
bList.forEach(b => { bList.forEach(b => {
const tr = el('tr'); const tr = el('tr');
tr.appendChild(el('th', null, b)); tr.appendChild(el('th', null, b));
let total = 0; let total = 0, totalDone = 0;
fList.forEach(f => { fList.forEach(f => {
const list = cells[b + '|' + f] || []; const list = cells[b + '|' + f] || [];
total += list.length;
const td = el('td'); const td = el('td');
if (list.length) { 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 }; const c3 = { todo: 0, doing: 0, done: 0 };
list.forEach(r => { const s = stageOf(r); if (s) c3[s]++; }); list.forEach(r => c3[stageOf(r)]++);
hover(cell, e => showTip(e, String(list.length), `${b}, ${f === '?' ? 'floor unknown' : 'floor ' + f}`, const left = list.length - c3.done;
STAGES.filter(s => c3[s.key]).map(s => [s.key, `${c3[s.key]} ${s.label.toLowerCase()}`]))); 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 })); clickable(cell, () => openList('place', { b, f }));
td.appendChild(cell); td.appendChild(cell);
} else td.appendChild(el('span', 'ov-heat-none', '·')); } else td.appendChild(el('span', 'ov-heat-none', '\u00b7'));
tr.appendChild(td); tr.appendChild(td);
}); });
const tt = el('td', 'ov-heat-total'); 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 })); clickable(tbtn, () => openList('place', { b, f: null }));
tt.appendChild(tbtn); tt.appendChild(tbtn);
tr.appendChild(tt); tr.appendChild(tt);
@@ -423,8 +436,11 @@
table.appendChild(tb); table.appendChild(tb);
wrap.appendChild(table); wrap.appendChild(table);
c.appendChild(wrap); c.appendChild(wrap);
const note = el('p', 'ov-note', (state.map === 'left' ? 'Devices not yet completed, by source location. ' : 'Every tracked source, by location. ') + const key = el('div', 'ov-heat-key');
'The stronger the shade, the more devices.' + (unknown ? ` ${plural(unknown, 'device')} without a building yet.` : '')); 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); c.appendChild(note);
return c; return c;
} }
@@ -528,7 +544,7 @@
const { b, f } = state.arg; const { b, f } = state.arg;
title = f === null ? b : `${b}, ${f === '?' ? 'floor unknown' : 'floor ' + f}`; title = f === null ? b : `${b}, ${f === '?' ? 'floor unknown' : 'floor ' + f}`;
items = all.filter(r => { 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']); const a = adv(r['Source Device']);
return a && a.Building === b && (f === null || (floorOf(a) || '?') === f); return a && a.Building === b && (f === null || (floorOf(a) || '?') === f);
}).map(r => { const a = adv(r['Source Device']); return { r, detail: a ? a.Location : '' }; }); }).map(r => { const a = adv(r['Source Device']); return { r, detail: a ? a.Location : '' }; });
@@ -540,9 +556,9 @@
} }
box.appendChild(listHead(title, items.length)); box.appendChild(listHead(title, items.length));
if (!items.length) { box.appendChild(el('p', 'ov-empty-text', 'Nothing here. That one is at zero.')); return; } if (!items.length) { box.appendChild(el('p', 'ov-empty-text ov-drill-empty', 'Nothing here. That one is at zero.')); return; }
const wrap = el('div', 'table-wrapper ov-table-wrap');
const table = el('table', 'master-table ov-table'); const table = el('table', 'master-table ov-table');
table.id = 'ov-table';
const thead = el('thead'), tr = el('tr'); const thead = el('thead'), tr = el('tr');
['Source Device', 'Destination', 'Backup Status', detailLabel, 'Tags'].forEach(h => tr.appendChild(el('th', null, h))); ['Source Device', 'Destination', 'Backup Status', detailLabel, 'Tags'].forEach(h => tr.appendChild(el('th', null, h)));
thead.appendChild(tr); table.appendChild(thead); thead.appendChild(tr); table.appendChild(thead);
@@ -559,22 +575,24 @@
tb.appendChild(row); tb.appendChild(row);
}); });
table.appendChild(tb); table.appendChild(tb);
wrap.appendChild(table); box.appendChild(table);
box.appendChild(wrap); 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) { function listHead(title, n) {
const h = el('div', 'ov-card-head'); const h = el('div', 'group-header ov-drill-head');
h.appendChild(el('h2', null, `${title} (${n})`)); h.appendChild(el('span', 'ov-drill-title', `${title} (${n})`));
const copy = el('button', 'ov-btn', 'Copy names'); copy.type = 'button'; const copy = el('button', 'ov-btn', 'Copy names'); copy.type = 'button';
copy.onclick = () => { 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')); if (typeof fallbackCopyTextToClipboard === 'function') fallbackCopyTextToClipboard(names.join('\n'));
copy.textContent = 'Copied'; setTimeout(() => { copy.textContent = 'Copy names'; }, 1500); copy.textContent = 'Copied'; setTimeout(() => { copy.textContent = 'Copy names'; }, 1500);
}; };
const close = el('button', 'ov-btn', 'Close'); close.type = 'button'; const close = el('button', 'ov-btn', 'Close'); close.type = 'button';
close.onclick = () => { state.kind = null; state.arg = null; render(); }; close.onclick = () => { state.kind = null; state.arg = null; render(); };
const btns = el('div', 'ov-head-btns'); btns.appendChild(copy); btns.appendChild(close); 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); h.appendChild(btns);
return h; return h;
} }
@@ -582,9 +600,9 @@
function renderApps(box, title, items) { function renderApps(box, title, items) {
box.appendChild(listHead(title, items.length)); box.appendChild(listHead(title, items.length));
if (!items.length) { box.appendChild(el('p', 'ov-empty-text', 'Every specialty app has an article.')); return; } 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.')); 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 wrap = el('div', 'table-wrapper ov-table-wrap');
const table = el('table', 'master-table ov-table'); const table = el('table', 'master-table ov-table');
table.id = 'ov-table';
const thead = el('thead'), tr = el('tr'); const thead = el('thead'), tr = el('tr');
['App', 'Devices', 'Sources with it', ''].forEach(h => tr.appendChild(el('th', null, h))); ['App', 'Devices', 'Sources with it', ''].forEach(h => tr.appendChild(el('th', null, h)));
thead.appendChild(tr); table.appendChild(thead); thead.appendChild(tr); table.appendChild(thead);
@@ -609,8 +627,8 @@
tb.appendChild(row); tb.appendChild(row);
}); });
table.appendChild(tb); table.appendChild(tb);
wrap.appendChild(table); box.appendChild(table);
box.appendChild(wrap); if (window.ExcelFilter) ExcelFilter.refresh(table);
} }
// --------------------------------------------------------------------------------- // ---------------------------------------------------------------------------------
@@ -630,7 +648,12 @@
render(); 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', () => { document.addEventListener('DOMContentLoaded', () => {
const orig = window.switchPage; 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);
});
})();