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=<id>` | 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 `<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 = ''; 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 = `<!-- 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; } }; }); })(); 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 @@ <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> 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/<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); + }); +})();