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"
ActiveLogHours = 24
# Every tracked device (including completed pairs and destinations the sweep skips)
# is pinged this often to keep "last online" / "days offline" current. Cycles only run
# in business hours plus the midnight pitstop, so 60 = hourly by day, plus midnight.
# Completed pairs skip network checks in the sweep, so they're pinged this often to keep
# "last online" / "days offline" current (every other device is pinged every cycle).
# Cycles only run in business hours plus the midnight pitstop: 60 = hourly, plus midnight.
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*,
*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.
- **Presence**: every tracked device (completed pairs and destinations included) is pinged
hourly during business hours and at midnight, so each one shows when it was last online and
how many days it has been offline.
- **Presence**: every ping is recorded, so each device shows when it was last online and how
many days it has been offline. Completed pairs, which skip network checks in the sweep, are
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
reachable are kept with their timestamps, and shown for completed migrations and offline
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,
found in AD/SCCM/DNS and monitors), with drag-to-reorder, resizable and Excel-style
filterable columns. Saved per browser.
@@ -106,6 +110,7 @@ refresh" buttons drop flag files that the worker watches for.
| Method & path | Purpose |
|---|---|
| `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 /get-config` | Mode flags for the front end |
| `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 ---
if ([string]::IsNullOrWhiteSpace($localPath)) { $localPath = "index.html" }
# Article permalinks (/docs/Adobe Acrobat Pro) are pages of the dashboard itself
if ($localPath -eq "docs" -or ($localPath -like "docs/*" -and $localPath -notmatch '\.(md|png|jpe?g|gif|webp|svg|css|js)$')) { $localPath = "index.html" }
# Page routes (/tracked, /status, ...) and article permalinks (/docs/Adobe Acrobat Pro)
# 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
if ($publicDataFiles -contains $localPath) {
+9 -5
View File
@@ -735,16 +735,20 @@ while ($true) {
$sweepSeconds = $phaseWatch.Elapsed.TotalSeconds
$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
$presenceDue = ((Get-Date) - $global:LastPresenceSweep).TotalMinutes -ge ($presenceMinutes - 0.5) -or $runFullSoftwareSweep
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 ($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)
Write-Host "`n[Presence Sweep: $($toPing.Count) device(s) not pinged this cycle]" -ForegroundColor Cyan
$toPing = @($completedDevs | Where-Object { -not $global:PingedThisCycle.Contains($_) } | Sort-Object)
Write-Host "`n[Presence Sweep: pinging $($toPing.Count) device(s) from completed migrations]" -ForegroundColor Cyan
$i = 0
foreach ($n in $toPing) {
$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 };
}
// 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);
});
})();