diff --git a/Config/usmt-dash.psd1 b/Config/usmt-dash.psd1
index 5ad570a..89b0461 100644
--- a/Config/usmt-dash.psd1
+++ b/Config/usmt-dash.psd1
@@ -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
}
diff --git a/README.md b/README.md
index 7f1d360..624ed57 100644
--- a/README.md
+++ b/README.md
@@ -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/
` | The dashboard, opened on that knowledge-base article |
| `GET /get-config` | Mode flags for the front end |
| `GET /get-logs?source=server\|worker&after=` | Tail of the service console output (terminal panel) |
diff --git a/USMT-Server.ps1 b/USMT-Server.ps1
index 64f0fbd..40ea054 100755
--- a/USMT-Server.ps1
+++ b/USMT-Server.ps1
@@ -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) {
diff --git a/USMT-Worker.ps1 b/USMT-Worker.ps1
index a3e5047..b42990e 100755
--- a/USMT-Worker.ps1
+++ b/USMT-Worker.ps1
@@ -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++
diff --git a/web/device-info.js b/web/device-info.js
index bdc9ef3..a9e0d15 100644
--- a/web/device-info.js
+++ b/web/device-info.js
@@ -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 `${WAITING}`;
+ if (!p) return notPinged(name) ? `${NOT_PINGED}` : `${WAITING}`;
const txt = offlineText(name);
const cls = p.online ? 'di-online' : (p.days !== null && p.days >= 3 ? 'di-offline-long' : 'di-offline');
let sub = '';
diff --git a/web/docs.css b/web/docs.css
index 19e5e06..e2d2435 100644
--- a/web/docs.css
+++ b/web/docs.css
@@ -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; }
diff --git a/web/docs.js b/web/docs.js
index fadab83..7a0289f 100644
--- a/web/docs.js
+++ b/web/docs.js
@@ -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 = `\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/ back here
+ window.KB = { open: name => { const d = findDoc(name); if (d) { openDoc(d); return true; } return false; } };
});
})();
diff --git a/web/excel-filter.js b/web/excel-filter.js
index f5b5e69..e0d34e6 100644
--- a/web/excel-filter.js
+++ b/web/excel-filter.js
@@ -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();
})();
diff --git a/web/index.html b/web/index.html
index 3035691..20dc053 100755
--- a/web/index.html
+++ b/web/index.html
@@ -359,6 +359,7 @@
+
@@ -372,10 +373,10 @@
-
+
diff --git a/web/layout.css b/web/layout.css
index b7c4cba..519a19b 100644
--- a/web/layout.css
+++ b/web/layout.css
@@ -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; }
diff --git a/web/overview.css b/web/overview.css
index d905652..fe44279 100644
--- a/web/overview.css
+++ b/web/overview.css
@@ -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; }
diff --git a/web/overview.js b/web/overview.js
index bf25b0d..5c738bf 100644
--- a/web/overview.js
+++ b/web/overview.js
@@ -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;
diff --git a/web/router.js b/web/router.js
new file mode 100644
index 0000000..233f328
--- /dev/null
+++ b/web/router.js
@@ -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/
+ * 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);
+ });
+})();