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"
|
||||
ActiveLogHours = 24
|
||||
|
||||
# Every tracked device (including completed pairs and destinations the sweep skips)
|
||||
# is pinged this often to keep "last online" / "days offline" current. Cycles only run
|
||||
# in business hours plus the midnight pitstop, so 60 = hourly by day, plus midnight.
|
||||
# Completed pairs skip network checks in the sweep, so they're pinged this often to keep
|
||||
# "last online" / "days offline" current (every other device is pinged every cycle).
|
||||
# Cycles only run in business hours plus the midnight pitstop: 60 = hourly, plus midnight.
|
||||
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*,
|
||||
*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.
|
||||
- **Presence**: every tracked device (completed pairs and destinations included) is pinged
|
||||
hourly during business hours and at midnight, so each one shows when it was last online and
|
||||
how many days it has been offline.
|
||||
- **Presence**: every ping is recorded, so each device shows when it was last online and how
|
||||
many days it has been offline. Completed pairs, which skip network checks in the sweep, are
|
||||
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
|
||||
reachable are kept with their timestamps, and shown for completed migrations and offline
|
||||
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,
|
||||
found in AD/SCCM/DNS and monitors), with drag-to-reorder, resizable and Excel-style
|
||||
filterable columns. Saved per browser.
|
||||
@@ -106,6 +110,7 @@ refresh" buttons drop flag files that the worker watches for.
|
||||
| Method & path | Purpose |
|
||||
|---|---|
|
||||
| `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 /get-config` | Mode flags for the front end |
|
||||
| `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 ---
|
||||
if ([string]::IsNullOrWhiteSpace($localPath)) { $localPath = "index.html" }
|
||||
# Article permalinks (/docs/Adobe Acrobat Pro) are pages of the dashboard itself
|
||||
if ($localPath -eq "docs" -or ($localPath -like "docs/*" -and $localPath -notmatch '\.(md|png|jpe?g|gif|webp|svg|css|js)$')) { $localPath = "index.html" }
|
||||
# Page routes (/tracked, /status, ...) and article permalinks (/docs/Adobe Acrobat Pro)
|
||||
# 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
|
||||
if ($publicDataFiles -contains $localPath) {
|
||||
|
||||
+9
-5
@@ -735,16 +735,20 @@ while ($true) {
|
||||
$sweepSeconds = $phaseWatch.Elapsed.TotalSeconds
|
||||
$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
|
||||
$presenceDue = ((Get-Date) - $global:LastPresenceSweep).TotalMinutes -ge ($presenceMinutes - 0.5) -or $runFullSoftwareSweep
|
||||
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 ($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)
|
||||
Write-Host "`n[Presence Sweep: $($toPing.Count) device(s) not pinged this cycle]" -ForegroundColor Cyan
|
||||
$toPing = @($completedDevs | Where-Object { -not $global:PingedThisCycle.Contains($_) } | Sort-Object)
|
||||
Write-Host "`n[Presence Sweep: pinging $($toPing.Count) device(s) from completed migrations]" -ForegroundColor Cyan
|
||||
$i = 0
|
||||
foreach ($n in $toPing) {
|
||||
$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 };
|
||||
}
|
||||
|
||||
// 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
|
||||
function daysOffline(name) {
|
||||
const p = presence(name);
|
||||
@@ -121,6 +130,7 @@
|
||||
function computed(name, key) {
|
||||
const a = entry(name);
|
||||
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') {
|
||||
const d = daysOffline(name);
|
||||
if (d === undefined) return { text: WAITING, state: 'waiting' };
|
||||
@@ -164,7 +174,7 @@
|
||||
function presenceHtml(name) {
|
||||
if (!isName(name)) return 'N/A';
|
||||
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 cls = p.online ? 'di-online' : (p.days !== null && p.days >= 3 ? 'di-offline-long' : 'di-offline');
|
||||
let sub = '';
|
||||
|
||||
+9
-2
@@ -10,8 +10,8 @@
|
||||
/* 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 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: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 { 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 6px rgba(52,152,219,0.35); }
|
||||
.kb-item.active { background: rgba(52,152,219,0.14); border-color: #3498db; }
|
||||
.kb-item-main { min-width: 0; flex: 1; }
|
||||
.kb-item a { color: #3498db; text-decoration: none; font-weight: 500; overflow-wrap: anywhere; }
|
||||
@@ -64,3 +64,10 @@
|
||||
@media (max-width: 1100px) {
|
||||
.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' };
|
||||
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 => {
|
||||
if (bq.dataset.kb) return;
|
||||
bq.dataset.kb = '1';
|
||||
@@ -405,14 +412,7 @@
|
||||
if (!m) ed.value = `<!-- category: ${sel.value} -->\n` + ed.value;
|
||||
}
|
||||
});
|
||||
wrap('switchPage', id => {
|
||||
if (id !== 'docs' && location.pathname.startsWith('/docs')) setUrl('/', 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');
|
||||
});
|
||||
// Page routing (including /docs) lives in router.js; it hands /docs/<title> back here
|
||||
window.KB = { open: name => { const d = findDoc(name); if (d) { openDoc(d); return true; } return false; } };
|
||||
});
|
||||
})();
|
||||
+74
-14
@@ -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();
|
||||
})();
|
||||
+4
-6
@@ -359,6 +359,7 @@
|
||||
<script src="/device-info.js"></script>
|
||||
<script src="/overview.js"></script>
|
||||
<script src="/docs.js"></script>
|
||||
<script src="/router.js"></script>
|
||||
<script src="/excel-filter.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -372,10 +373,10 @@
|
||||
</svg>
|
||||
</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">
|
||||
<h2>USMT Dash</h2>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<div class="nav-links" id="nav-links-menu">
|
||||
<button class="nav-btn active" id="btn-overview" onclick="switchPage('overview')">Overview</button>
|
||||
@@ -2829,7 +2830,7 @@
|
||||
if (!match) return;
|
||||
|
||||
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 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>` : ''}
|
||||
<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 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>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 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>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>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>
|
||||
</table>
|
||||
</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; }
|
||||
.custom-hscroll {
|
||||
position: sticky; bottom: var(--dock-h, 40px); z-index: 15;
|
||||
display: flex; align-items: center; gap: 6px; height: 30px; padding: 0 8px;
|
||||
background: var(--bg-card); border-top: 2px solid #3498db; border-radius: 0 0 8px 8px;
|
||||
box-shadow: 0 -3px 10px rgba(0,0,0,0.18); user-select: none; touch-action: none;
|
||||
display: flex; align-items: center; gap: 6px; height: 24px; padding: 0 8px;
|
||||
background: var(--bg-card); border-top: 2px solid var(--table-header-bg); border-radius: 0 0 8px 8px;
|
||||
box-shadow: 0 -2px 8px rgba(0,0,0,0.15); user-select: none; touch-action: none;
|
||||
}
|
||||
.custom-hscroll[hidden] { display: none; }
|
||||
.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;
|
||||
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-track {
|
||||
position: relative; flex: 1 1 auto; height: 14px; border-radius: 7px; cursor: pointer;
|
||||
background: rgba(127,127,127,0.22); box-shadow: inset 0 1px 2px rgba(0,0,0,0.25); outline: none;
|
||||
position: relative; flex: 1 1 auto; height: 10px; border-radius: 5px; cursor: pointer;
|
||||
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 {
|
||||
position: absolute; top: -3px; left: 0; height: 20px; min-width: 48px; border-radius: 10px;
|
||||
background: #3498db; cursor: grab; box-shadow: 0 1px 4px rgba(0,0,0,0.35);
|
||||
display: flex; align-items: center; justify-content: center; transition: background 0.15s;
|
||||
position: absolute; top: -2px; left: 0; height: 14px; min-width: 40px; border-radius: 7px;
|
||||
background: var(--table-header-bg); cursor: grab; box-shadow: 0 1px 3px rgba(0,0,0,0.35);
|
||||
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:hover, .custom-hscroll.dragging .chs-thumb { background: #1d7fc4; }
|
||||
.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 { filter: brightness(1.3); }
|
||||
.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; }
|
||||
[data-theme="dark"] .chs-track { background: rgba(255,255,255,0.16); }
|
||||
[data-theme="dark"] .chs-thumb { background: #5dade2; }
|
||||
[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); }
|
||||
.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.12); }
|
||||
[data-theme="dark"] .chs-thumb { border-color: rgba(255,255,255,0.35); }
|
||||
|
||||
/* 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 */
|
||||
@@ -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; }
|
||||
|
||||
/* 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 {
|
||||
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;
|
||||
@@ -470,3 +469,23 @@ table.sync-bd tbody tr { cursor: help; }
|
||||
.nav-logo { display: none; }
|
||||
.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 ->
|
||||
completed) and a sequential blue ramp for counts, each stepped separately for dark mode
|
||||
(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 {
|
||||
--ov-todo: #86b6ef; --ov-doing: #2a78d6; --ov-done: #104281;
|
||||
--ov-bar: #2a78d6; --ov-track: rgba(127,127,127,0.12);
|
||||
--ov-todo: #d03b3b; --ov-doing: #f0a20c; --ov-done: #0ca30c; --ov-other: #8a8f98;
|
||||
--ov-track: rgba(127,127,127,0.12);
|
||||
--ov-good: #0ca30c; --ov-serious: #ec835a;
|
||||
}
|
||||
[data-theme="dark"] #overview {
|
||||
--ov-todo: #184f95; --ov-doing: #3987e5; --ov-done: #9ec5f4;
|
||||
--ov-bar: #3987e5; --ov-track: rgba(255,255,255,0.07);
|
||||
}
|
||||
.ov-s0 { background: #cde2fb; color: #0b0b0b; } .ov-s1 { background: #b7d3f6; color: #0b0b0b; }
|
||||
.ov-s2 { background: #9ec5f4; color: #0b0b0b; } .ov-s3 { background: #6da7ec; color: #0b0b0b; }
|
||||
.ov-s4 { background: #3987e5; color: #fff; } .ov-s5 { background: #256abf; color: #fff; }
|
||||
.ov-s6 { background: #184f95; color: #fff; }
|
||||
[data-theme="dark"] .ov-s0 { background: #104281; color: #fff; } [data-theme="dark"] .ov-s1 { background: #184f95; color: #fff; }
|
||||
[data-theme="dark"] .ov-s2 { background: #1c5cab; color: #fff; } [data-theme="dark"] .ov-s3 { background: #256abf; color: #fff; }
|
||||
[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; }
|
||||
[data-theme="dark"] #overview { --ov-todo: #e05555; --ov-doing: #e8a317; --ov-done: #2fb344; --ov-other: #6b7280; --ov-track: rgba(255,255,255,0.07); }
|
||||
/* Location bands: how much of a location is done, red -> green */
|
||||
.ov-h0 { background: #d03b3b; color: #fff; }
|
||||
.ov-h1 { background: #ec835a; color: #1f2328; }
|
||||
.ov-h2 { background: #fab219; color: #1f2328; }
|
||||
.ov-h3 { background: #8fd16a; color: #1f2328; }
|
||||
.ov-h4 { background: #0ca30c; color: #fff; }
|
||||
.ov-heat-key { display: flex; flex-wrap: wrap; gap: 6px 14px; margin-top: 10px; }
|
||||
.ov-heat-key-item { display: inline-flex; align-items: center; gap: 6px; font-size: 0.8em; color: var(--text-muted); }
|
||||
.ov-heat-key-item i { width: 14px; height: 10px; border-radius: 3px; }
|
||||
|
||||
/* 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; }
|
||||
.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-label { font-size: 0.88em; color: var(--text-main); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.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; }
|
||||
|
||||
/* 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 .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 */
|
||||
.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-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})`;
|
||||
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;
|
||||
|
||||
@@ -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