From b0f700928ecda7739b90f8530d205c80c1cc7f4e Mon Sep 17 00:00:00 2001 From: Johan Sandoval Date: Tue, 6 Oct 2026 22:01:43 +0000 Subject: [PATCH] Phone Jump button; docs never scroll sideways; presence sweep timing kept - Jump (phones / tablets): find a device by name and scroll to its card, or jump to a section, the top or the bottom of the page. - Documentation on phones: long paths, links, code and tables wrap; toolbar is title, last edited, then the buttons. - Filter bar's Clear is the only clear button on phones. - Card fields: search box and add list as wide as the field rows. - Presence sweep: the demo uses the hourly schedule too, and the last sweep time survives a worker restart (a restart used to trigger an extra sweep). --- Config/usmt-dash.psd1 | 2 +- README.md | 3 + USMT-Worker.ps1 | 12 ++- web/index.html | 1 + web/jump.js | 207 ++++++++++++++++++++++++++++++++++++++++++ web/layout.css | 52 ++++++++++- 6 files changed, 272 insertions(+), 5 deletions(-) create mode 100644 web/jump.js diff --git a/Config/usmt-dash.psd1 b/Config/usmt-dash.psd1 index 62addfd..127726a 100644 --- a/Config/usmt-dash.psd1 +++ b/Config/usmt-dash.psd1 @@ -131,7 +131,7 @@ CycleSeconds = 45 # cooldown between cycles (business hours are ignored) DelayMsPerDevice = 35 # artificial per-device latency so the progress bar moves LifecycleHours = 24 # how long one simulated migration takes, start to finish - PresenceSweepMinutes = 5 # ping every device this often (Live default is hourly) + PresenceSweepMinutes = 60 # completed / Skip USMT devices: hourly, like Live (the demo runs all day) LivePublishSeconds = 4 # publish swept rows this often during a cycle Verbose = $true # run the worker with -v style tracing } diff --git a/README.md b/README.md index e18bccd..d230966 100644 --- a/README.md +++ b/README.md @@ -62,6 +62,9 @@ a 1,300-device scope. *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. +- **Phones and tablets**: tables become cards with one *Filter & sort* button, and a floating + *Jump* button finds a device by name (scrolls to its card without filtering) or jumps to any + section, the top or the bottom of the page. - **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. diff --git a/USMT-Worker.ps1 b/USMT-Worker.ps1 index 0cb5500..74666c3 100755 --- a/USMT-Worker.ps1 +++ b/USMT-Worker.ps1 @@ -146,6 +146,12 @@ $hasLastSeen = [bool](Get-Command Get-DeviceLastSeen -ErrorAction SilentlyContin # Every ping the worker makes is recorded here: when the device was last checked, what the # ping said, and when it last answered. Kept across restarts in Presence.json. $global:Presence = Read-JsonTable $presencePath +# When the last presence sweep ran survives a worker restart, so a restart doesn't trigger +# an extra sweep (it's kept in Presence.json under a reserved key) +if ($global:Presence.ContainsKey('__sweep')) { + try { $global:LastPresenceSweep = [datetime]$global:Presence['__sweep'].Last } catch {} + $global:Presence.Remove('__sweep') +} $global:PingedThisCycle = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) function Set-Presence { @@ -272,7 +278,9 @@ function Save-AdvancedCache { $out[$key] = $entry } Write-JsonFile $advancedCachePath $out 5 - Write-JsonFile $presencePath $global:Presence 4 + $presenceOut = @{} + $global:Presence + if ($global:LastPresenceSweep -gt [datetime]::MinValue) { $presenceOut['__sweep'] = @{ Last = $global:LastPresenceSweep.ToString("o") } } + Write-JsonFile $presencePath $presenceOut 4 } # Cached / skipped rows show the latest presence-sweep result instead of a placeholder @@ -796,7 +804,7 @@ while ($true) { } $presenceCount = $toPing.Count $global:LastPresenceSweep = Get-Date - Write-Host "[Presence Updated]" -ForegroundColor Green + Write-Host "[Presence Updated. Next presence sweep after $($global:LastPresenceSweep.AddMinutes($presenceMinutes).ToString('hh:mm tt')), or at midnight]" -ForegroundColor Green } $presenceSeconds = $phaseWatch.Elapsed.TotalSeconds $phaseWatch.Restart() diff --git a/web/index.html b/web/index.html index 9c8169f..3eea62a 100755 --- a/web/index.html +++ b/web/index.html @@ -360,6 +360,7 @@ + diff --git a/web/jump.js b/web/jump.js new file mode 100644 index 0000000..256eb06 --- /dev/null +++ b/web/jump.js @@ -0,0 +1,207 @@ +/* + * USMT Dash - "Jump" for phones and tablets + * + * Scrolling through thousands of device cards is slow, so on narrow screens a round Jump + * button floats above the terminal bar on every page. It opens a sheet with: + * - Find a device: type part of a name and pick it; the page scrolls to that card and + * flashes it (nothing is filtered or hidden, unlike the search boxes). + * - Sections: every group on the page (USMT Status groups, Workbench stacks, the Overview + * list, the article list / article) with its count, to jump straight there. + * - Top / Bottom. + */ +(function () { + 'use strict'; + + const narrow = window.matchMedia('(max-width: 1100px)'); + let btn = null, sheet = null, backdrop = null; + + const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; }; + const visible = e => !!(e && e.offsetParent !== null && e.getClientRects().length); + const currentPage = () => Array.from(document.querySelectorAll('.page')).find(p => p.style.display !== 'none'); + const navH = () => parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')) || 60; + + // Scroll so the element sits just below everything that sticks to the top + function scrollToEl(target, extra) { + const top = target.getBoundingClientRect().top + window.scrollY - navH() - (extra || 0) - 8; + window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' }); + } + // Sticky group header + filter bar above a card (the header is a sibling of the table or + // of one of its wrappers) + function stickyAbove(row) { + let h = 0; + const table = row.closest('table'); + for (let e = table && table.parentElement, depth = 0; e && depth < 4; e = e.parentElement, depth++) { + const gh = e.querySelector(':scope > .group-header'); + if (gh) { if (getComputedStyle(gh).position === 'sticky') h += gh.offsetHeight; break; } + } + const bar = table && table.previousElementSibling; + if (bar && bar.classList.contains('xf-bar')) h += bar.offsetHeight; + return h; + } + function flash(node) { + node.classList.remove('jump-flash'); + void node.offsetWidth; + node.classList.add('jump-flash'); + setTimeout(() => node.classList.remove('jump-flash'), 2200); + } + + // A group header's title: a stack's name box, else its text without the buttons + function headerLabel(gh) { + const input = gh.querySelector('input'); + if (input) return input.value.trim(); + const c = gh.cloneNode(true); + c.querySelectorAll('button, div, .no-copy').forEach(n => n.remove()); + return c.textContent.replace(/\s+/g, ' ').trim(); + } + + // --------------------------------------------------------------------------------- + // What's on the page + // --------------------------------------------------------------------------------- + function sections(page) { + const out = []; + page.querySelectorAll('.group-header').forEach(gh => { + if (!visible(gh)) return; + const label = headerLabel(gh); + if (label) out.push({ label, node: gh.closest('.table-wrapper, .stack-box, [id^="group-container-"]') || gh }); + }); + if (page.id === 'docs') { + const list = document.getElementById('docList'), art = document.getElementById('docToolbar'); + if (visible(list)) out.push({ label: 'Article list', node: list.parentElement }); + if (visible(art)) out.push({ label: 'Article', node: art }); + } + if (page.id === 'overview') { + [['ov-issues', 'Issues to clear'], ['ov-grid', 'Status & locations'], ['ov-months', 'Deployment calendar']].forEach(([cls, label]) => { + const n = page.querySelector('.' + cls); + if (visible(n)) out.push({ label, node: n.closest('.ov-card') || n }); + }); + } + return out; + } + + // Rows / cards whose device names match + function findDevices(page, q) { + q = q.trim().toUpperCase(); + if (q.length < 2) return []; + const hits = []; + const seen = new Set(); + page.querySelectorAll('table.master-table tbody tr').forEach(tr => { + if (hits.length >= 25 || !visible(tr)) return; + // device names are the bold cell text in every table (any naming scheme) + const names = Array.from(tr.querySelectorAll('td b')).map(b => b.textContent.trim().toUpperCase()) + .concat(tr.cells[0] ? [tr.cells[0].textContent.trim().toUpperCase()] : []); + const name = names.find(n => n && n.length < 64 && n.includes(q)); + if (!name) return; + const group = tr.closest('[id^="group-container-"], .stack-box, .table-wrapper'); + const gh = group && group.querySelector('.group-header'); + const where = gh ? headerLabel(gh).replace(/\s*\(\d+\)\s*$/, '') : ''; + const key = name + '|' + where; + if (seen.has(key)) return; + seen.add(key); + hits.push({ name, where, tr }); + }); + return hits; + } + + // --------------------------------------------------------------------------------- + // Sheet + // --------------------------------------------------------------------------------- + function close() { + if (sheet) sheet.remove(); + if (backdrop) backdrop.remove(); + sheet = backdrop = null; + } + + function open() { + close(); + const page = currentPage(); + if (!page) return; + backdrop = el('div', 'xf-backdrop'); backdrop.onclick = close; + sheet = el('div', 'xf-menu xf-sheet jump-sheet'); + sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-label', 'Jump to'); + const head = el('div', 'xf-head'); + head.appendChild(el('span', 'xf-title', 'Jump to')); + const x = el('button', 'xf-x', '×'); x.type = 'button'; x.title = 'Close'; x.onclick = close; + head.appendChild(x); + sheet.appendChild(head); + + const hasRows = !!page.querySelector('table.master-table tbody tr'); + const results = el('div', 'jump-results'); + if (hasRows) { + const search = el('input', 'xf-search jump-search'); + search.type = 'search'; search.placeholder = 'Find a device (part of the name)...'; + search.setAttribute('autocomplete', 'off'); search.setAttribute('autocapitalize', 'characters'); + sheet.appendChild(search); + sheet.appendChild(results); + const show = () => { + results.textContent = ''; + const q = search.value; + if (q.trim().length < 2) return; + const hits = findDevices(page, q); + if (!hits.length) { results.appendChild(el('div', 'jump-empty', 'No device on this page matches. (Filters may be hiding it.)')); return; } + hits.forEach(h => { + const b = el('button', 'jump-item'); b.type = 'button'; + b.appendChild(el('b', null, h.name)); + if (h.where) b.appendChild(el('span', null, h.where)); + b.onclick = () => { close(); scrollToEl(h.tr, stickyAbove(h.tr)); setTimeout(() => flash(h.tr), 350); }; + results.appendChild(b); + }); + }; + search.oninput = show; + search.onkeydown = e => { if (e.key === 'Enter') { const f = results.querySelector('.jump-item'); if (f) f.click(); } }; + } + + const secs = sections(page); + if (secs.length) { + sheet.appendChild(el('div', 'xf-group', 'Sections')); + const list = el('div', 'jump-sections'); + secs.forEach(s => { + const b = el('button', 'jump-item jump-sec'); b.type = 'button'; + b.textContent = s.label; + b.onclick = () => { close(); scrollToEl(s.node); }; + list.appendChild(b); + }); + sheet.appendChild(list); + } + + const ends = el('div', 'xf-actions'); + const top = el('button', 'xf-cancel', '↑ Top'); top.type = 'button'; + top.onclick = () => { close(); window.scrollTo({ top: 0, behavior: 'smooth' }); }; + const bottom = el('button', 'xf-cancel', '↓ Bottom'); bottom.type = 'button'; + bottom.onclick = () => { close(); window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' }); }; + ends.appendChild(top); ends.appendChild(bottom); + sheet.appendChild(ends); + + document.body.appendChild(backdrop); + document.body.appendChild(sheet); + const s = sheet.querySelector('.jump-search'); + if (s) setTimeout(() => s.focus(), 50); + } + + // --------------------------------------------------------------------------------- + // Button: phones / tablets only, on pages taller than a couple of screens + // --------------------------------------------------------------------------------- + function update() { + if (!btn) return; + const page = currentPage(); + const long = document.documentElement.scrollHeight > window.innerHeight * 1.8; + btn.hidden = !narrow.matches || !page || !long; + } + + function init() { + btn = el('button', 'jump-btn'); + btn.type = 'button'; btn.title = 'Jump to a device or section'; btn.setAttribute('aria-label', 'Jump to a device or section'); + btn.innerHTML = 'Jump'; + btn.onclick = open; + btn.hidden = true; + document.body.appendChild(btn); + let t = null; + const later = () => { clearTimeout(t); t = setTimeout(update, 250); }; + new MutationObserver(later).observe(document.body, { childList: true, subtree: true }); + window.addEventListener('resize', later); + if (narrow.addEventListener) narrow.addEventListener('change', () => { close(); update(); }); + document.addEventListener('keydown', e => { if (e.key === 'Escape' && sheet) close(); }); + update(); + } + + if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); +})(); diff --git a/web/layout.css b/web/layout.css index 5f3f4d0..3f02224 100644 --- a/web/layout.css +++ b/web/layout.css @@ -213,8 +213,6 @@ body { padding-bottom: var(--dock-h, 40px) !important; } } #docs > div > div:last-child > div:last-child { min-height: 60vh; } #docContent img { max-width: 100%; height: auto; } - #docContent table { display: block; overflow-x: auto; } - #docContent pre { overflow-x: auto; } #docToolbar { flex-wrap: wrap; gap: 8px; } /* Modals fill the screen width and scroll inside themselves */ @@ -535,3 +533,53 @@ a.nav-brand:focus-visible { outline: 2px solid #3498db; outline-offset: 3px; } /* Details title: when the toggle has to wrap under the name it sits on the left */ #detailsTitle.dt-wrapped .dt-toggle { margin-left: 0; } + +/* Card fields: the search box and the add list are as wide as the field rows above them */ +.xf-fields-sheet .xf-add .xf-search { margin: 0 0 8px; width: 100%; box-sizing: border-box; } +.xf-fields-sheet .xf-add .xf-list { margin: 0; width: 100%; box-sizing: border-box; } + +/* Phones / tablets: the filter bar's own Clear button is enough */ +@media (max-width: 1100px) { + .xf-clear-all { display: none !important; } +} + +/* Documentation on phones / tablets: nothing scrolls sideways. Long paths, links and code + wrap; tables fit the width; the toolbar is title, last edited, then the buttons */ +@media (max-width: 1100px) { + #docContent { overflow-x: hidden; overflow-wrap: anywhere; word-break: break-word; } + #docContent pre, #docContent pre code { white-space: pre-wrap !important; word-break: break-all; overflow-x: hidden !important; } + #docContent table { display: table !important; table-layout: fixed; width: 100%; overflow: visible !important; } + #docContent th, #docContent td { overflow-wrap: anywhere; word-break: break-word; white-space: normal !important; } + #docContent img { max-width: 100% !important; height: auto !important; } + #docContent a, #docContent code { overflow-wrap: anywhere; word-break: break-all; } + #docs, #docs > div, #docs > div > div:last-child > div:last-child { max-width: 100%; overflow-x: hidden; box-sizing: border-box; } + #docToolbar { flex-wrap: wrap !important; align-items: center; gap: 8px !important; } + #docToolbar #docHeaderInfo { flex: 1 1 100% !important; min-width: 0; } + #docToolbar #docDisplayTitle { overflow-wrap: anywhere; } + #docToolbar > button, #docToolbar > select { flex: 0 0 auto; margin: 0 !important; } +} + +/* Phones / tablets: floating Jump button and its sheet */ +.jump-btn { + position: fixed; right: 14px; bottom: calc(var(--dock-h, 40px) + 14px); z-index: 1800; + display: inline-flex; align-items: center; gap: 6px; padding: 10px 15px 10px 12px; + background: var(--table-header-bg); color: #fff; border: 1px solid rgba(255,255,255,0.25); border-radius: 999px; + font: inherit; font-weight: 700; font-size: 0.9em; cursor: pointer; box-shadow: 0 6px 18px rgba(0,0,0,0.35); +} +.jump-btn[hidden] { display: none; } +.jump-btn svg { width: 18px; height: 18px; } +.jump-sheet .jump-search { margin: 8px 12px; font-size: 16px; } +.jump-results, .jump-sections { overflow-y: auto; padding: 0 8px; } +.jump-results { max-height: 38vh; } +.jump-sections { max-height: 30vh; } +.jump-item { + display: flex; align-items: baseline; justify-content: space-between; gap: 10px; width: 100%; + padding: 12px 10px; background: none; border: 0; border-bottom: 1px solid var(--border-light); + color: var(--text-main); font: inherit; text-align: left; cursor: pointer; +} +.jump-item:hover, .jump-item:focus-visible { background: var(--hover-bg); } +.jump-item span { font-size: 0.8em; color: var(--text-muted); text-align: right; } +.jump-sec { font-weight: 600; } +.jump-empty { padding: 12px 10px; color: var(--text-muted); font-size: 0.9em; } +@keyframes jump-flash { 0%, 60% { box-shadow: 0 0 0 3px #3498db, 0 0 18px rgba(52,152,219,0.8); } 100% { box-shadow: none; } } +tr.jump-flash { animation: jump-flash 2.2s ease-out; }