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

+74 -14
View File
@@ -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();
})();