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:
1 parent
e2125c760a
commit
f72e39892c
13 files changed
+332
-117
No files matched your search
@@ -52,9 +52,9 @@
|
|||||||
# A task-sequence log modified within this window counts as "active"
|
# A task-sequence log modified within this window counts as "active"
|
||||||
ActiveLogHours = 24
|
ActiveLogHours = 24
|
||||||
|
|
||||||
# Every tracked device (including completed pairs and destinations the sweep skips)
|
# Completed pairs skip network checks in the sweep, so they're pinged this often to keep
|
||||||
# is pinged this often to keep "last online" / "days offline" current. Cycles only run
|
# "last online" / "days offline" current (every other device is pinged every cycle).
|
||||||
# in business hours plus the midnight pitstop, so 60 = hourly by day, plus midnight.
|
# Cycles only run in business hours plus the midnight pitstop: 60 = hourly, plus midnight.
|
||||||
PresenceSweepMinutes = 60
|
PresenceSweepMinutes = 60
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,12 +52,16 @@ a 1,300-device scope.
|
|||||||
location, department, users, for both sides of a migration. A blank says why: *Not in AD*,
|
location, department, users, for both sides of a migration. A blank says why: *Not in AD*,
|
||||||
*Not in SCCM*, *No DNS record*, or *Waiting for worker...* if it hasn't been looked up yet.
|
*Not in SCCM*, *No DNS record*, or *Waiting for worker...* if it hasn't been looked up yet.
|
||||||
The dialog refreshes in place when the worker finishes a cycle.
|
The dialog refreshes in place when the worker finishes a cycle.
|
||||||
- **Presence**: every tracked device (completed pairs and destinations included) is pinged
|
- **Presence**: every ping is recorded, so each device shows when it was last online and how
|
||||||
hourly during business hours and at midnight, so each one shows when it was last online and
|
many days it has been offline. Completed pairs, which skip network checks in the sweep, are
|
||||||
how many days it has been offline.
|
pinged hourly during business hours and at midnight to keep theirs current.
|
||||||
- **Snapshots**: the last free space and scanstate / loadstate lines seen while a device was
|
- **Snapshots**: the last free space and scanstate / loadstate lines seen while a device was
|
||||||
reachable are kept with their timestamps, and shown for completed migrations and offline
|
reachable are kept with their timestamps, and shown for completed migrations and offline
|
||||||
devices instead of blanks.
|
devices instead of blanks.
|
||||||
|
- **Excel-style filters** on every table: sort, search and tick values; list columns match
|
||||||
|
*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.
|
||||||
- **Custom View**: build your own table from any field (including days offline, last online,
|
- **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
|
found in AD/SCCM/DNS and monitors), with drag-to-reorder, resizable and Excel-style
|
||||||
filterable columns. Saved per browser.
|
filterable columns. Saved per browser.
|
||||||
@@ -106,6 +110,7 @@ refresh" buttons drop flag files that the worker watches for.
|
|||||||
| Method & path | Purpose |
|
| Method & path | Purpose |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `GET /` and static files | Dashboard, assets, `Data\` state files, `Documentation\` |
|
| `GET /` and static files | Dashboard, assets, `Data\` state files, `Documentation\` |
|
||||||
|
| `GET /tracked` · `/workbench` · `/status` · `/devices` · `/custom` · `/docs` | The dashboard, opened on that page (the logo and name go to `/`, the Overview) |
|
||||||
| `GET /docs/<title>` | The dashboard, opened on that knowledge-base article |
|
| `GET /docs/<title>` | The dashboard, opened on that knowledge-base article |
|
||||||
| `GET /get-config` | Mode flags for the front end |
|
| `GET /get-config` | Mode flags for the front end |
|
||||||
| `GET /get-logs?source=server\|worker&after=<id>` | Tail of the service console output (terminal panel) |
|
| `GET /get-logs?source=server\|worker&after=<id>` | Tail of the service console output (terminal panel) |
|
||||||
|
|||||||
+4
-2
@@ -472,8 +472,10 @@ while ($listener.IsListening) {
|
|||||||
|
|
||||||
# --- STATIC FILES ---
|
# --- STATIC FILES ---
|
||||||
if ([string]::IsNullOrWhiteSpace($localPath)) { $localPath = "index.html" }
|
if ([string]::IsNullOrWhiteSpace($localPath)) { $localPath = "index.html" }
|
||||||
# Article permalinks (/docs/Adobe Acrobat Pro) are pages of the dashboard itself
|
# Page routes (/tracked, /status, ...) and article permalinks (/docs/Adobe Acrobat Pro)
|
||||||
if ($localPath -eq "docs" -or ($localPath -like "docs/*" -and $localPath -notmatch '\.(md|png|jpe?g|gif|webp|svg|css|js)$')) { $localPath = "index.html" }
|
# are pages of the dashboard itself
|
||||||
|
$pageRoutes = @("overview", "home", "tracked", "workbench", "status", "devices", "custom", "docs")
|
||||||
|
if ($pageRoutes -contains $localPath.TrimEnd('/') -or ($localPath -like "docs/*" -and $localPath -notmatch '\.(md|png|jpe?g|gif|webp|svg|css|js)$')) { $localPath = "index.html" }
|
||||||
|
|
||||||
# Data/ state files, Documentation/ articles + images, and web/ assets for everything else
|
# Data/ state files, Documentation/ articles + images, and web/ assets for everything else
|
||||||
if ($publicDataFiles -contains $localPath) {
|
if ($publicDataFiles -contains $localPath) {
|
||||||
|
|||||||
+9
-5
@@ -735,16 +735,20 @@ while ($true) {
|
|||||||
$sweepSeconds = $phaseWatch.Elapsed.TotalSeconds
|
$sweepSeconds = $phaseWatch.Elapsed.TotalSeconds
|
||||||
$phaseWatch.Restart()
|
$phaseWatch.Restart()
|
||||||
|
|
||||||
# --- PRESENCE SWEEP: ping everything the sweep didn't (cached pairs, skipped tags...) ---
|
# --- PRESENCE SWEEP: completed pairs ---
|
||||||
|
# Every other device is pinged by the sweep itself on every cycle. Completed pairs skip
|
||||||
|
# their network checks, so they're pinged here: hourly during business hours (cycles only
|
||||||
|
# run then) and on the midnight pitstop.
|
||||||
$presenceCount = 0
|
$presenceCount = 0
|
||||||
$presenceDue = ((Get-Date) - $global:LastPresenceSweep).TotalMinutes -ge ($presenceMinutes - 0.5) -or $runFullSoftwareSweep
|
$presenceDue = ((Get-Date) - $global:LastPresenceSweep).TotalMinutes -ge ($presenceMinutes - 0.5) -or $runFullSoftwareSweep
|
||||||
if ($presenceDue) {
|
if ($presenceDue) {
|
||||||
$everyone = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
$completedDevs = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
|
||||||
foreach ($r in $deviceTable) {
|
foreach ($r in $deviceTable) {
|
||||||
foreach ($n in @($r."Source Device", $r."Destination Device")) { if ($n -and $n -ne "N/A") { $null = $everyone.Add($n) } }
|
if ($r.Pairing -ne "Cached") { continue }
|
||||||
|
foreach ($n in @($r."Source Device", $r."Destination Device")) { if ($n -and $n -ne "N/A") { $null = $completedDevs.Add($n) } }
|
||||||
}
|
}
|
||||||
$toPing = @($everyone | Where-Object { -not $global:PingedThisCycle.Contains($_) } | Sort-Object)
|
$toPing = @($completedDevs | Where-Object { -not $global:PingedThisCycle.Contains($_) } | Sort-Object)
|
||||||
Write-Host "`n[Presence Sweep: $($toPing.Count) device(s) not pinged this cycle]" -ForegroundColor Cyan
|
Write-Host "`n[Presence Sweep: pinging $($toPing.Count) device(s) from completed migrations]" -ForegroundColor Cyan
|
||||||
$i = 0
|
$i = 0
|
||||||
foreach ($n in $toPing) {
|
foreach ($n in $toPing) {
|
||||||
$i++
|
$i++
|
||||||
|
|||||||
+11
-1
@@ -96,6 +96,15 @@
|
|||||||
return { online, last, lastIso: a.LastOnline, checked: a.LastChecked, ping: a.LastPing, first: a.FirstChecked, days, source: a.LastOnlineSource };
|
return { online, last, lastIso: a.LastOnline, checked: a.LastChecked, ping: a.LastPing, first: a.FirstChecked, days, source: a.LastOnlineSource };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip USMT tags are inventory only: the worker never pings them
|
||||||
|
function notPinged(name) {
|
||||||
|
try {
|
||||||
|
const rows = typeof apiData !== 'undefined' && Array.isArray(apiData) ? apiData : [];
|
||||||
|
return rows.some(r => r['Source Device'] === name && r.Pairing === 'Skipped');
|
||||||
|
} catch (e) { return false; }
|
||||||
|
}
|
||||||
|
const NOT_PINGED = 'Not pinged (Skip USMT tag)';
|
||||||
|
|
||||||
// Whole days offline (0 when online or offline less than a day), null if never seen
|
// Whole days offline (0 when online or offline less than a day), null if never seen
|
||||||
function daysOffline(name) {
|
function daysOffline(name) {
|
||||||
const p = presence(name);
|
const p = presence(name);
|
||||||
@@ -121,6 +130,7 @@
|
|||||||
function computed(name, key) {
|
function computed(name, key) {
|
||||||
const a = entry(name);
|
const a = entry(name);
|
||||||
if (!a) return { text: WAITING, state: 'waiting' };
|
if (!a) return { text: WAITING, state: 'waiting' };
|
||||||
|
if (['DaysOffline', 'LastOnline', 'LastChecked'].includes(key) && !a.LastChecked && notPinged(name)) return { text: NOT_PINGED, state: 'na' };
|
||||||
if (key === 'DaysOffline') {
|
if (key === 'DaysOffline') {
|
||||||
const d = daysOffline(name);
|
const d = daysOffline(name);
|
||||||
if (d === undefined) return { text: WAITING, state: 'waiting' };
|
if (d === undefined) return { text: WAITING, state: 'waiting' };
|
||||||
@@ -164,7 +174,7 @@
|
|||||||
function presenceHtml(name) {
|
function presenceHtml(name) {
|
||||||
if (!isName(name)) return 'N/A';
|
if (!isName(name)) return 'N/A';
|
||||||
const p = presence(name);
|
const p = presence(name);
|
||||||
if (!p) return `<span class="di-waiting">${WAITING}</span>`;
|
if (!p) return notPinged(name) ? `<span class="di-sub">${NOT_PINGED}</span>` : `<span class="di-waiting">${WAITING}</span>`;
|
||||||
const txt = offlineText(name);
|
const txt = offlineText(name);
|
||||||
const cls = p.online ? 'di-online' : (p.days !== null && p.days >= 3 ? 'di-offline-long' : 'di-offline');
|
const cls = p.online ? 'di-online' : (p.days !== null && p.days >= 3 ? 'di-offline-long' : 'di-offline');
|
||||||
let sub = '';
|
let sub = '';
|
||||||
|
|||||||
+9
-2
@@ -10,8 +10,8 @@
|
|||||||
/* groups + items */
|
/* groups + items */
|
||||||
.kb-group-title { font-size: 0.72em; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); margin: 2px 0 6px; display: flex; justify-content: space-between; }
|
.kb-group-title { font-size: 0.72em; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: var(--text-muted); margin: 2px 0 6px; display: flex; justify-content: space-between; }
|
||||||
.kb-group-title span { font-weight: 600; }
|
.kb-group-title span { font-weight: 600; }
|
||||||
.kb-item { display: flex; align-items: flex-start; gap: 6px; padding: 6px 8px; margin: 0 -8px 2px; border-radius: 6px; border: 1px solid transparent; transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; }
|
.kb-item { display: flex; align-items: flex-start; gap: 6px; padding: 6px 8px; margin: 0 0 2px; min-width: 0; border-radius: 6px; border: 1px solid transparent; transition: background 0.15s, border-color 0.15s, box-shadow 0.15s; }
|
||||||
.kb-item:hover { background: rgba(52,152,219,0.08); border-color: rgba(52,152,219,0.45); box-shadow: 0 0 10px rgba(52,152,219,0.35); }
|
.kb-item:hover { background: rgba(52,152,219,0.08); border-color: rgba(52,152,219,0.45); box-shadow: 0 0 6px rgba(52,152,219,0.35); }
|
||||||
.kb-item.active { background: rgba(52,152,219,0.14); border-color: #3498db; }
|
.kb-item.active { background: rgba(52,152,219,0.14); border-color: #3498db; }
|
||||||
.kb-item-main { min-width: 0; flex: 1; }
|
.kb-item-main { min-width: 0; flex: 1; }
|
||||||
.kb-item a { color: #3498db; text-decoration: none; font-weight: 500; overflow-wrap: anywhere; }
|
.kb-item a { color: #3498db; text-decoration: none; font-weight: 500; overflow-wrap: anywhere; }
|
||||||
@@ -64,3 +64,10 @@
|
|||||||
@media (max-width: 1100px) {
|
@media (max-width: 1100px) {
|
||||||
.kb-tool span { display: none; }
|
.kb-tool span { display: none; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Code blocks: the theme's own padding doubled up with the block's, which looked like an
|
||||||
|
empty line above and below the code */
|
||||||
|
#docContent pre code.hljs, #docContent pre > code { padding: 0 !important; display: block; }
|
||||||
|
|
||||||
|
/* Article list: never scrolls sideways */
|
||||||
|
#docList { overflow-x: hidden !important; padding: 0 6px; margin: 0 -6px; }
|
||||||
+9
-9
@@ -324,6 +324,13 @@
|
|||||||
// ---------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------
|
||||||
const ALERTS = { NOTE: 'Note', TIP: 'Tip', IMPORTANT: 'Important', WARNING: 'Warning', CAUTION: 'Caution' };
|
const ALERTS = { NOTE: 'Note', TIP: 'Tip', IMPORTANT: 'Important', WARNING: 'Warning', CAUTION: 'Caution' };
|
||||||
function decorate() {
|
function decorate() {
|
||||||
|
// no trailing blank line at the end of a code block
|
||||||
|
document.querySelectorAll('#docContent pre code').forEach(code => {
|
||||||
|
if (code.dataset.kbTrim) return;
|
||||||
|
code.dataset.kbTrim = '1';
|
||||||
|
const last = code.lastChild;
|
||||||
|
if (last && last.nodeType === 3) last.nodeValue = last.nodeValue.replace(/\s+$/, '');
|
||||||
|
});
|
||||||
document.querySelectorAll('#docContent blockquote').forEach(bq => {
|
document.querySelectorAll('#docContent blockquote').forEach(bq => {
|
||||||
if (bq.dataset.kb) return;
|
if (bq.dataset.kb) return;
|
||||||
bq.dataset.kb = '1';
|
bq.dataset.kb = '1';
|
||||||
@@ -405,14 +412,7 @@
|
|||||||
if (!m) ed.value = `<!-- category: ${sel.value} -->\n` + ed.value;
|
if (!m) ed.value = `<!-- category: ${sel.value} -->\n` + ed.value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
wrap('switchPage', id => {
|
// Page routing (including /docs) lives in router.js; it hands /docs/<title> back here
|
||||||
if (id !== 'docs' && location.pathname.startsWith('/docs')) setUrl('/', false);
|
window.KB = { open: name => { const d = findDoc(name); if (d) { openDoc(d); return true; } return false; } };
|
||||||
if (id === 'docs' && currentTitle() && !location.pathname.startsWith('/docs/')) setUrl(permalink(currentTitle()), false);
|
|
||||||
});
|
|
||||||
window.addEventListener('popstate', () => {
|
|
||||||
const doc = findDoc(titleFromPath());
|
|
||||||
if (doc) openDoc(doc);
|
|
||||||
else if (location.pathname === '/' && document.getElementById('docs').style.display !== 'none') switchPage('overview');
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
+74
-14
@@ -69,6 +69,17 @@
|
|||||||
sortDir: i => { const s = sortStates['software']; return s && s.col === i ? s.dir : 'none'; }
|
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')) {
|
if (table.closest('#stackGrid')) {
|
||||||
const sid = id.replace(/^table-/, '');
|
const sid = id.replace(/^table-/, '');
|
||||||
const key = 'stack-' + sid;
|
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;
|
if (allowed.free.op === 'ge' ? sp.free < allowed.free.gb : sp.free >= allowed.free.gb) return false;
|
||||||
}
|
}
|
||||||
const vals = cellValues(td, col);
|
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
|
// exact set: the row's items are precisely the ticked ones, no more, no fewer
|
||||||
const own = new Set(vals);
|
const own = new Set(vals);
|
||||||
if (own.size !== allowed.size || !vals.every(v => allowed.has(v))) return false;
|
if (own.size !== allowed.size || !vals.every(v => allowed.has(v))) return false;
|
||||||
@@ -206,12 +221,29 @@
|
|||||||
decorate(table, ad, cols);
|
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) {
|
function nativeSortRows(table, cols, s) {
|
||||||
const col = cols[s.col];
|
const col = cols[s.col];
|
||||||
if (!col) return;
|
if (!col) return;
|
||||||
const body = table.tBodies[0];
|
const body = table.tBodies[0];
|
||||||
const rows = dataRows(table);
|
const rows = dataRows(table);
|
||||||
const num = tr => { const sp = parseSpace(tr.cells[s.col]); return sp ? sp[s.by] : null; };
|
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) => {
|
rows.sort((a, b) => {
|
||||||
if (s.by) { // disk size / free space: no reading always last
|
if (s.by) { // disk size / free space: no reading always last
|
||||||
const x = num(a), y = num(b);
|
const x = num(a), y = num(b);
|
||||||
@@ -422,6 +454,15 @@
|
|||||||
menu.appendChild(el('div', 'xf-sep'));
|
menu.appendChild(el('div', 'xf-sep'));
|
||||||
} else if (col.sortable) {
|
} else if (col.sortable) {
|
||||||
const dir = sortDirOf(ad, idx);
|
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'); });
|
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');
|
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'); });
|
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
|
// List columns: rows having ANY ticked item, ONLY ticked items (nothing else), or
|
||||||
let onlyToggle = null;
|
// EXACTLY the ticked items
|
||||||
|
let listMode = null, setMode = null;
|
||||||
if (col.list) {
|
if (col.list) {
|
||||||
const seg = el('div', 'xf-seg');
|
const seg = el('div', 'xf-seg xf-seg3');
|
||||||
seg.title = 'Any: devices with at least one ticked item. Exactly: devices whose list is exactly the ticked items, nothing more and nothing less.';
|
seg.title = 'Any: at least one ticked item. Only: nothing except ticked items. Exactly: precisely the ticked items, nothing more and nothing less.';
|
||||||
const mk = (text, only) => {
|
const curMode = !current || !current.only ? 'any' : (current.only === 'subset' ? 'subset' : 'exact');
|
||||||
const b = el('button', 'xf-seg-btn', text); b.type = 'button';
|
const btns = {};
|
||||||
b.onclick = () => { seg.querySelectorAll('.xf-seg-btn').forEach(x => x.classList.toggle('on', x === b)); };
|
setMode = m => { listMode = m; Object.entries(btns).forEach(([k, b]) => b.classList.toggle('on', k === m)); };
|
||||||
if (!!(current && current.only) === only) b.classList.add('on');
|
[['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);
|
seg.appendChild(b);
|
||||||
return b;
|
});
|
||||||
};
|
setMode(curMode);
|
||||||
mk('Has any ticked', false);
|
|
||||||
onlyToggle = mk('Exactly ticked', true);
|
|
||||||
menu.appendChild(seg);
|
menu.appendChild(seg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,6 +596,20 @@
|
|||||||
};
|
};
|
||||||
pick('Documented', isDocumented);
|
pick('Documented', isDocumented);
|
||||||
pick('Undocumented', v => !isDocumented(v));
|
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 = () => {
|
search.oninput = () => {
|
||||||
const q = search.value.trim().toLowerCase();
|
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 chosen = new Set(visibleOpts().filter(o => o.cb.checked).map(o => o.v));
|
||||||
const free = freeRule ? freeRule() : null;
|
const free = freeRule ? freeRule() : null;
|
||||||
if (free) chosen.free = free;
|
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] || {};
|
filters[ad.key] = filters[ad.key] || {};
|
||||||
// everything ticked and no extra rule = no filter
|
// everything ticked and no extra rule = no filter
|
||||||
if (!q && chosen.size === values.length && !free) delete filters[ad.key][key];
|
if (!q && chosen.size === values.length && !free) delete filters[ad.key][key];
|
||||||
@@ -749,6 +806,9 @@
|
|||||||
schedule();
|
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);
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
|
||||||
else init();
|
else init();
|
||||||
})();
|
})();
|
||||||
+4
-6
@@ -359,6 +359,7 @@
|
|||||||
<script src="/device-info.js"></script>
|
<script src="/device-info.js"></script>
|
||||||
<script src="/overview.js"></script>
|
<script src="/overview.js"></script>
|
||||||
<script src="/docs.js"></script>
|
<script src="/docs.js"></script>
|
||||||
|
<script src="/router.js"></script>
|
||||||
<script src="/excel-filter.js" defer></script>
|
<script src="/excel-filter.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -372,10 +373,10 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="nav-brand">
|
<a class="nav-brand" href="/" title="Home (Overview)" onclick="if (!event.ctrlKey && !event.metaKey && !event.shiftKey) { event.preventDefault(); switchPage('overview'); }">
|
||||||
<img src="/favicon.png" alt="USMT Logo" class="nav-logo">
|
<img src="/favicon.png" alt="USMT Logo" class="nav-logo">
|
||||||
<h2>USMT Dash</h2>
|
<h2>USMT Dash</h2>
|
||||||
</div>
|
</a>
|
||||||
|
|
||||||
<div class="nav-links" id="nav-links-menu">
|
<div class="nav-links" id="nav-links-menu">
|
||||||
<button class="nav-btn active" id="btn-overview" onclick="switchPage('overview')">Overview</button>
|
<button class="nav-btn active" id="btn-overview" onclick="switchPage('overview')">Overview</button>
|
||||||
@@ -2829,7 +2830,7 @@
|
|||||||
if (!match) return;
|
if (!match) return;
|
||||||
|
|
||||||
const btnText = window.isAdvView ? "Simple View" : "Advanced View";
|
const btnText = window.isAdvView ? "Simple View" : "Advanced View";
|
||||||
document.getElementById('detailsTitle').innerHTML = `Details: ${match["Source Device"]} ${inlineCopyBtn(match["Source Device"])} <button onclick="toggleAdvView()" style="float:right; margin-right: 15px; cursor: pointer; padding: 6px 12px; border-radius: 4px; background: #34495e; color: white; border: none; font-size: 0.6em; font-weight: bold; vertical-align: middle; transition: 0.2s;">${btnText}</button>`;
|
document.getElementById('detailsTitle').innerHTML = `<span class="dt-name">Details: ${match["Source Device"]} ${inlineCopyBtn(match["Source Device"])}</span><button class="dt-toggle" onclick="toggleAdvView()">${btnText}</button>`;
|
||||||
|
|
||||||
const statusBg = themes[currentTheme].groupColors[match["Backup Status"]] || "#ffffff";
|
const statusBg = themes[currentTheme].groupColors[match["Backup Status"]] || "#ffffff";
|
||||||
const statusText = themes[currentTheme].textColors[match["Backup Status"]] || "#333333";
|
const statusText = themes[currentTheme].textColors[match["Backup Status"]] || "#333333";
|
||||||
@@ -2923,17 +2924,14 @@
|
|||||||
${window.isAdvView ? `<tr><th colspan="2" style="text-align: center; background: var(--table-header-bg); color: var(--table-header-text);">Migration Overview</th></tr>` : ''}
|
${window.isAdvView ? `<tr><th colspan="2" style="text-align: center; background: var(--table-header-bg); color: var(--table-header-text);">Migration Overview</th></tr>` : ''}
|
||||||
<tr><th width="40%">Source Device</th><td ${tdStyle}><b>${srcName}</b> ${inlineCopyBtn(srcName)} ${srcAppBtn}</td></tr>
|
<tr><th width="40%">Source Device</th><td ${tdStyle}><b>${srcName}</b> ${inlineCopyBtn(srcName)} ${srcAppBtn}</td></tr>
|
||||||
<tr><th>Source Ping</th><td style="color: ${getPingColor(match["Source Online"])}; font-weight: bold; padding: 10px;">${match["Source Online"]}${DeviceInfo.pingMark(match, "Source")}</td></tr>
|
<tr><th>Source Ping</th><td style="color: ${getPingColor(match["Source Online"])}; font-weight: bold; padding: 10px;">${match["Source Online"]}${DeviceInfo.pingMark(match, "Source")}</td></tr>
|
||||||
<tr><th>Source Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(srcName)}</td></tr>
|
|
||||||
<tr><th>Space Available</th><td ${tdStyle}>${space}${match["Source Space Checked"] && !match["Source Snapshot"] ? `<br><span class="di-sub">Checked ${DeviceInfo.fmtWhen(match["Source Space Checked"])}</span>` : ''}${DeviceInfo.snapshotLine(match, "Source")}</td></tr>
|
<tr><th>Space Available</th><td ${tdStyle}>${space}${match["Source Space Checked"] && !match["Source Snapshot"] ? `<br><span class="di-sub">Checked ${DeviceInfo.fmtWhen(match["Source Space Checked"])}</span>` : ''}${DeviceInfo.snapshotLine(match, "Source")}</td></tr>
|
||||||
<tr><th>Scanstate Line</th><td ${tdStyle}>${match["Source Scanstate Line"]}${elapsed(match["Source Scanstate Elapsed"])}${DeviceInfo.snapshotLine(match, "Source")}</td></tr>
|
<tr><th>Scanstate Line</th><td ${tdStyle}>${match["Source Scanstate Line"]}${elapsed(match["Source Scanstate Elapsed"])}${DeviceInfo.snapshotLine(match, "Source")}</td></tr>
|
||||||
<tr><th>Dest Device</th><td ${tdStyle}><b>${dstName}</b> ${inlineCopyBtn(dstName)} ${dstAppBtn}</td></tr>
|
<tr><th>Dest Device</th><td ${tdStyle}><b>${dstName}</b> ${inlineCopyBtn(dstName)} ${dstAppBtn}</td></tr>
|
||||||
<tr><th>Dest Ping</th><td style="color: ${getPingColor(match["Destination Online"])}; font-weight: bold; padding: 10px;">${match["Destination Online"]}${DeviceInfo.pingMark(match, "Destination")}</td></tr>
|
<tr><th>Dest Ping</th><td style="color: ${getPingColor(match["Destination Online"])}; font-weight: bold; padding: 10px;">${match["Destination Online"]}${DeviceInfo.pingMark(match, "Destination")}</td></tr>
|
||||||
${hasDst ? `<tr><th>Dest Presence</th><td ${tdStyle}>${DeviceInfo.presenceHtml(dstName)}</td></tr>` : ''}
|
|
||||||
<tr><th>Loadstate Line</th><td ${tdStyle}>${match["Destination Loadstate Line"]}${elapsed(match["Destination Loadstate Elapsed"])}${DeviceInfo.snapshotLine(match, "Destination")}</td></tr>
|
<tr><th>Loadstate Line</th><td ${tdStyle}>${match["Destination Loadstate Line"]}${elapsed(match["Destination Loadstate Elapsed"])}${DeviceInfo.snapshotLine(match, "Destination")}</td></tr>
|
||||||
<tr><th>Backup Status</th><td style="background-color: ${statusBg}; color: ${statusText}; padding: 10px;"><b>${match["Backup Status"]}</b></td></tr>
|
<tr><th>Backup Status</th><td style="background-color: ${statusBg}; color: ${statusText}; padding: 10px;"><b>${match["Backup Status"]}</b></td></tr>
|
||||||
<tr><th>Specialty Software</th><td ${tdStyle}>${specialtySoftware}</td></tr>
|
<tr><th>Specialty Software</th><td ${tdStyle}>${specialtySoftware}</td></tr>
|
||||||
<tr><th>USMT Status</th><td ${tdStyle}>${match["USMT Status"]}${pairing ? `<br><span class="di-sub">${pairing}</span>` : ''}</td></tr>
|
<tr><th>USMT Status</th><td ${tdStyle}>${match["USMT Status"]}${pairing ? `<br><span class="di-sub">${pairing}</span>` : ''}</td></tr>
|
||||||
${window.isAdvView ? '' : `<tr><th>Monitors</th><td ${tdStyle}>${monitorsCell(srcName)}</td></tr>`}
|
|
||||||
<tr><th>Tags</th><td ${tdStyle}>${match["Tags"] || 'None'}</td></tr>
|
<tr><th>Tags</th><td ${tdStyle}>${match["Tags"] || 'None'}</td></tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+39
-20
@@ -42,36 +42,35 @@ table.xf-table thead tr.filter-row { display: none !important; }
|
|||||||
#table-custom thead th { position: sticky !important; top: var(--sticky-top, 112px) !important; z-index: 20; }
|
#table-custom thead th { position: sticky !important; top: var(--sticky-top, 112px) !important; z-index: 20; }
|
||||||
.custom-hscroll {
|
.custom-hscroll {
|
||||||
position: sticky; bottom: var(--dock-h, 40px); z-index: 15;
|
position: sticky; bottom: var(--dock-h, 40px); z-index: 15;
|
||||||
display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 8px;
|
display: flex; align-items: center; gap: 6px; height: 24px; padding: 0 8px;
|
||||||
background: var(--bg-card); border-top: 2px solid #3498db; border-radius: 0 0 8px 8px;
|
background: var(--bg-card); border-top: 2px solid var(--table-header-bg); border-radius: 0 0 8px 8px;
|
||||||
box-shadow: 0 -3px 10px rgba(0,0,0,0.18); user-select: none; touch-action: none;
|
box-shadow: 0 -2px 8px rgba(0,0,0,0.15); user-select: none; touch-action: none;
|
||||||
}
|
}
|
||||||
.custom-hscroll[hidden] { display: none; }
|
.custom-hscroll[hidden] { display: none; }
|
||||||
.chs-btn {
|
.chs-btn {
|
||||||
flex: 0 0 30px; height: 22px; border: 1px solid var(--border-color, #ccc); border-radius: 5px;
|
flex: 0 0 24px; height: 18px; border: 1px solid var(--border-color, #ccc); border-radius: 4px;
|
||||||
background: var(--bg-body, #f0f2f5); color: var(--text-main, #333); cursor: pointer;
|
background: var(--bg-body, #f0f2f5); color: var(--text-main, #333); cursor: pointer;
|
||||||
font-size: 11px; line-height: 1; padding: 0;
|
font-size: 9px; line-height: 1; padding: 0;
|
||||||
}
|
}
|
||||||
.chs-btn:hover:not(:disabled) { background: #3498db; border-color: #3498db; color: #fff; }
|
.chs-btn:hover:not(:disabled) { background: var(--table-header-bg); border-color: var(--table-header-bg); color: #fff; }
|
||||||
.chs-btn:disabled { opacity: 0.35; cursor: default; }
|
.chs-btn:disabled { opacity: 0.35; cursor: default; }
|
||||||
.chs-track {
|
.chs-track {
|
||||||
position: relative; flex: 1 1 auto; height: 14px; border-radius: 7px; cursor: pointer;
|
position: relative; flex: 1 1 auto; height: 10px; border-radius: 5px; cursor: pointer;
|
||||||
background: rgba(127,127,127,0.22); box-shadow: inset 0 1px 2px rgba(0,0,0,0.25); outline: none;
|
background: rgba(127,127,127,0.2); box-shadow: inset 0 1px 2px rgba(0,0,0,0.2); outline: none;
|
||||||
}
|
}
|
||||||
.chs-track:focus-visible { box-shadow: 0 0 0 2px #3498db; }
|
.chs-track:focus-visible { box-shadow: 0 0 0 2px var(--table-header-bg); }
|
||||||
.chs-thumb {
|
.chs-thumb {
|
||||||
position: absolute; top: -3px; left: 0; height: 20px; min-width: 48px; border-radius: 10px;
|
position: absolute; top: -2px; left: 0; height: 14px; min-width: 40px; border-radius: 7px;
|
||||||
background: #3498db; cursor: grab; box-shadow: 0 1px 4px rgba(0,0,0,0.35);
|
background: var(--table-header-bg); cursor: grab; box-shadow: 0 1px 3px rgba(0,0,0,0.35);
|
||||||
display: flex; align-items: center; justify-content: center; transition: background 0.15s;
|
border: 1px solid rgba(255,255,255,0.18); box-sizing: border-box;
|
||||||
|
display: flex; align-items: center; justify-content: center; transition: filter 0.15s;
|
||||||
}
|
}
|
||||||
.chs-thumb span { width: 14px; height: 8px; border-left: 2px solid rgba(255,255,255,0.8); border-right: 2px solid rgba(255,255,255,0.8); }
|
.chs-thumb span { width: 10px; height: 6px; border-left: 2px solid rgba(255,255,255,0.7); border-right: 2px solid rgba(255,255,255,0.7); }
|
||||||
.chs-thumb:hover, .custom-hscroll.dragging .chs-thumb { background: #1d7fc4; }
|
.chs-thumb:hover, .custom-hscroll.dragging .chs-thumb { filter: brightness(1.3); }
|
||||||
.custom-hscroll.dragging .chs-thumb { cursor: grabbing; }
|
.custom-hscroll.dragging .chs-thumb { cursor: grabbing; }
|
||||||
.chs-info { flex: 0 0 auto; min-width: 34px; text-align: right; font-size: 0.75em; color: var(--text-muted, #666); font-variant-numeric: tabular-nums; }
|
.chs-info { flex: 0 0 auto; min-width: 30px; text-align: right; font-size: 0.7em; color: var(--text-muted, #666); font-variant-numeric: tabular-nums; }
|
||||||
[data-theme="dark"] .chs-track { background: rgba(255,255,255,0.16); }
|
[data-theme="dark"] .chs-track { background: rgba(255,255,255,0.12); }
|
||||||
[data-theme="dark"] .chs-thumb { background: #5dade2; }
|
[data-theme="dark"] .chs-thumb { border-color: rgba(255,255,255,0.35); }
|
||||||
[data-theme="dark"] .chs-thumb:hover, [data-theme="dark"] .custom-hscroll.dragging .chs-thumb { background: #85c1e9; }
|
|
||||||
[data-theme="dark"] .chs-thumb span { border-color: rgba(0,0,0,0.55); }
|
|
||||||
|
|
||||||
/* Custom View header on one line like the other tables: drag grip, name, then the
|
/* Custom View header on one line like the other tables: drag grip, name, then the
|
||||||
Excel-style button (sort + filter) and copy icon; remove (x) appears on hover */
|
Excel-style button (sort + filter) and copy icon; remove (x) appears on hover */
|
||||||
@@ -372,7 +371,7 @@ table.xf-table thead th:has(> .xf-btn):not(.sortable-th) { position: relative; p
|
|||||||
.xf-clear-all svg { width: 14px; height: 14px; flex: none; }
|
.xf-clear-all svg { width: 14px; height: 14px; flex: none; }
|
||||||
|
|
||||||
/* Software menus: documented / undocumented quick picks, doc marker, "None" pinned first */
|
/* Software menus: documented / undocumented quick picks, doc marker, "None" pinned first */
|
||||||
.xf-picks { display: flex; gap: 6px; padding: 0 12px 6px; }
|
.xf-picks { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 12px 6px; }
|
||||||
.xf-pick {
|
.xf-pick {
|
||||||
flex: 1; padding: 5px 6px; cursor: pointer; font: inherit; font-size: 0.82em; white-space: nowrap;
|
flex: 1; padding: 5px 6px; cursor: pointer; font: inherit; font-size: 0.82em; white-space: nowrap;
|
||||||
background: transparent; color: #3498db; border: 1px solid rgba(52,152,219,0.55); border-radius: 5px;
|
background: transparent; color: #3498db; border: 1px solid rgba(52,152,219,0.55); border-radius: 5px;
|
||||||
@@ -470,3 +469,23 @@ table.sync-bd tbody tr { cursor: help; }
|
|||||||
.nav-logo { display: none; }
|
.nav-logo { display: none; }
|
||||||
.top-nav .nav-btn { padding: 8px 4px; }
|
.top-nav .nav-btn { padding: 8px 4px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Logo + name: the Home link (Overview) */
|
||||||
|
a.nav-brand { text-decoration: none; color: inherit; cursor: pointer; border-radius: 6px; }
|
||||||
|
a.nav-brand:hover h2 { color: #d6eaf8; }
|
||||||
|
a.nav-brand:focus-visible { outline: 2px solid #3498db; outline-offset: 3px; }
|
||||||
|
|
||||||
|
/* Device details title: name on the left, Simple / Advanced View toggle on the right, one line */
|
||||||
|
#detailsTitle { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding-right: 10px; min-width: 0; }
|
||||||
|
#detailsTitle .dt-name { min-width: 0; overflow-wrap: anywhere; }
|
||||||
|
#detailsTitle .dt-toggle {
|
||||||
|
margin-left: auto; cursor: pointer; padding: 6px 12px; border-radius: 4px; background: #34495e; color: #fff;
|
||||||
|
border: none; font-size: 0.55em; font-weight: bold; white-space: nowrap; transition: background 0.2s;
|
||||||
|
}
|
||||||
|
#detailsTitle .dt-toggle:hover { background: #3498db; }
|
||||||
|
@media (max-width: 700px) { #detailsTitle { padding-right: 32px; } #detailsTitle .dt-toggle { margin-left: 0; } }
|
||||||
|
|
||||||
|
/* Excel filter: three list modes, and the one-click "only documented" pick */
|
||||||
|
.xf-seg3 .xf-seg-btn { font-size: 0.78em; padding-left: 4px; padding-right: 4px; }
|
||||||
|
.xf-pick.xf-pick-main { flex-basis: 100%; background: #27ae60; border-color: #27ae60; color: #fff; font-weight: 600; }
|
||||||
|
.xf-pick.xf-pick-main:hover { background: #219150; border-color: #219150; }
|
||||||
+31
-20
@@ -2,23 +2,39 @@
|
|||||||
/* Chart colors: an ordinal blue ramp for migration stage (not started -> in progress ->
|
/* Chart colors: an ordinal blue ramp for migration stage (not started -> in progress ->
|
||||||
completed) and a sequential blue ramp for counts, each stepped separately for dark mode
|
completed) and a sequential blue ramp for counts, each stepped separately for dark mode
|
||||||
(validated with the dataviz palette checker against the card surfaces). */
|
(validated with the dataviz palette checker against the card surfaces). */
|
||||||
|
/* Stage colors read as status: not started = red, in progress = amber, completed = green
|
||||||
|
(the dashboard's status palette; every colored mark also carries a number or a label) */
|
||||||
#overview {
|
#overview {
|
||||||
--ov-todo: #86b6ef; --ov-doing: #2a78d6; --ov-done: #104281;
|
--ov-todo: #d03b3b; --ov-doing: #f0a20c; --ov-done: #0ca30c; --ov-other: #8a8f98;
|
||||||
--ov-bar: #2a78d6; --ov-track: rgba(127,127,127,0.12);
|
--ov-track: rgba(127,127,127,0.12);
|
||||||
--ov-good: #0ca30c; --ov-serious: #ec835a;
|
--ov-good: #0ca30c; --ov-serious: #ec835a;
|
||||||
}
|
}
|
||||||
[data-theme="dark"] #overview {
|
[data-theme="dark"] #overview { --ov-todo: #e05555; --ov-doing: #e8a317; --ov-done: #2fb344; --ov-other: #6b7280; --ov-track: rgba(255,255,255,0.07); }
|
||||||
--ov-todo: #184f95; --ov-doing: #3987e5; --ov-done: #9ec5f4;
|
/* Location bands: how much of a location is done, red -> green */
|
||||||
--ov-bar: #3987e5; --ov-track: rgba(255,255,255,0.07);
|
.ov-h0 { background: #d03b3b; color: #fff; }
|
||||||
}
|
.ov-h1 { background: #ec835a; color: #1f2328; }
|
||||||
.ov-s0 { background: #cde2fb; color: #0b0b0b; } .ov-s1 { background: #b7d3f6; color: #0b0b0b; }
|
.ov-h2 { background: #fab219; color: #1f2328; }
|
||||||
.ov-s2 { background: #9ec5f4; color: #0b0b0b; } .ov-s3 { background: #6da7ec; color: #0b0b0b; }
|
.ov-h3 { background: #8fd16a; color: #1f2328; }
|
||||||
.ov-s4 { background: #3987e5; color: #fff; } .ov-s5 { background: #256abf; color: #fff; }
|
.ov-h4 { background: #0ca30c; color: #fff; }
|
||||||
.ov-s6 { background: #184f95; color: #fff; }
|
.ov-heat-key { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 10px; }
|
||||||
[data-theme="dark"] .ov-s0 { background: #104281; color: #fff; } [data-theme="dark"] .ov-s1 { background: #184f95; color: #fff; }
|
.ov-heat-key-item { display: inline-flex; align-items: center; gap: 6px; font-size: 0.8em; color: var(--text-muted); }
|
||||||
[data-theme="dark"] .ov-s2 { background: #1c5cab; color: #fff; } [data-theme="dark"] .ov-s3 { background: #256abf; color: #fff; }
|
.ov-heat-key-item i { width: 14px; height: 10px; border-radius: 3px; }
|
||||||
[data-theme="dark"] .ov-s4 { background: #3987e5; color: #fff; } [data-theme="dark"] .ov-s5 { background: #6da7ec; color: #0b0b0b; }
|
|
||||||
[data-theme="dark"] .ov-s6 { background: #9ec5f4; color: #0b0b0b; }
|
/* What opened the list below: clearly marked */
|
||||||
|
.ov-tile.ov-selected { border: 2px solid #3498db !important; background: rgba(52,152,219,0.12); box-shadow: 0 0 0 3px rgba(52,152,219,0.25); transform: none; position: relative; }
|
||||||
|
.ov-showing { display: inline-block; margin-top: 6px; font-size: 0.72em; font-weight: 700; color: #fff; background: #3498db; border-radius: 999px; padding: 2px 8px; }
|
||||||
|
.ov-bar-row.ov-selected { background: rgba(52,152,219,0.16); box-shadow: inset 3px 0 0 #3498db; }
|
||||||
|
.ov-legend-item.ov-selected { color: var(--text-main); text-decoration: underline; text-decoration-color: #3498db; text-decoration-thickness: 2px; text-underline-offset: 4px; }
|
||||||
|
.ov-heat-cell.ov-selected, .ov-heat-sum.ov-selected { box-shadow: 0 0 0 2px var(--bg-card), 0 0 0 5px #3498db !important; }
|
||||||
|
.ov-heat-sum.ov-selected { background: rgba(52,152,219,0.16); }
|
||||||
|
|
||||||
|
/* The list: same look as the dashboard's tables, header sticks under the nav */
|
||||||
|
.ov-drill[hidden] { display: none; }
|
||||||
|
.ov-drill { padding: 0 !important; overflow: visible !important; border-radius: 10px; }
|
||||||
|
.ov-drill-head { border-left-color: #3498db !important; border-radius: 10px 10px 0 0; }
|
||||||
|
.ov-drill-title { font-weight: 700; }
|
||||||
|
.ov-drill .ov-head-btns { display: flex; gap: 8px; margin-left: auto; }
|
||||||
|
.ov-drill-empty, .ov-drill-note { padding: 12px 16px; margin: 0; }
|
||||||
|
|
||||||
#overview-root { max-width: 1500px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; }
|
#overview-root { max-width: 1500px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; }
|
||||||
.ov-title { margin: 0; color: var(--text-main); }
|
.ov-title { margin: 0; color: var(--text-main); }
|
||||||
@@ -75,7 +91,7 @@ span.ov-legend-item { cursor: default; }
|
|||||||
.ov-bar-row:hover, .ov-bar-row:focus-visible { background: var(--ov-track); }
|
.ov-bar-row:hover, .ov-bar-row:focus-visible { background: var(--ov-track); }
|
||||||
.ov-bar-label { font-size: 0.88em; color: var(--text-main); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
.ov-bar-label { font-size: 0.88em; color: var(--text-main); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
.ov-bar-track { height: 14px; }
|
.ov-bar-track { height: 14px; }
|
||||||
.ov-bar { display: block; height: 14px; background: var(--ov-bar); border-radius: 0 4px 4px 0; }
|
.ov-bar { display: block; height: 14px; border-radius: 0 4px 4px 0; }
|
||||||
.ov-bar-value { text-align: right; font-weight: 700; color: var(--text-main); font-variant-numeric: tabular-nums; }
|
.ov-bar-value { text-align: right; font-weight: 700; color: var(--text-main); font-variant-numeric: tabular-nums; }
|
||||||
|
|
||||||
/* heatmap */
|
/* heatmap */
|
||||||
@@ -109,9 +125,6 @@ table.ov-heat td { padding: 0; text-align: center; border: 0; background: none;
|
|||||||
.ov-today { box-shadow: inset 0 0 0 2px #3498db; }
|
.ov-today { box-shadow: inset 0 0 0 2px #3498db; }
|
||||||
.ov-today .ov-day-num { color: #3498db; font-weight: 700; }
|
.ov-today .ov-day-num { color: #3498db; font-weight: 700; }
|
||||||
|
|
||||||
/* drill-down */
|
|
||||||
.ov-drill[hidden] { display: none; }
|
|
||||||
.ov-table-wrap { max-height: 520px; overflow: auto; border-top: none; }
|
|
||||||
|
|
||||||
/* tooltip */
|
/* tooltip */
|
||||||
.ov-tip { position: fixed; z-index: 4000; display: none; pointer-events: none; background: var(--bg-card); color: var(--text-main); border: 1px solid var(--border-color); border-radius: 8px; padding: 8px 10px; box-shadow: 0 6px 20px rgba(0,0,0,0.25); font-size: 0.85em; max-width: 260px; }
|
.ov-tip { position: fixed; z-index: 4000; display: none; pointer-events: none; background: var(--bg-card); color: var(--text-main); border: 1px solid var(--border-color); border-radius: 8px; padding: 8px 10px; box-shadow: 0 6px 20px rgba(0,0,0,0.25); font-size: 0.85em; max-width: 260px; }
|
||||||
@@ -147,5 +160,3 @@ table.ov-heat td { padding: 0; text-align: center; border: 0; background: none;
|
|||||||
.ov-bar-row { grid-template-columns: 128px 1fr 32px; }
|
.ov-bar-row { grid-template-columns: 128px 1fr 32px; }
|
||||||
.ov-bar-label { font-size: 0.82em; }
|
.ov-bar-label { font-size: 0.82em; }
|
||||||
}
|
}
|
||||||
/* the drill-down list scrolls inside its card, so its header sticks to the card's top */
|
|
||||||
#overview .ov-table thead th { top: 0 !important; z-index: 2; }
|
|
||||||
+55
-32
@@ -258,6 +258,7 @@
|
|||||||
const k = el('i', 'ov-key'); k.style.background = `var(--ov-${s.key})`;
|
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.appendChild(k); item.appendChild(el('span', null, `${s.label} `)); item.appendChild(el('b', null, String(counts[s.key])));
|
||||||
item.onclick = () => openList('stage', s.key);
|
item.onclick = () => openList('stage', s.key);
|
||||||
|
if (state.kind === 'stage' && state.arg === s.key) item.classList.add('ov-selected');
|
||||||
legend.appendChild(item);
|
legend.appendChild(item);
|
||||||
});
|
});
|
||||||
const other = all.length - usmt.length;
|
const other = all.length - usmt.length;
|
||||||
@@ -282,6 +283,7 @@
|
|||||||
t.appendChild(top);
|
t.appendChild(top);
|
||||||
t.appendChild(el('div', 'ov-tile-num', waiting ? '–' : String(n)));
|
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')));
|
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}`);
|
t.setAttribute('aria-label', `${issue.label}: ${n}. ${issue.hint}`);
|
||||||
clickable(t, () => openList('issue', issue.key));
|
clickable(t, () => openList('issue', issue.key));
|
||||||
issuesWrap.appendChild(t);
|
issuesWrap.appendChild(t);
|
||||||
@@ -303,6 +305,9 @@
|
|||||||
row.appendChild(el('span', 'ov-bar-label', s));
|
row.appendChild(el('span', 'ov-bar-label', s));
|
||||||
const track = el('span', 'ov-bar-track');
|
const track = el('span', 'ov-bar-track');
|
||||||
const bar = el('span', 'ov-bar');
|
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';
|
bar.style.width = n ? `max(4px, ${(100 * n / max).toFixed(1)}%)` : '0';
|
||||||
track.appendChild(bar);
|
track.appendChild(bar);
|
||||||
row.appendChild(track);
|
row.appendChild(track);
|
||||||
@@ -325,7 +330,7 @@
|
|||||||
frag.appendChild(calendarCard(all));
|
frag.appendChild(calendarCard(all));
|
||||||
|
|
||||||
// Drill-down
|
// Drill-down
|
||||||
const drill = el('section', 'ov-card ov-drill');
|
const drill = el('section', 'table-wrapper ov-drill');
|
||||||
drill.id = 'ov-drill';
|
drill.id = 'ov-drill';
|
||||||
frag.appendChild(drill);
|
frag.appendChild(drill);
|
||||||
|
|
||||||
@@ -354,17 +359,19 @@
|
|||||||
function locationCard(all) {
|
function locationCard(all) {
|
||||||
const c = card('Where the work is', null);
|
const c = card('Where the work is', null);
|
||||||
const toggle = el('div', 'ov-toggle');
|
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';
|
const b = el('button', state.map === k ? 'on' : '', label); b.type = 'button';
|
||||||
b.onclick = () => { state.map = k; render(); };
|
b.onclick = () => { state.map = k; render(); };
|
||||||
toggle.appendChild(b);
|
toggle.appendChild(b);
|
||||||
});
|
});
|
||||||
c.querySelector('.ov-card-head').appendChild(toggle);
|
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();
|
const cells = {}, buildings = new Set(), floors = new Set();
|
||||||
let unknown = 0;
|
let unknown = 0;
|
||||||
all.forEach(r => {
|
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 a = adv(r['Source Device']);
|
||||||
const b = a && a.Building && a.Building !== 'N/A' ? a.Building : null;
|
const b = a && a.Building && a.Building !== 'N/A' ? a.Building : null;
|
||||||
const f = floorOf(a);
|
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...'));
|
c.appendChild(el('p', 'ov-empty-text', all.length ? 'No location data yet (waiting for inventory).' : 'Waiting for the worker...'));
|
||||||
return c;
|
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 wrap = el('div', 'ov-heat-wrap');
|
||||||
const table = el('table', 'ov-heat');
|
const table = el('table', 'ov-heat');
|
||||||
const thead = el('thead'), hr = el('tr');
|
const thead = el('thead'), hr = el('tr');
|
||||||
hr.appendChild(el('th', 'ov-heat-corner', 'Building'));
|
hr.appendChild(el('th', 'ov-heat-corner', 'Building'));
|
||||||
fList.forEach(f => hr.appendChild(el('th', null, f === '?' ? 'Unknown' : 'Floor ' + f)));
|
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);
|
thead.appendChild(hr); table.appendChild(thead);
|
||||||
const tb = el('tbody');
|
const tb = el('tbody');
|
||||||
|
const sel = state.kind === 'place' ? state.arg : null;
|
||||||
bList.forEach(b => {
|
bList.forEach(b => {
|
||||||
const tr = el('tr');
|
const tr = el('tr');
|
||||||
tr.appendChild(el('th', null, b));
|
tr.appendChild(el('th', null, b));
|
||||||
let total = 0;
|
let total = 0, totalDone = 0;
|
||||||
fList.forEach(f => {
|
fList.forEach(f => {
|
||||||
const list = cells[b + '|' + f] || [];
|
const list = cells[b + '|' + f] || [];
|
||||||
total += list.length;
|
|
||||||
const td = el('td');
|
const td = el('td');
|
||||||
if (list.length) {
|
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 };
|
const c3 = { todo: 0, doing: 0, done: 0 };
|
||||||
list.forEach(r => { const s = stageOf(r); if (s) c3[s]++; });
|
list.forEach(r => c3[stageOf(r)]++);
|
||||||
hover(cell, e => showTip(e, String(list.length), `${b}, ${f === '?' ? 'floor unknown' : 'floor ' + f}`,
|
const left = list.length - c3.done;
|
||||||
STAGES.filter(s => c3[s.key]).map(s => [s.key, `${c3[s.key]} ${s.label.toLowerCase()}`])));
|
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 }));
|
clickable(cell, () => openList('place', { b, f }));
|
||||||
td.appendChild(cell);
|
td.appendChild(cell);
|
||||||
} else td.appendChild(el('span', 'ov-heat-none', '·'));
|
} else td.appendChild(el('span', 'ov-heat-none', '\u00b7'));
|
||||||
tr.appendChild(td);
|
tr.appendChild(td);
|
||||||
});
|
});
|
||||||
const tt = el('td', 'ov-heat-total');
|
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 }));
|
clickable(tbtn, () => openList('place', { b, f: null }));
|
||||||
tt.appendChild(tbtn);
|
tt.appendChild(tbtn);
|
||||||
tr.appendChild(tt);
|
tr.appendChild(tt);
|
||||||
@@ -423,8 +436,11 @@
|
|||||||
table.appendChild(tb);
|
table.appendChild(tb);
|
||||||
wrap.appendChild(table);
|
wrap.appendChild(table);
|
||||||
c.appendChild(wrap);
|
c.appendChild(wrap);
|
||||||
const note = el('p', 'ov-note', (state.map === 'left' ? 'Devices not yet completed, by source location. ' : 'Every tracked source, by location. ') +
|
const key = el('div', 'ov-heat-key');
|
||||||
'The stronger the shade, the more devices.' + (unknown ? ` ${plural(unknown, 'device')} without a building yet.` : ''));
|
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);
|
c.appendChild(note);
|
||||||
return c;
|
return c;
|
||||||
}
|
}
|
||||||
@@ -528,7 +544,7 @@
|
|||||||
const { b, f } = state.arg;
|
const { b, f } = state.arg;
|
||||||
title = f === null ? b : `${b}, ${f === '?' ? 'floor unknown' : 'floor ' + f}`;
|
title = f === null ? b : `${b}, ${f === '?' ? 'floor unknown' : 'floor ' + f}`;
|
||||||
items = all.filter(r => {
|
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']);
|
const a = adv(r['Source Device']);
|
||||||
return a && a.Building === b && (f === null || (floorOf(a) || '?') === f);
|
return a && a.Building === b && (f === null || (floorOf(a) || '?') === f);
|
||||||
}).map(r => { const a = adv(r['Source Device']); return { r, detail: a ? a.Location : '' }; });
|
}).map(r => { const a = adv(r['Source Device']); return { r, detail: a ? a.Location : '' }; });
|
||||||
@@ -540,9 +556,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
box.appendChild(listHead(title, items.length));
|
box.appendChild(listHead(title, items.length));
|
||||||
if (!items.length) { box.appendChild(el('p', 'ov-empty-text', 'Nothing here. That one is at zero.')); return; }
|
if (!items.length) { box.appendChild(el('p', 'ov-empty-text ov-drill-empty', 'Nothing here. That one is at zero.')); return; }
|
||||||
const wrap = el('div', 'table-wrapper ov-table-wrap');
|
|
||||||
const table = el('table', 'master-table ov-table');
|
const table = el('table', 'master-table ov-table');
|
||||||
|
table.id = 'ov-table';
|
||||||
const thead = el('thead'), tr = el('tr');
|
const thead = el('thead'), tr = el('tr');
|
||||||
['Source Device', 'Destination', 'Backup Status', detailLabel, 'Tags'].forEach(h => tr.appendChild(el('th', null, h)));
|
['Source Device', 'Destination', 'Backup Status', detailLabel, 'Tags'].forEach(h => tr.appendChild(el('th', null, h)));
|
||||||
thead.appendChild(tr); table.appendChild(thead);
|
thead.appendChild(tr); table.appendChild(thead);
|
||||||
@@ -559,22 +575,24 @@
|
|||||||
tb.appendChild(row);
|
tb.appendChild(row);
|
||||||
});
|
});
|
||||||
table.appendChild(tb);
|
table.appendChild(tb);
|
||||||
wrap.appendChild(table);
|
box.appendChild(table);
|
||||||
box.appendChild(wrap);
|
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) {
|
function listHead(title, n) {
|
||||||
const h = el('div', 'ov-card-head');
|
const h = el('div', 'group-header ov-drill-head');
|
||||||
h.appendChild(el('h2', null, `${title} (${n})`));
|
h.appendChild(el('span', 'ov-drill-title', `${title} (${n})`));
|
||||||
const copy = el('button', 'ov-btn', 'Copy names'); copy.type = 'button';
|
const copy = el('button', 'ov-btn', 'Copy names'); copy.type = 'button';
|
||||||
copy.onclick = () => {
|
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'));
|
if (typeof fallbackCopyTextToClipboard === 'function') fallbackCopyTextToClipboard(names.join('\n'));
|
||||||
copy.textContent = 'Copied'; setTimeout(() => { copy.textContent = 'Copy names'; }, 1500);
|
copy.textContent = 'Copied'; setTimeout(() => { copy.textContent = 'Copy names'; }, 1500);
|
||||||
};
|
};
|
||||||
const close = el('button', 'ov-btn', 'Close'); close.type = 'button';
|
const close = el('button', 'ov-btn', 'Close'); close.type = 'button';
|
||||||
close.onclick = () => { state.kind = null; state.arg = null; render(); };
|
close.onclick = () => { state.kind = null; state.arg = null; render(); };
|
||||||
const btns = el('div', 'ov-head-btns'); btns.appendChild(copy); btns.appendChild(close);
|
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);
|
h.appendChild(btns);
|
||||||
return h;
|
return h;
|
||||||
}
|
}
|
||||||
@@ -582,9 +600,9 @@
|
|||||||
function renderApps(box, title, items) {
|
function renderApps(box, title, items) {
|
||||||
box.appendChild(listHead(title, items.length));
|
box.appendChild(listHead(title, items.length));
|
||||||
if (!items.length) { box.appendChild(el('p', 'ov-empty-text', 'Every specialty app has an article.')); return; }
|
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.'));
|
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 wrap = el('div', 'table-wrapper ov-table-wrap');
|
|
||||||
const table = el('table', 'master-table ov-table');
|
const table = el('table', 'master-table ov-table');
|
||||||
|
table.id = 'ov-table';
|
||||||
const thead = el('thead'), tr = el('tr');
|
const thead = el('thead'), tr = el('tr');
|
||||||
['App', 'Devices', 'Sources with it', ''].forEach(h => tr.appendChild(el('th', null, h)));
|
['App', 'Devices', 'Sources with it', ''].forEach(h => tr.appendChild(el('th', null, h)));
|
||||||
thead.appendChild(tr); table.appendChild(thead);
|
thead.appendChild(tr); table.appendChild(thead);
|
||||||
@@ -609,8 +627,8 @@
|
|||||||
tb.appendChild(row);
|
tb.appendChild(row);
|
||||||
});
|
});
|
||||||
table.appendChild(tb);
|
table.appendChild(tb);
|
||||||
wrap.appendChild(table);
|
box.appendChild(table);
|
||||||
box.appendChild(wrap);
|
if (window.ExcelFilter) ExcelFilter.refresh(table);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------------
|
||||||
@@ -630,7 +648,12 @@
|
|||||||
render();
|
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', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
const orig = window.switchPage;
|
const orig = window.switchPage;
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/*
|
||||||
|
* USMT Dash - page routes
|
||||||
|
*
|
||||||
|
* Every page in the nav has its own address, so links, bookmarks, new tabs and the
|
||||||
|
* browser's back / forward buttons land on the right page without loading the home page
|
||||||
|
* first: / (Overview) /tracked /workbench /status /devices /custom /docs /docs/<title>
|
||||||
|
* The server answers all of these with the dashboard itself.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const ROUTES = {
|
||||||
|
'overview': '/',
|
||||||
|
'tracked-devices': '/tracked',
|
||||||
|
'workbench': '/workbench',
|
||||||
|
'all-devices': '/status',
|
||||||
|
'software': '/devices',
|
||||||
|
'custom': '/custom',
|
||||||
|
'docs': '/docs'
|
||||||
|
};
|
||||||
|
const ALIASES = { '/overview': 'overview', '/home': 'overview' };
|
||||||
|
|
||||||
|
function pageFor(path) {
|
||||||
|
path = (path || '/').replace(/\/+$/, '') || '/';
|
||||||
|
if (path === '/docs' || path.startsWith('/docs/')) return 'docs';
|
||||||
|
if (ALIASES[path]) return ALIASES[path];
|
||||||
|
return Object.keys(ROUTES).find(id => ROUTES[id] === path) || null;
|
||||||
|
}
|
||||||
|
const docTitle = () => (typeof currentDocTitle !== 'undefined' && currentDocTitle) ? currentDocTitle : '';
|
||||||
|
function pathFor(id) {
|
||||||
|
if (id === 'docs') {
|
||||||
|
if (location.pathname.startsWith('/docs/')) return location.pathname; // docs.js already set the article
|
||||||
|
return docTitle() ? '/docs/' + encodeURIComponent(docTitle()) : '/docs';
|
||||||
|
}
|
||||||
|
return ROUTES[id] || '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
let quiet = false; // true while following the address bar (no new history entries)
|
||||||
|
function go(id) {
|
||||||
|
if (!ROUTES[id]) return;
|
||||||
|
const path = pathFor(id);
|
||||||
|
if (quiet || location.pathname === path) return;
|
||||||
|
try { history.pushState({ page: id }, '', path + location.hash); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function follow() {
|
||||||
|
const id = pageFor(location.pathname);
|
||||||
|
if (!id) return;
|
||||||
|
quiet = true;
|
||||||
|
try {
|
||||||
|
if (id === 'docs') {
|
||||||
|
const m = location.pathname.match(/^\/docs\/(.+)$/);
|
||||||
|
let title = null;
|
||||||
|
if (m) { try { title = decodeURIComponent(m[1]); } catch (e) {} }
|
||||||
|
if (!(title && window.KB && KB.open(title))) switchPage('docs');
|
||||||
|
} else {
|
||||||
|
switchPage(id);
|
||||||
|
}
|
||||||
|
} finally { quiet = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
const orig = window.switchPage;
|
||||||
|
if (typeof orig !== 'function') return;
|
||||||
|
window.switchPage = function (id) {
|
||||||
|
const r = orig.apply(this, arguments);
|
||||||
|
go(id);
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
// Open the page in the address bar right away (articles open once the list has loaded)
|
||||||
|
const id = pageFor(location.pathname);
|
||||||
|
if (id && id !== 'overview' && id !== 'docs') { quiet = true; try { orig(id); } finally { quiet = false; } }
|
||||||
|
if (id === 'docs' && location.pathname === '/docs') { quiet = true; try { orig('docs'); } finally { quiet = false; } }
|
||||||
|
window.addEventListener('popstate', follow);
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in new issue
Block a user