Live updates during a cycle; Filter & sort dropdown on phones; layout polish
- Worker publishes the rows swept so far every few seconds (LivePublishSeconds) with a DataVersion stamp; the dashboard reloads on each new version, so tables, the Overview and open dialogs update mid-cycle. Shared files are written to a temp file and swapped in, so readers never see half a file. - Phones / tablets: one Filter & sort button opens a column list instead of a row of chips, on every table. - Details: a wrapped Simple / Advanced toggle sits on the left. - Card fields: fixed-height dialog; field list and add list share it evenly.
This commit is contained in:
1 parent
7242f8f8af
commit
9dc2d88726
8 files changed
+149
-31
No files matched your search
@@ -57,6 +57,10 @@
|
||||
# is pinged every cycle).
|
||||
# Cycles only run in business hours plus the midnight pitstop: 60 = hourly, plus midnight.
|
||||
PresenceSweepMinutes = 60
|
||||
|
||||
# While a cycle runs, the rows swept so far are published this often, so the
|
||||
# dashboard updates live instead of only when the cycle finishes
|
||||
LivePublishSeconds = 10
|
||||
}
|
||||
|
||||
# -------------------------------------------------------------------------------------
|
||||
@@ -128,6 +132,7 @@
|
||||
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)
|
||||
LivePublishSeconds = 4 # publish swept rows this often during a cycle
|
||||
Verbose = $true # run the worker with -v style tracing
|
||||
}
|
||||
|
||||
|
||||
@@ -73,8 +73,9 @@ a 1,300-device scope.
|
||||
after an app are linked from that app in the software view.
|
||||
- **Business-hours scheduling**: the worker cycles every few minutes during working hours,
|
||||
sleeps overnight, and makes a midnight "pitstop" for the daily software refresh.
|
||||
*Force Update* and *Force Full Refresh* skip the wait. Newly tracked devices are swept and
|
||||
published first; the sync dialog shows how long each kind of device (SCCM association,
|
||||
*Force Update* and *Force Full Refresh* skip the wait. The dashboard updates live while a cycle runs
|
||||
(swept rows are published every few seconds), newly tracked devices are swept and
|
||||
published first, the sync dialog shows how long each kind of device (SCCM association,
|
||||
manual pair, completed snapshot...) took.
|
||||
|
||||
| | |
|
||||
|
||||
+43
-12
@@ -31,6 +31,10 @@ $loadLogName = if ($config.SCCM.LoadStateLog) { $config.SCCM.LoadStateLog
|
||||
# How often every tracked device (cached and destination devices included) is pinged to keep
|
||||
# "last online" current. Cycles only run in business hours plus the midnight pitstop, so this
|
||||
# works out to an hourly presence sweep during the day and one at midnight.
|
||||
# While a cycle runs, rows are published this often (merged with last cycle's rows for devices
|
||||
# not swept yet), so the dashboard updates live instead of only when the cycle ends
|
||||
$livePublishSeconds = if ($config.IsDemo) { $config.Demo.LivePublishSeconds } else { $config.Worker.LivePublishSeconds }
|
||||
if (-not $livePublishSeconds) { $livePublishSeconds = 10 }
|
||||
$presenceMinutes = if ($config.IsDemo) { $config.Demo.PresenceSweepMinutes } else { $config.Worker.PresenceSweepMinutes }
|
||||
if (-not $presenceMinutes) { $presenceMinutes = 60 }
|
||||
|
||||
@@ -50,6 +54,7 @@ $globalLastUpdated = "Never"
|
||||
$globalNextRun = "Calculating..."
|
||||
$globalLastDuration = "N/A"
|
||||
$global:LastBreakdown = $null
|
||||
$global:DataVersion = ""
|
||||
$global:LastPresenceSweep = [datetime]::MinValue
|
||||
|
||||
# --- FUNCTIONS ---
|
||||
@@ -64,9 +69,10 @@ function Update-WorkerState {
|
||||
NextCycle = $nextRun
|
||||
LastDuration = $duration
|
||||
LastCycle = $global:LastBreakdown
|
||||
DataVersion = $global:DataVersion
|
||||
} | ConvertTo-Json -Compress -Depth 5
|
||||
|
||||
[System.IO.File]::WriteAllText($statePath, $json, [System.Text.Encoding]::UTF8)
|
||||
Write-SharedText $statePath $json
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@@ -91,6 +97,21 @@ function Set-LogInfoLine {
|
||||
|
||||
function Get-IsoNow { return (Get-Date).ToString("o") }
|
||||
|
||||
# Files the dashboard reads while the worker writes them: write a temp file, then swap it in,
|
||||
# so a reader never sees a half-written file. Falls back to a direct write if the swap fails
|
||||
# (e.g. Windows refusing to replace a file another process has open).
|
||||
function Write-SharedText([string]$path, [string]$text) {
|
||||
$tmp = "$path.tmp"
|
||||
try {
|
||||
[System.IO.File]::WriteAllText($tmp, $text, [System.Text.Encoding]::UTF8)
|
||||
if (Test-Path -LiteralPath $path) { [System.IO.File]::Replace($tmp, $path, $null) }
|
||||
else { [System.IO.File]::Move($tmp, $path) }
|
||||
} catch {
|
||||
[System.IO.File]::WriteAllText($path, $text, [System.Text.Encoding]::UTF8)
|
||||
if (Test-Path -LiteralPath $tmp) { Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
}
|
||||
|
||||
function ConvertTo-Hashtable($obj) {
|
||||
$h = @{}
|
||||
if ($obj -is [hashtable]) { foreach ($k in $obj.Keys) { $h[$k] = $obj[$k] } }
|
||||
@@ -111,8 +132,7 @@ function Read-JsonTable([string]$path) {
|
||||
}
|
||||
|
||||
function Write-JsonFile([string]$path, $obj, [int]$depth = 6) {
|
||||
$json = ConvertTo-Json -InputObject $obj -Depth $depth -Compress
|
||||
[System.IO.File]::WriteAllText($path, $json, [System.Text.Encoding]::UTF8)
|
||||
Write-SharedText $path (ConvertTo-Json -InputObject $obj -Depth $depth -Compress)
|
||||
}
|
||||
|
||||
# --- PROVIDER (data source adapter) ---
|
||||
@@ -216,6 +236,14 @@ function Use-Snapshot {
|
||||
}
|
||||
}
|
||||
|
||||
# Data.json + a version stamp the dashboard compares to know there's something new to load
|
||||
function Publish-Data {
|
||||
param($rows)
|
||||
$global:DataVersion = (Get-Date).ToString("o")
|
||||
$payload = [PSCustomObject]@{ LastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt"); DataVersion = $global:DataVersion; Devices = $rows }
|
||||
Write-SharedText $jsonPath ($payload | ConvertTo-Json -Depth 6)
|
||||
}
|
||||
|
||||
# Every row has the same columns; these are filled in as the device is processed
|
||||
function New-DeviceRow {
|
||||
param([string]$sName, [string]$dName, [string]$tags, [string]$pairing)
|
||||
@@ -499,6 +527,7 @@ while ($true) {
|
||||
if ($newSources.Count -gt 0) { Write-Host "--- Commencing Data Sweep ($totalDevices Devices, $($newSources.Count) new first) ---" -ForegroundColor White }
|
||||
else { Write-Host "--- Commencing Data Sweep ($totalDevices Devices) ---" -ForegroundColor White }
|
||||
|
||||
$publishWatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
foreach ($deviceName in $sourceDevices) {
|
||||
$currentDeviceCount++
|
||||
$deviceWatch = [System.Diagnostics.Stopwatch]::StartNew()
|
||||
@@ -722,14 +751,19 @@ while ($true) {
|
||||
$timing[$category].Count++
|
||||
$timing[$category].Ms += $deviceWatch.Elapsed.TotalMilliseconds
|
||||
|
||||
# Newly tracked devices are done: publish them now rather than at the end of the cycle
|
||||
if ($newSources.Count -gt 0 -and $knownSources.Count -gt 0 -and $currentDeviceCount -eq $newSources.Count) {
|
||||
Write-Host " -> [PRIORITY] $($newSources.Count) new device(s) swept. Publishing early..." -ForegroundColor Green
|
||||
# Live updates: newly tracked devices are published as soon as they're done, then the
|
||||
# rows swept so far every few seconds (devices not reached yet keep last cycle's row)
|
||||
$newDone = ($newSources.Count -gt 0 -and $currentDeviceCount -eq $newSources.Count)
|
||||
$remaining = $totalDevices - $currentDeviceCount
|
||||
if ($remaining -gt 0 -and ($newDone -or $publishWatch.Elapsed.TotalSeconds -ge $livePublishSeconds)) {
|
||||
if ($newDone) { Write-Host " -> [PRIORITY] $($newSources.Count) new device(s) swept. Publishing early..." -ForegroundColor Green }
|
||||
elseif ($v) { Write-Host " -> [LIVE] Publishing $currentDeviceCount of $totalDevices swept device(s)..." -ForegroundColor DarkGray }
|
||||
$interim = [System.Collections.Generic.List[object]]::new()
|
||||
foreach ($r in $deviceTable) { $interim.Add($r) }
|
||||
foreach ($dev in $knownSources) { if ($previousRows.ContainsKey($dev)) { $interim.Add($previousRows[$dev]) } }
|
||||
foreach ($dev in $sourceDevices) { if (-not $processedSources.Contains($dev) -and $previousRows.ContainsKey($dev)) { $interim.Add($previousRows[$dev]) } }
|
||||
Save-AdvancedCache
|
||||
Write-JsonFile $jsonPath ([PSCustomObject]@{ LastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt"); Devices = $interim })
|
||||
Publish-Data $interim
|
||||
$publishWatch.Restart()
|
||||
}
|
||||
}
|
||||
$sweepSeconds = $phaseWatch.Elapsed.TotalSeconds
|
||||
@@ -806,10 +840,7 @@ while ($true) {
|
||||
|
||||
if ($v) { Write-Host " -> Writing payload to Data.json..." -ForegroundColor DarkGray }
|
||||
$globalLastUpdated = (Get-Date).ToString("MM/dd/yy hh:mm tt")
|
||||
$exportPayload = [PSCustomObject]@{ LastUpdated = $globalLastUpdated; Devices = $deviceTable }
|
||||
|
||||
$jsonFinal = $exportPayload | ConvertTo-Json -Depth 6
|
||||
[System.IO.File]::WriteAllText($jsonPath, $jsonFinal, [System.Text.Encoding]::UTF8)
|
||||
Publish-Data $deviceTable
|
||||
$saveSeconds = $phaseWatch.Elapsed.TotalSeconds
|
||||
|
||||
$cycleEnd = Get-Date
|
||||
|
||||
+3
-1
@@ -721,6 +721,8 @@
|
||||
// ---- worker data ----
|
||||
'GET Worker-State.json': async () => {
|
||||
const state = (await serverGet('/Worker-State.json')) || {};
|
||||
// the visitor's own (simulated) changes count as new data too
|
||||
if (state.DataVersion && Store.state.appliedTracked) state.DataVersion += '#' + Store.state.rev;
|
||||
if (Sim.worker) return jsonResponse(Object.assign({}, state, Sim.worker));
|
||||
return jsonResponse(state);
|
||||
},
|
||||
@@ -731,7 +733,7 @@
|
||||
// the dashboard shows its "Waiting for Worker..." placeholders
|
||||
if (!s.appliedTracked || !snap.rows.length) return jsonResponse(snap.data);
|
||||
const { rows } = buildRows(s.appliedTracked, snap.rows, snap.adv);
|
||||
return jsonResponse({ LastUpdated: snap.data.LastUpdated || psStamp(), Devices: rows });
|
||||
return jsonResponse({ LastUpdated: snap.data.LastUpdated || psStamp(), DataVersion: (snap.data.DataVersion || '') + '#' + s.rev, Devices: rows });
|
||||
},
|
||||
'GET Advanced-Cache.json': async () => {
|
||||
const s = Store.state;
|
||||
|
||||
+50
-14
@@ -318,35 +318,71 @@
|
||||
bar.className = 'xf-bar';
|
||||
table.parentNode.insertBefore(bar, table);
|
||||
}
|
||||
// One "Filter & sort" button (plus Card fields / Clear) instead of a chip per column:
|
||||
// the column list opens as a sheet, so the bar stays a single short row
|
||||
const sortedCol = cols.find(c => { const d = sortDirOf(ad, c.idx); return d === 'asc' || d === 'desc'; });
|
||||
const parts = [];
|
||||
if (ad.custom) parts.push('<button type="button" class="xf-chip xf-fields" data-act="fields">☰ Card fields</button>');
|
||||
cols.forEach(c => {
|
||||
if (!c.filterable && !c.sortable) return;
|
||||
const on = activeKeys.has(ad.colKey(c.idx));
|
||||
const n = on ? filters[ad.key][ad.colKey(c.idx)].size : 0;
|
||||
const dir = sortDirOf(ad, c.idx);
|
||||
const arrow = dir === 'asc' ? ' ↑' : dir === 'desc' ? ' ↓' : '';
|
||||
parts.push(`<button type="button" class="xf-chip${on ? ' xf-on' : ''}" data-idx="${c.idx}">${on ? FUNNEL_SVG : ''}<span></span>${on ? ` <b>${n}</b>` : ''}${arrow}${CARET_SVG}</button>`);
|
||||
});
|
||||
if (active.length) parts.push('<button type="button" class="xf-chip xf-clear" data-act="clear">Clear all</button>');
|
||||
parts.push(`<button type="button" class="xf-chip xf-filterbtn${active.length ? ' xf-on' : ''}" data-act="pick">${FUNNEL_SVG}<span>Filter & sort</span>${active.length ? ` <b>${active.length}</b>` : ''}${sortedCol ? ' · ' + (sortDirOf(ad, sortedCol.idx) === 'asc' ? '↑' : '↓') : ''}${CARET_SVG}</button>`);
|
||||
if (active.length) parts.push('<button type="button" class="xf-chip xf-clear" data-act="clear">Clear</button>');
|
||||
const sig = JSON.stringify([parts, cols.map(c => c.label)]);
|
||||
if (bar.dataset.sig !== sig) {
|
||||
bar.innerHTML = parts.join('');
|
||||
// labels via textContent (column names can come from user-chosen fields)
|
||||
bar.querySelectorAll('.xf-chip[data-idx]').forEach(chip => {
|
||||
chip.querySelector('span').textContent = cols[+chip.dataset.idx].label;
|
||||
});
|
||||
bar.dataset.sig = sig;
|
||||
bar.onclick = e => {
|
||||
const chip = e.target.closest('.xf-chip');
|
||||
if (!chip) return;
|
||||
if (chip.dataset.act === 'clear') { delete filters[ad.key]; ad.rerun(); return; }
|
||||
if (chip.dataset.act === 'fields') { openFieldEditor(); return; }
|
||||
openMenu(table, +chip.dataset.idx, chip);
|
||||
if (chip.dataset.act === 'pick') openColumnPicker(table, chip);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Phones / tablets: pick a column, then its usual sort + filter menu opens
|
||||
function openColumnPicker(table, anchor) {
|
||||
closeMenu();
|
||||
const ad = adapter(table);
|
||||
if (!ad) return;
|
||||
const cols = columnsOf(table, ad).filter(c => c.filterable || c.sortable);
|
||||
const activeKeys = new Set(activeFilters(ad, columnsOf(table, ad)).map(c => ad.colKey(c.idx)));
|
||||
backdrop = el('div', 'xf-backdrop'); backdrop.onclick = closeMenu;
|
||||
menu = el('div', 'xf-menu xf-sheet xf-picker');
|
||||
menuCtx = { picker: true, table };
|
||||
const head = el('div', 'xf-head');
|
||||
head.appendChild(el('span', 'xf-title', 'Filter & sort'));
|
||||
const x = el('button', 'xf-x', '\u00d7'); x.type = 'button'; x.title = 'Close'; x.onclick = closeMenu;
|
||||
head.appendChild(x);
|
||||
menu.appendChild(head);
|
||||
const list = el('div', 'xf-pick-list');
|
||||
cols.forEach(c => {
|
||||
const b = el('button', 'xf-pick-col'); b.type = 'button';
|
||||
b.appendChild(el('span', 'xf-pick-name', c.label));
|
||||
const bits = [];
|
||||
const on = activeKeys.has(ad.colKey(c.idx));
|
||||
if (on) { const f = filters[ad.key][ad.colKey(c.idx)]; bits.push(f.size + ' selected'); }
|
||||
const d = sortDirOf(ad, c.idx);
|
||||
if (d === 'asc') bits.push('sorted \u2191'); else if (d === 'desc') bits.push('sorted \u2193');
|
||||
const st = el('span', 'xf-pick-state', bits.join(' \u00b7 '));
|
||||
if (on) b.classList.add('xf-on');
|
||||
b.appendChild(st);
|
||||
b.insertAdjacentHTML('beforeend', '<svg class="xf-pick-chev" viewBox="0 0 16 16" aria-hidden="true"><path d="M6 3.5 10.5 8 6 12.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>');
|
||||
b.onclick = () => { closeMenu(); openMenu(table, c.idx, anchor); };
|
||||
list.appendChild(b);
|
||||
});
|
||||
menu.appendChild(list);
|
||||
if (activeKeys.size) {
|
||||
const actions = el('div', 'xf-actions');
|
||||
const clr = el('button', 'xf-cancel', 'Clear all filters'); clr.type = 'button';
|
||||
clr.onclick = () => { delete filters[ad.key]; closeMenu(); ad.rerun(); };
|
||||
actions.appendChild(clr);
|
||||
menu.appendChild(actions);
|
||||
}
|
||||
document.body.appendChild(backdrop);
|
||||
document.body.appendChild(menu);
|
||||
document.addEventListener('keydown', onMenuKey, true);
|
||||
}
|
||||
|
||||
// Effective sort direction of a column: a size/free-space sort overrides the table's own
|
||||
function sortDirOf(ad, i) {
|
||||
const ns = nativeSort[ad.key];
|
||||
|
||||
+6
-1
@@ -897,6 +897,7 @@
|
||||
}
|
||||
|
||||
let globalDataLastUpdated = "Never";
|
||||
let globalDataVersion = "";
|
||||
let globalTrackedHash = "";
|
||||
|
||||
const groupsArray = ["No Data", "Not Started", "Backup In Progress", "Backup Completed", "Restore In Progress", "Completed", "Not Associated"];
|
||||
@@ -4361,7 +4362,10 @@
|
||||
}
|
||||
|
||||
// Early return ONLY if the Worker AND the Tracked Devices list are both completely unchanged
|
||||
if (state.LastUpdated === globalDataLastUpdated && apiData.length > 0 && currentTrackedHash === globalTrackedHash) {
|
||||
// The worker publishes rows several times a cycle (DataVersion); older workers only
|
||||
// once per cycle (LastUpdated)
|
||||
const dataUnchanged = state.DataVersion ? state.DataVersion === globalDataVersion : state.LastUpdated === globalDataLastUpdated;
|
||||
if (dataUnchanged && apiData.length > 0 && currentTrackedHash === globalTrackedHash) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4374,6 +4378,7 @@
|
||||
const payload = await dataRes.json();
|
||||
if (payload.LastUpdated && payload.Devices) {
|
||||
apiData = payload.Devices;
|
||||
globalDataVersion = payload.DataVersion || state.DataVersion || "";
|
||||
globalDataLastUpdated = payload.LastUpdated;
|
||||
btn.innerHTML = `<span class="st-pre">Data Updated At: </span>${globalDataLastUpdated}`;
|
||||
} else {
|
||||
|
||||
+27
-1
@@ -333,7 +333,7 @@ table.xf-table thead th:has(> .xf-btn):not(.sortable-th) { position: relative; p
|
||||
.xf-bar { display: none; }
|
||||
@media (max-width: 1100px) {
|
||||
.xf-bar {
|
||||
display: flex; flex-wrap: wrap; gap: 6px; padding: 8px 10px; overflow: visible; /* chips wrap: no sideways scrolling */
|
||||
display: flex; flex-wrap: wrap; align-items: center; gap: 6px; padding: 8px 10px; overflow: visible; /* one short row: Filter & sort (+ Card fields / Clear) */
|
||||
position: sticky; top: var(--sticky-top, 112px); z-index: 25;
|
||||
background: linear-gradient(rgba(0,0,0,0.15), rgba(0,0,0,0.15)), var(--bg-card);
|
||||
border-bottom: 1px solid var(--border-color); box-shadow: 0 4px 8px rgba(0,0,0,0.25);
|
||||
@@ -509,3 +509,29 @@ a.nav-brand:focus-visible { outline: 2px solid #3498db; outline-offset: 3px; }
|
||||
|
||||
/* Excel filter: three list modes */
|
||||
.xf-seg3 .xf-seg-btn { font-size: 0.78em; padding-left: 4px; padding-right: 4px; }
|
||||
|
||||
/* Phones / tablets: the "Filter & sort" button and its column list */
|
||||
.xf-chip.xf-filterbtn { font-weight: 600; padding: 7px 13px; }
|
||||
.xf-chip.xf-filterbtn svg:first-child { width: 12px; height: 12px; }
|
||||
.xf-picker .xf-pick-list { overflow-y: auto; min-height: 0; flex: 1 1 auto; padding: 0 8px 8px; }
|
||||
.xf-pick-col {
|
||||
display: flex; align-items: center; gap: 10px; width: 100%; padding: 13px 10px; text-align: left; cursor: pointer;
|
||||
background: none; border: 0; border-bottom: 1px solid var(--border-light); color: var(--text-main); font: inherit;
|
||||
}
|
||||
.xf-pick-col:hover, .xf-pick-col:focus-visible { background: var(--hover-bg); }
|
||||
.xf-pick-name { flex: 1 1 auto; min-width: 0; font-weight: 600; }
|
||||
.xf-pick-state { flex: none; font-size: 0.8em; color: var(--text-muted); }
|
||||
.xf-pick-col.xf-on .xf-pick-state { color: #b7950b; font-weight: 600; }
|
||||
[data-theme="dark"] .xf-pick-col.xf-on .xf-pick-state { color: #f1c40f; }
|
||||
.xf-pick-chev { width: 14px; height: 14px; flex: none; color: var(--text-muted); }
|
||||
|
||||
/* Card fields: the dialog keeps a fixed height and the two halves share it evenly */
|
||||
.xf-fields-overlay .modal-content.xf-fields-sheet { height: calc(100vh - 100px); height: calc(100dvh - 100px); }
|
||||
@media (max-width: 1100px) { .xf-fields-overlay .modal-content.xf-fields-sheet { height: calc(100vh - 80px) !important; height: calc(100dvh - 80px) !important; } }
|
||||
@media (max-width: 600px) { .xf-fields-overlay .modal-content.xf-fields-sheet { height: calc(100dvh - 72px) !important; } }
|
||||
.xf-fields-sheet .xf-fields { flex: 1 1 0; min-height: 120px; max-height: none; }
|
||||
.xf-fields-sheet .xf-add { flex: 1 1 0; min-height: 150px; margin-top: 10px; }
|
||||
.xf-fields-sheet .xf-add .xf-list { flex: 1 1 auto; min-height: 0; max-height: none; overflow-y: auto; }
|
||||
|
||||
/* Details title: when the toggle has to wrap under the name it sits on the left */
|
||||
#detailsTitle.dt-wrapped .dt-toggle { margin-left: 0; }
|
||||
@@ -59,6 +59,17 @@
|
||||
}
|
||||
return null;
|
||||
}
|
||||
// Details dialog title: if the Simple/Advanced toggle wrapped under the name, align it left
|
||||
function updateDetailsTitle() {
|
||||
const t = document.getElementById('detailsTitle');
|
||||
if (!t || !t.offsetParent) return;
|
||||
const name = t.querySelector('.dt-name'), btn = t.querySelector('.dt-toggle');
|
||||
if (!name || !btn) return;
|
||||
t.classList.remove('dt-wrapped');
|
||||
const wrapped = btn.getBoundingClientRect().top > name.getBoundingClientRect().bottom - 4;
|
||||
t.classList.toggle('dt-wrapped', wrapped);
|
||||
}
|
||||
|
||||
function updateSticky() {
|
||||
const root = document.documentElement;
|
||||
const nav = document.querySelector('.top-nav');
|
||||
@@ -77,6 +88,7 @@
|
||||
if (table.parentElement) setVar(table.parentElement, '--sticky-top', top); // for the filter chip bar
|
||||
});
|
||||
setupCustomHScroll();
|
||||
updateDetailsTitle();
|
||||
}
|
||||
|
||||
// Custom View can be wider than the page. A sideways-scrolling box would stop its header
|
||||
|
||||
Reference in new issue
Block a user