diff --git a/Demo/New-DemoData.ps1 b/Demo/New-DemoData.ps1 index e3558f9..f5f8c2a 100644 --- a/Demo/New-DemoData.ps1 +++ b/Demo/New-DemoData.ps1 @@ -51,14 +51,9 @@ for ($m = 0; $m -lt 3; $m++) { $manual += "$src, $dst" } -# --- 2. Date-batch tags (recent and upcoming working days) --- -function Get-Workday([datetime]$from, [int]$offset) { - $d = $from.Date; $step = [math]::Sign($offset); $left = [math]::Abs($offset) - while ($left -gt 0) { $d = $d.AddDays($step); if ($d.DayOfWeek -ne 'Saturday' -and $d.DayOfWeek -ne 'Sunday') { $left-- } } - return $d -} -$today = Get-Date -$dateTags = @(-4, -3, -2, -1, 0, 1) | ForEach-Object { (Get-Workday $today $_).ToString("M/d") } +# --- 2. Date-batch tags (recent and upcoming working days; the worker keeps them current) --- +$dateTags = Get-DemoDateTags +[System.IO.File]::WriteAllText((Join-Path $dataDir "Demo-Dates.json"), (@{ Tags = $dateTags } | ConvertTo-Json), [System.Text.Encoding]::UTF8) $tags = [ordered]@{} foreach ($t in $dateTags) { $tags[$t] = [System.Collections.Generic.List[string]]::new() } diff --git a/Providers/Demo.ps1 b/Providers/Demo.ps1 index 0593e23..c49d216 100644 --- a/Providers/Demo.ps1 +++ b/Providers/Demo.ps1 @@ -135,6 +135,42 @@ function Get-DemoPick([string]$name, [string]$salt, [array]$list) { return $list function Get-DemoInt([string]$name, [string]$salt, [int]$min, [int]$max) { return $min + [int][math]::Floor((Get-DemoRand $name $salt) * ($max - $min + 1)) } function Get-DemoNow { return [DateTimeOffset]::UtcNow.ToUnixTimeSeconds() } +# ----------------------------------------------------------------------------------------- +# Date-batch tags ("M/d") sit on fixed working-day offsets from today. The seed records the +# labels it used in Demo-Dates.json and the worker moves them along when the day changes, so +# a demo container that runs for weeks still shows this week's deployments. +# ----------------------------------------------------------------------------------------- +$script:DemoDateOffsets = @(-4, -3, -2, -1, 0, 1) +function Get-DemoWorkday([datetime]$from, [int]$offset) { + $d = $from.Date; $step = [math]::Sign($offset); $left = [math]::Abs($offset) + while ($left -gt 0) { $d = $d.AddDays($step); if ($d.DayOfWeek -ne 'Saturday' -and $d.DayOfWeek -ne 'Sunday') { $left-- } } + return $d +} +function Get-DemoDateTags { $today = Get-Date; return @($script:DemoDateOffsets | ForEach-Object { (Get-DemoWorkday $today $_).ToString("M/d") }) } + +function Update-DemoDateTags([string]$DataDir) { + $metaPath = Join-Path $DataDir "Demo-Dates.json" + $trackedPath = Join-Path $DataDir "Tracked-Devices.json" + if (-not (Test-Path $metaPath) -or -not (Test-Path $trackedPath)) { return } + try { $old = @(([System.IO.File]::ReadAllText($metaPath) | ConvertFrom-Json).Tags) } catch { return } + $new = Get-DemoDateTags + if ($old.Count -ne $new.Count -or ($old -join '|') -eq ($new -join '|')) { return } + + $rename = @{} + for ($i = 0; $i -lt $old.Count; $i++) { $rename[$old[$i]] = $new[$i] } + try { $tracked = [System.IO.File]::ReadAllText($trackedPath) | ConvertFrom-Json } catch { return } + $out = [ordered]@{} + foreach ($prop in $tracked.PSObject.Properties) { + $name = if ($rename.ContainsKey($prop.Name)) { $rename[$prop.Name] } else { $prop.Name } + $out[$name] = $prop.Value + } + $tmp = "$trackedPath.tmp" + [System.IO.File]::WriteAllText($tmp, ($out | ConvertTo-Json -Depth 5), [System.Text.Encoding]::UTF8) + Move-Item -LiteralPath $tmp -Destination $trackedPath -Force + [System.IO.File]::WriteAllText($metaPath, (@{ Tags = $new } | ConvertTo-Json), [System.Text.Encoding]::UTF8) + Write-Host "Demo: date tags moved to today ($($old -join ', ') -> $($new -join ', '))" -ForegroundColor DarkCyan +} + # ----------------------------------------------------------------------------------------- # Serial scheme: 7 chars + check char. Sources and destinations use different check offsets # and a destination's first 7 chars are a reversible scramble of its source's. diff --git a/README.md b/README.md index 7180bbf..24eaf5a 100644 --- a/README.md +++ b/README.md @@ -169,6 +169,8 @@ Compose*) build pack, set the exposed port to **8080**, and attach a domain. The reseeds the simulated fleet on every start and needs no volumes or secrets. Times are shown in `TZ` (default `America/Chicago`); set it to your own timezone. The demo worker runs 6 AM to 9 PM every day (`Demo.BusinessHours` in the config) and sleeps overnight. +The date tags (such as `10/7`) move along each day, so a container that runs for weeks still +shows this week's deployments. ### Demo (local PowerShell 7) diff --git a/USMT-Worker.ps1 b/USMT-Worker.ps1 index dcf4660..c374f9d 100755 --- a/USMT-Worker.ps1 +++ b/USMT-Worker.ps1 @@ -405,6 +405,7 @@ while ($true) { Update-WorkerState "Preparing..." 0 0 $globalLastDuration "Calculating..." $session = Open-ProviderCycle + if ($config.IsDemo) { Update-DemoDateTags $dataDir } $completedList = @() if (Test-Path $completedPath) { diff --git a/web/docs.js b/web/docs.js index 08b44d6..4e833e3 100644 --- a/web/docs.js +++ b/web/docs.js @@ -295,6 +295,7 @@ jsPDF: { unit: 'mm', format: 'a4', orientation: 'portrait' }, pagebreak: { mode: ['css', 'legacy'], avoid: ['pre', 'table', 'img', 'blockquote', 'h1', 'h2', 'h3', 'h4', 'li', 'tr'] } }; + await loadHtml2Pdf(); await html2pdf().set(opt).from(page).toPdf().get('pdf').then(pdf => { const n = pdf.internal.getNumberOfPages(); const w = pdf.internal.pageSize.getWidth(), h = pdf.internal.pageSize.getHeight(); @@ -382,8 +383,16 @@ }; addToolbarButtons(); + // The search index fetches every article. On a first visit to any other page it waits + // until the device data has drawn, so those requests don't hold the dashboard up. + let indexReady = location.pathname.startsWith('/docs'); + const indexNow = () => { if (indexReady) return; indexReady = true; reindex(); }; + document.addEventListener('usmt:data', () => setTimeout(indexNow, 300), { once: true }); + setTimeout(indexNow, 4000); + wrap('switchPage', id => { if (id === 'docs') indexNow(); }); + wrap('loadDocsList', () => { - reindex(); + if (indexReady) reindex(); if (firstLoad) { firstLoad = false; const t = titleFromPath(); diff --git a/web/index.html b/web/index.html index 3eea62a..2aa0d8a 100755 --- a/web/index.html +++ b/web/index.html @@ -8,7 +8,6 @@ - @@ -1784,7 +1783,22 @@ } } - window.onload = async () => { + // PDF export library (~900 KB): fetched the first time someone exports, not on every visit + function loadHtml2Pdf() { + if (window.html2pdf) return Promise.resolve(); + return window._html2pdfLoading || (window._html2pdfLoading = new Promise((resolve, reject) => { + const s = document.createElement('script'); + s.src = '/vendor/html2pdf/html2pdf.bundle.min.js'; + s.onload = resolve; + s.onerror = () => { window._html2pdfLoading = null; reject(new Error('Could not load the PDF library')); }; + document.head.appendChild(s); + })); + } + + // Start as soon as the page is parsed (not after every image and script has finished, as + // window.onload would); the timeout lets every module's own DOMContentLoaded setup run first + document.addEventListener('DOMContentLoaded', () => setTimeout(initDashboard, 0)); + async function initDashboard() { updateThemeBtn(); initMasterTables(); @@ -1812,9 +1826,9 @@ document.getElementById('btn-edit-mode').innerHTML = EDIT_SVG; document.getElementById('btn-save-doc').innerHTML = `${SAVE_SVG} Save`; - await loadBlacklist(); - await loadDocsList(); - await loadAllTags(); + // Load side by side; the device data doesn't wait for the article list + const docsReady = loadDocsList(); + await Promise.all([loadBlacklist(), loadAllTags()]); const savedTech = localStorage.getItem('usmt_tech_id'); if (savedTech) { @@ -1822,15 +1836,16 @@ await loadTechRoster(); } + startDashboard(); + // Check if we were opened in a new tab specifically to view a document + await docsReady; const urlParams = new URLSearchParams(window.location.search); const targetDoc = urlParams.get('doc'); if (targetDoc && availableDocs.includes(targetDoc)) { await openDoc(targetDoc); } - - startDashboard(); - }; + } // Code spans and fenced blocks are set aside while the [[Badge:..]] / [[Copy:..]] / UNC // path rewrites run, so an article can show that syntax literally inside `code` @@ -2088,7 +2103,7 @@ btn.disabled = true; btn.innerHTML = btn.innerHTML.replace('PDF', 'Exporting...'); - html2pdf().set(opt).from(clone).save().finally(() => { + loadHtml2Pdf().then(() => html2pdf().set(opt).from(clone).save()).finally(() => { btn.disabled = false; btn.innerHTML = btn.innerHTML.replace('Exporting...', 'PDF'); }); @@ -4307,7 +4322,8 @@ async function pullData() { try { - // 1. FAST STATE POLLING + // 1. FAST STATE POLLING (the tracked list is fetched alongside it) + const trackFetch = fetch('Tracked-Devices.json?t=' + Date.now()).catch(() => null); const stateRes = await fetch('Worker-State.json?t=' + Date.now()); if (!stateRes.ok) return; const state = await stateRes.json(); @@ -4353,7 +4369,7 @@ document.getElementById('syncBreakdown').innerHTML = DeviceInfo.syncBreakdownHtml(state); // Check if Tracked-Devices was updated by you or a teammate - const trackRes = await fetch('Tracked-Devices.json?t=' + Date.now()).catch(() => null); + const trackRes = await trackFetch; let currentTrackedHash = ""; let trackData = null; if (trackRes && trackRes.ok) { @@ -4374,6 +4390,7 @@ globalTrackedHash = currentTrackedHash; // 2. FETCH CORE DATA AND ADVANCED DATA + const advFetch = fetch('Advanced-Cache.json?t=' + Date.now()).catch(() => null); const dataRes = await fetch('Data.json?t=' + Date.now()).catch(() => null); if (dataRes && dataRes.ok) { const payload = await dataRes.json(); @@ -4387,7 +4404,7 @@ } } - const advRes = await fetch('Advanced-Cache.json?t=' + Date.now()).catch(() => null); + const advRes = await advFetch; if (advRes && advRes.ok) { advancedDataCache = await advRes.json(); } @@ -4496,6 +4513,8 @@ renderSoftwareTable(); // <-- Forces the Software tab to update instantly! renderCustomTable(); refreshOpenDialogs(); + document.dispatchEvent(new Event('usmt:data')); + if (window.Overview) Overview.refresh(); // draw the Overview now, not on its next timer tick // 3. LAZY LOAD SOFTWARE LAST fetch('Software-Cache.json?t=' + Date.now()) @@ -4528,6 +4547,7 @@ renderSoftwareTable(); renderCustomTable(); refreshOpenDialogs(true); + if (window.Overview) Overview.refresh(); } } }).catch(() => null); diff --git a/web/overview.css b/web/overview.css index 4830d1b..c7833d5 100644 --- a/web/overview.css +++ b/web/overview.css @@ -164,3 +164,13 @@ table.ov-heat td { padding: 0; text-align: center; border: 0; background: none; /* Undocumented apps list: room goes to the device names */ #ov-table td.ov-src-list { white-space: normal; overflow-wrap: anywhere; } + +/* Loading placeholder (first fraction of a second, before the data arrives) */ +.ov-skel { min-height: 150px; margin-bottom: 18px; + background: linear-gradient(90deg, var(--bg-card) 25%, rgba(127,127,127,0.10) 50%, var(--bg-card) 75%); + background-size: 200% 100%; animation: ov-skel 1.2s linear infinite; } +.ov-skel-hero { min-height: 120px; } +.ov-skel-row { display: grid; grid-template-columns: 1fr 1fr; gap: 18px; } +@media (max-width: 700px) { .ov-skel-row { grid-template-columns: 1fr; } } +@keyframes ov-skel { from { background-position: 100% 0; } to { background-position: -100% 0; } } +@media (prefers-reduced-motion: reduce) { .ov-skel { animation: none; } } diff --git a/web/overview.js b/web/overview.js index b5969b1..da0b8be 100644 --- a/web/overview.js +++ b/web/overview.js @@ -30,6 +30,7 @@ let state = { kind: null, arg: null, map: 'left' }; let lastSig = ''; + let loaded = false; // set once the dashboard's first data load has finished // --------------------------------------------------------------------------------- // Data @@ -222,9 +223,16 @@ return c; } + // Shown from the moment the page opens until the first data arrives + function renderSkeleton(root) { + root.innerHTML = '

Overview

Loading the fleet...

' + + '
'; + } + function render() { const root = document.getElementById('overview-root'); if (!root) return; + if (!loaded) { renderSkeleton(root); return; } const all = rows(); hideTip(); @@ -663,7 +671,10 @@ renderList: () => renderList() }; + document.addEventListener('usmt:data', () => { loaded = true; }); document.addEventListener('DOMContentLoaded', () => { + maybeRender(true); + setTimeout(() => { if (!loaded) { loaded = true; maybeRender(true); } }, 8000); // never stuck on the placeholder const orig = window.switchPage; if (typeof orig === 'function') { window.switchPage = function (id) {