Page routes, Home link, documented-app filters, Overview colors and list fixes

- Every nav page has its own address (/tracked, /status, /devices, ...);
  the logo and name go to the Overview; back/forward work.
- List filters gain an "Only ticked" mode; software columns get a one-click
  "devices with only documented apps" pick and an easiest-first sort.
- Overview: the device list uses the dashboard's table header (sticky, with
  Excel filters); what opened it is clearly highlighted; locations colored by
  how much is done (red to green); stages read red / amber / green.
- Details: one-line title bar; presence and monitors only in Advanced View.
- Presence sweep pings only completed pairs (everything else is pinged by
  the sweep itself), hourly and at midnight.
- Custom View scrollbar smaller, in the table header blue.
- Docs: no sideways scroll in the article list; no blank lines around code.
This commit is contained in:
Johan Sandoval committed 2026-10-06 20:27:37 +00:00
1 parent e2125c760a
commit f72e39892c
13 files changed
+332 -117

No files matched your search

+55 -32
View File
@@ -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;