Files
usmt-dash/web/overview.js
T
Johan Sandoval 76068893ee Overview calendar: mark today in amber so it can't pass for the selected day
Today gets an amber stripe across the top, an amber badge on the date and
an amber edge; the selected day keeps the blue outline and tint.
2026-10-07 21:02:41 +00:00

694 lines
37 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* USMT Dash - Overview page
*
* The landing page. Its job is to help a team drive the open issues on a refresh to zero:
* a row of issue counters (offline, not started, Bad Client / Broken WMI tags, missing from
* AD/SCCM/DNS, low disk, backup errors, undocumented apps), overall progress, devices per
* Backup Status, a building x floor map of what's left, and a calendar of the deployment
* date tags with how far each day's devices have got. Every number opens the devices
* behind it; every device opens its details.
*
* Counts are per SOURCE device (one row per migration). Reads the dashboard's globals
* (apiData, advancedDataCache, softwareCache, availableDocs) and re-renders when they change.
*/
(function () {
'use strict';
const LOW_DISK_GB = 20;
const STAGES = [
{ key: 'todo', label: 'Not started' },
{ key: 'doing', label: 'In progress' },
{ key: 'done', label: 'Completed' }
];
const IN_PROGRESS = ['Backup In Progress', 'Backup Completed', 'Restore In Progress'];
const STATUS_ORDER = ['Not Started', 'Backup In Progress', 'Backup Completed', 'Restore In Progress', 'Completed', 'Not Associated', 'No Data', 'Manual pair', 'Skipped'];
const DATE_TAG = /^(\d{1,2})\/(\d{1,2})(?:\/(\d{2}|\d{4}))?$/;
const g = name => { try { return eval(name); } catch (e) { return undefined; } }; // dashboard globals (let-scoped)
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
const plural = (n, one, many) => `${n} ${n === 1 ? one : (many || one + 's')}`;
let state = { kind: null, arg: null, map: 'left' };
let lastSig = '';
let loaded = false; // set once the dashboard's first data load has finished
// ---------------------------------------------------------------------------------
// Data
// ---------------------------------------------------------------------------------
function rows() {
const data = g('apiData');
return Array.isArray(data) ? data.filter(r => r && r['Source Device']) : [];
}
const tagsOf = r => String(r.Tags || '').split(',').map(t => t.trim()).filter(t => t && t !== 'None');
const isManual = r => r.Pairing === 'Manual' || (r['USMT Status'] === 'N/A' && r['Backup Status'] === 'N/A' && r['Destination Device'] !== 'N/A');
function statusOf(r) {
if (isManual(r)) return 'Manual pair';
const s = r['Backup Status'];
return s && s !== 'N/A' ? s : 'No Data';
}
function stageOf(r) {
const s = statusOf(r);
if (s === 'Completed') return 'done';
if (IN_PROGRESS.includes(s)) return 'doing';
if (s === 'Skipped' || s === 'Manual pair') return null; // not a USMT migration
return 'todo';
}
const gb = v => { const m = String(v || '').match(/([\d.]+)\s*GB/i); return m ? parseFloat(m[1]) : null; };
function adv(name) { return window.DeviceInfo ? DeviceInfo.entry(name) : null; }
// Floor: an explicit Floor field if the inventory has one, else the part of the location
// before the first separator ("03-132" -> 03)
function floorOf(a) {
if (!a) return null;
if (a.Floor && a.Floor !== 'N/A') return String(a.Floor);
const m = String(a.Location || '').match(/^\s*([A-Za-z]?\d{1,3}|[A-Za-z]{1,2})\s*[-./ ]/);
return m ? m[1].replace(/^0+(?=\d)/, '') : null;
}
function parseDateTag(tag) {
const m = String(tag).match(DATE_TAG);
if (!m) return null;
const now = new Date();
let y = m[3] ? (m[3].length === 2 ? 2000 + +m[3] : +m[3]) : now.getFullYear();
let d = new Date(y, +m[1] - 1, +m[2]);
if (d.getMonth() !== +m[1] - 1) return null;
if (!m[3]) { // no year: pick the one closest to today (a 12/30 tag seen in January)
const diff = d - now;
if (diff > 182 * 864e5) d = new Date(y - 1, +m[1] - 1, +m[2]);
else if (diff < -182 * 864e5) d = new Date(y + 1, +m[1] - 1, +m[2]);
}
return d;
}
const dayKey = d => `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
function specialtyApps(name) {
const sc = g('softwareCache') || {};
if (!Array.isArray(sc[name])) return null;
try {
return [...new Set([
...sc[name].filter(a => isSpecialtyApp(a.Name, name)).map(a => getAppDisplayName(a.Name)),
...getComboApps(sc[name]).map(c => c.Name)
])];
} catch (e) { return null; }
}
function hasDoc(app) {
const docs = g('availableDocs') || [];
return docs.some(d => d.toLowerCase() === String(app).toLowerCase());
}
function missingText(name) {
const a = adv(name);
if (!a) return '';
const no = [];
if (a.InAD === false) no.push('AD');
if (a.InSCCM === false) no.push('SCCM');
if (a.InDNS === false) no.push('DNS');
return no.length ? 'not in ' + no.join(', ') : '';
}
// Each issue: the rows behind it and a per-row detail for the drill-down
const ISSUES = [
{
key: 'offline', label: 'Offline', hint: 'Sources not answering pings (completed and inventory-only devices excluded)',
pick(all) {
return all.filter(r => !['Completed', 'Skipped'].includes(statusOf(r))).map(r => {
const p = DeviceInfo.presence(r['Source Device']);
if (!p || p.online) return null;
return { r, sort: p.days === null ? 1e9 : p.days, detail: DeviceInfo.offlineText(r['Source Device']) };
}).filter(Boolean).sort((a, b) => b.sort - a.sort);
},
sub(list) { const long = list.filter(x => x.sort >= 3).length; return long ? `${long} for 3+ days` : (list.length ? 'all under 3 days' : 'all reachable'); }
},
{
key: 'notstarted', label: 'Not started', hint: 'Associated, backup not started yet',
pick: all => all.filter(r => statusOf(r) === 'Not Started').map(r => ({ r, detail: r['Source Online'] || '' })),
sub(list) { const on = list.filter(x => x.r['Source Online'] === 'Online').length; return `${on} online now`; }
},
{
key: 'badclient', label: 'Bad Client', hint: 'Devices under a "Bad Client" tag',
pick: all => all.filter(r => tagsOf(r).some(t => /bad\s*client/i.test(t))).map(r => ({ r, detail: tagsOf(r).join(', ') })),
sub: () => 'tagged by the team'
},
{
key: 'brokenwmi', label: 'Broken WMI', hint: 'Devices under a "Broken WMI" tag',
pick: all => all.filter(r => tagsOf(r).some(t => /broken\s*wmi/i.test(t))).map(r => ({ r, detail: tagsOf(r).join(', ') })),
sub: () => 'tagged by the team'
},
{
key: 'missing', label: 'Not in AD / SCCM / DNS', hint: 'Sources missing from a directory, or replacements missing once the backup has started',
pick: all => all.map(r => {
const parts = [];
const s = missingText(r['Source Device']);
if (s) parts.push('Source ' + s);
if (stageOf(r) && stageOf(r) !== 'todo' || isManual(r)) {
const d = missingText(r['Destination Device']);
if (d) parts.push('Dest ' + d);
}
return parts.length ? { r, detail: parts.join('; ') } : null;
}).filter(Boolean),
sub: list => `${list.filter(x => /^Source/.test(x.detail)).length} source, ${list.filter(x => /Dest/.test(x.detail)).length} destination`
},
{
key: 'lowdisk', label: `Under ${LOW_DISK_GB} GB free`, hint: `Sources still to back up with less than ${LOW_DISK_GB} GB free`,
pick: all => all.filter(r => ['Not Started', 'Backup In Progress'].includes(statusOf(r))).map(r => {
const free = gb(r['Source Free Space']);
return free !== null && free < LOW_DISK_GB ? { r, sort: free, detail: `${r['Source Free Space']} free of ${r['Source Total Space']}` } : null;
}).filter(Boolean).sort((a, b) => a.sort - b.sort),
sub: () => 'before backup'
},
{
key: 'errors', label: 'Backup errors', hint: 'The last scanstate / loadstate line is an error',
pick: all => all.filter(r => /error/i.test(r['Source Scanstate Line'] || '') || /error/i.test(r['Destination Loadstate Line'] || ''))
.map(r => ({ r, detail: /error/i.test(r['Source Scanstate Line'] || '') ? r['Source Scanstate Line'] : r['Destination Loadstate Line'] })),
sub: () => 'scanstate / loadstate'
},
{
key: 'undocumented', label: 'Undocumented apps', hint: 'Specialty apps on sources with no knowledge-base article', apps: true,
pick(all) {
const count = {};
all.forEach(r => (specialtyApps(r['Source Device']) || []).forEach(a => { if (!hasDoc(a)) (count[a] = count[a] || []).push(r['Source Device']); }));
return Object.entries(count).map(([app, devs]) => ({ app, devs, sort: devs.length })).sort((a, b) => b.sort - a.sort || a.app.localeCompare(b.app));
},
sub(list) { const n = list.reduce((s, x) => s + x.devs.length, 0); return `on ${plural(n, 'device')}`; }
}
];
// ---------------------------------------------------------------------------------
// Tooltip (one, shared)
// ---------------------------------------------------------------------------------
let tip = null;
function showTip(evt, value, label, lines) {
if (!tip) { tip = el('div', 'ov-tip'); tip.setAttribute('role', 'tooltip'); document.body.appendChild(tip); }
tip.textContent = '';
tip.appendChild(el('div', 'ov-tip-value', value));
if (label) tip.appendChild(el('div', 'ov-tip-label', label));
(lines || []).forEach(([key, text]) => {
const row = el('div', 'ov-tip-row');
if (key) { const k = el('i', 'ov-key'); k.style.background = `var(--ov-${key})`; row.appendChild(k); }
row.appendChild(el('span', null, text));
tip.appendChild(row);
});
tip.style.display = 'block';
const r = evt.target.getBoundingClientRect();
const tw = tip.offsetWidth, th = tip.offsetHeight;
let x = (evt.clientX || r.left + r.width / 2) + 14, y = (evt.clientY || r.top) + 14;
if (x + tw > window.innerWidth - 8) x = Math.max(8, x - tw - 28);
if (y + th > window.innerHeight - 8) y = Math.max(8, y - th - 28);
tip.style.left = x + 'px'; tip.style.top = y + 'px';
}
function hideTip() { if (tip) tip.style.display = 'none'; }
function hover(node, fn) {
node.addEventListener('pointermove', e => fn(e));
node.addEventListener('focus', e => fn(e));
node.addEventListener('pointerleave', hideTip);
node.addEventListener('blur', hideTip);
}
function clickable(node, fn) {
node.tabIndex = 0;
node.setAttribute('role', 'button');
node.addEventListener('click', fn);
node.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fn(); } });
}
// ---------------------------------------------------------------------------------
// Render
// ---------------------------------------------------------------------------------
function card(title, sub) {
const c = el('section', 'ov-card');
const h = el('div', 'ov-card-head');
h.appendChild(el('h2', null, title));
if (sub) h.appendChild(el('span', 'ov-card-sub', sub));
c.appendChild(h);
return c;
}
// Shown from the moment the page opens until the first data arrives
function renderSkeleton(root) {
root.innerHTML = '<div class="ov-head"><div><h1 class="ov-title">Overview</h1><p class="ov-subtitle">Loading the fleet...</p></div></div>'
+ '<section class="ov-card ov-skel ov-skel-hero"></section><div class="ov-skel-row"><section class="ov-card ov-skel"></section><section class="ov-card ov-skel"></section></div>';
}
function render() {
const root = document.getElementById('overview-root');
if (!root) return;
if (!loaded) { renderSkeleton(root); return; }
const all = rows();
hideTip();
const frag = document.createDocumentFragment();
const waiting = !all.length || all.every(r => statusOf(r) === 'No Data');
// Header
const head = el('div', 'ov-head');
const titles = el('div');
titles.appendChild(el('h1', 'ov-title', 'Overview'));
const upd = g('globalDataLastUpdated');
titles.appendChild(el('p', 'ov-subtitle', waiting ? 'Waiting for the worker\'s first sweep...' :
`${plural(all.length, 'source device')} tracked${upd && upd !== 'Never' ? ' · data from ' + upd : ''}. Click any number to see the devices behind it.`));
head.appendChild(titles);
frag.appendChild(head);
// Progress (hero + meter)
const usmt = all.filter(r => stageOf(r));
const counts = { todo: 0, doing: 0, done: 0 };
usmt.forEach(r => counts[stageOf(r)]++);
const pct = usmt.length ? Math.round(100 * counts.done / usmt.length) : 0;
const prog = el('section', 'ov-card ov-progress');
const hero = el('div', 'ov-hero');
hero.appendChild(el('div', 'ov-hero-num', pct + '%'));
hero.appendChild(el('div', 'ov-hero-label', `migrated · ${counts.done} of ${plural(usmt.length, 'USMT migration')}`));
prog.appendChild(hero);
prog.appendChild(stackBar(counts, usmt.length, 'ov-meter', stage => openList('stage', stage)));
const legend = el('div', 'ov-legend');
STAGES.forEach(s => {
const item = el('button', 'ov-legend-item'); item.type = 'button';
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;
if (other) legend.appendChild(el('span', 'ov-legend-note', `+ ${plural(other, 'manual pair or inventory-only device', 'manual pairs / inventory-only devices')}`));
prog.appendChild(legend);
frag.appendChild(prog);
// Issue counters
const issuesWrap = el('section', 'ov-issues');
issuesWrap.setAttribute('aria-label', 'Issues to clear');
ISSUES.forEach(issue => {
const list = waiting ? [] : issue.pick(all);
const n = list.length;
const t = el('div', 'ov-tile' + (n ? ' ov-has' : ' ov-zero') + (state.kind === 'issue' && state.arg === issue.key ? ' ov-selected' : ''));
t.title = issue.hint;
const top = el('div', 'ov-tile-top');
const icon = el('span', 'ov-status-icon');
icon.innerHTML = n ? '<svg viewBox="0 0 16 16"><path d="M8 1.5 15 14H1z" fill="currentColor"/><path d="M8 6v3.6M8 11.4v.1" stroke="#fff" stroke-width="1.6" stroke-linecap="round"/></svg>'
: '<svg viewBox="0 0 16 16"><circle cx="8" cy="8" r="7" fill="currentColor"/><path d="m4.8 8.2 2.2 2.2 4.2-4.6" fill="none" stroke="#fff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"/></svg>';
top.appendChild(icon);
top.appendChild(el('span', 'ov-tile-label', issue.label));
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);
});
frag.appendChild(issuesWrap);
const grid = el('div', 'ov-grid');
// Backup Status: horizontal bars, one hue, direct labels
const statusCard = card('Devices by Backup Status', 'source devices');
const byStatus = {};
all.forEach(r => { const s = statusOf(r); byStatus[s] = (byStatus[s] || 0) + 1; });
const max = Math.max(1, ...Object.values(byStatus));
const bars = el('div', 'ov-bars');
STATUS_ORDER.concat(Object.keys(byStatus).filter(s => !STATUS_ORDER.includes(s))).forEach(s => {
const n = byStatus[s] || 0;
if (!n && ['No Data', 'Manual pair', 'Skipped'].includes(s)) return;
const row = el('div', 'ov-bar-row');
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);
row.appendChild(el('span', 'ov-bar-value', String(n)));
if (n) {
clickable(row, () => openList('status', s));
hover(row, e => showTip(e, String(n), s, [[null, `${Math.round(100 * n / all.length)}% of tracked sources`]]));
}
bars.appendChild(row);
});
statusCard.appendChild(bars);
grid.appendChild(statusCard);
// Locations: building x floor heatmap
grid.appendChild(locationCard(all));
frag.appendChild(grid);
// Deployment calendar
frag.appendChild(calendarCard(all));
// Drill-down
const drill = el('section', 'table-wrapper ov-drill');
drill.id = 'ov-drill';
frag.appendChild(drill);
root.textContent = '';
root.appendChild(frag);
renderList();
}
// A 3-part stacked bar (not started / in progress / completed) with 2px gaps
function stackBar(counts, total, cls, onPick) {
const bar = el('div', cls + ' ov-stack');
if (!total) { bar.classList.add('ov-empty'); return bar; }
STAGES.forEach(s => {
const n = counts[s.key];
if (!n) return;
const seg = el('span', 'ov-seg');
seg.style.flexGrow = n;
seg.style.background = `var(--ov-${s.key})`;
hover(seg, e => showTip(e, String(n), s.label, [[null, `${Math.round(100 * n / total)}%`]]));
if (onPick) seg.addEventListener('click', e => { e.stopPropagation(); onPick(s.key); });
bar.appendChild(seg);
});
return bar;
}
function locationCard(all) {
const c = card('Where the work is', null);
const toggle = el('div', 'ov-toggle');
[['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 (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);
if (!b) { unknown++; return; }
buildings.add(b);
const fk = f || '?';
floors.add(fk);
const key = b + '|' + fk;
(cells[key] = cells[key] || []).push(r);
});
const bList = [...buildings].sort();
const fList = [...floors].sort((a, b) => (a === '?') - (b === '?') || a.localeCompare(b, undefined, { numeric: true }));
if (!bList.length) {
c.appendChild(el('p', 'ov-empty-text', all.length ? 'No location data yet (waiting for inventory).' : 'Waiting for the worker...'));
return c;
}
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', 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, totalDone = 0;
fList.forEach(f => {
const list = cells[b + '|' + f] || [];
const td = el('td');
if (list.length) {
const c3 = { todo: 0, doing: 0, done: 0 };
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', '\u00b7'));
tr.appendChild(td);
});
const tt = el('td', 'ov-heat-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);
tb.appendChild(tr);
});
table.appendChild(tb);
wrap.appendChild(table);
c.appendChild(wrap);
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;
}
function calendarCard(all) {
const c = card('Deployment calendar', 'date tags');
const days = {};
all.forEach(r => tagsOf(r).forEach(t => {
const d = parseDateTag(t);
if (!d) return;
const k = dayKey(d);
(days[k] = days[k] || { date: d, tag: t, rows: [] }).rows.push(r);
}));
const dates = Object.values(days).map(x => x.date);
if (!dates.length) {
c.appendChild(el('p', 'ov-empty-text', 'No date tags yet. Tag devices with their deployment date (for example 10/14) to plan them here.'));
return c;
}
const legend = el('div', 'ov-legend ov-legend-small');
STAGES.forEach(s => { const it = el('span', 'ov-legend-item'); const k = el('i', 'ov-key'); k.style.background = `var(--ov-${s.key})`; it.appendChild(k); it.appendChild(el('span', null, s.label)); legend.appendChild(it); });
c.querySelector('.ov-card-head').appendChild(legend);
const today = new Date(); today.setHours(0, 0, 0, 0);
const first = new Date(Math.min(...dates)), last = new Date(Math.max(...dates, today));
const months = el('div', 'ov-months');
for (let m = new Date(first.getFullYear(), first.getMonth(), 1); m <= last; m = new Date(m.getFullYear(), m.getMonth() + 1, 1)) {
const month = el('div', 'ov-month');
month.appendChild(el('div', 'ov-month-name', m.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })));
const grid = el('div', 'ov-cal');
['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].forEach(d => grid.appendChild(el('div', 'ov-dow', d)));
for (let i = 0; i < m.getDay(); i++) grid.appendChild(el('div', 'ov-day ov-pad'));
const dim = new Date(m.getFullYear(), m.getMonth() + 1, 0).getDate();
for (let d = 1; d <= dim; d++) {
const date = new Date(m.getFullYear(), m.getMonth(), d);
const info = days[dayKey(date)];
const isToday = date.getTime() === today.getTime();
const cell = el('div', 'ov-day' + (isToday ? ' ov-today' : '') + (date.getDay() % 6 === 0 ? ' ov-weekend' : ''));
const head = el('div', 'ov-day-head');
head.appendChild(el('span', 'ov-day-num', String(d)));
if (isToday) cell.title = 'Today';
cell.appendChild(head);
if (info) {
cell.classList.add('ov-has-day');
const c3 = { todo: 0, doing: 0, done: 0 };
info.rows.forEach(r => { const s = stageOf(r); if (s) c3[s]++; });
const n = info.rows.length;
cell.appendChild(el('span', 'ov-day-count', String(n)));
const usm = c3.todo + c3.doing + c3.done;
cell.appendChild(stackBar(c3, usm, 'ov-day-bar'));
const lines = STAGES.filter(s => c3[s.key]).map(s => [s.key, `${c3[s.key]} ${s.label.toLowerCase()}`]);
if (n > usm) lines.push([null, `${n - usm} manual / inventory only`]);
hover(cell, e => showTip(e, plural(n, 'device'), date.toLocaleDateString(undefined, { weekday: 'long', month: 'short', day: 'numeric' }) + ` (tag ${info.tag})`, lines));
clickable(cell, () => openList('day', info.tag));
if (state.kind === 'day' && state.arg === info.tag) cell.classList.add('ov-selected');
cell.setAttribute('aria-label', `${info.tag}: ${n} devices, ${c3.done} completed, ${c3.doing} in progress, ${c3.todo} not started`);
}
grid.appendChild(cell);
}
month.appendChild(grid);
months.appendChild(month);
}
c.appendChild(months);
return c;
}
// ---------------------------------------------------------------------------------
// Drill-down list
// ---------------------------------------------------------------------------------
function openList(kind, arg) {
const same = state.kind === kind && JSON.stringify(state.arg) === JSON.stringify(arg);
state.kind = same ? null : kind;
state.arg = same ? null : arg;
render();
if (state.kind) {
const d = document.getElementById('ov-drill');
if (d) d.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
}
function renderList() {
const box = document.getElementById('ov-drill');
if (!box) return;
box.textContent = '';
if (!state.kind) { box.hidden = true; return; }
box.hidden = false;
const all = rows();
let title = '', items = [], detailLabel = 'Detail';
if (state.kind === 'issue') {
const issue = ISSUES.find(i => i.key === state.arg);
title = issue.label;
items = issue.pick(all);
if (issue.apps) return renderApps(box, title, items);
detailLabel = { offline: 'Offline for', notstarted: 'Source ping', lowdisk: 'Free space', errors: 'Last log line', missing: 'Missing from' }[issue.key] || 'Tags';
} else if (state.kind === 'stage') {
const s = STAGES.find(x => x.key === state.arg);
title = s.label;
items = all.filter(r => stageOf(r) === state.arg).map(r => ({ r, detail: statusOf(r) }));
detailLabel = 'Backup Status';
} else if (state.kind === 'status') {
title = state.arg;
items = all.filter(r => statusOf(r) === state.arg).map(r => ({ r, detail: r['Source Online'] || '' }));
detailLabel = 'Source ping';
} else if (state.kind === 'place') {
const { b, f } = state.arg;
title = f === null ? b : `${b}, ${f === '?' ? 'floor unknown' : 'floor ' + f}`;
items = all.filter(r => {
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 : '' }; });
detailLabel = 'Location';
} else if (state.kind === 'day') {
title = `Tag ${state.arg}`;
items = all.filter(r => tagsOf(r).includes(state.arg)).map(r => ({ r, detail: r['Source Online'] || '' }));
detailLabel = 'Source ping';
}
box.appendChild(listHead(title, items.length));
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);
const tb = el('tbody');
items.forEach(({ r, detail }) => {
const row = el('tr', 'clickable-row');
const td = (t, bold) => { const c = el('td'); if (bold) c.appendChild(el('b', null, t)); else c.textContent = t; c.title = t; return c; };
row.appendChild(td(r['Source Device'], true));
row.appendChild(td(r['Destination Device'] || 'N/A'));
row.appendChild(td(statusOf(r), true));
const dc = td(String(detail || ''));
// ping values colored like the USMT Status page
if (/^(Online|Timed Out|Not Found|DNS Issue)$/.test(String(detail || '')) && typeof getPingColor === 'function') { dc.style.color = getPingColor(detail); dc.style.fontWeight = 'bold'; }
row.appendChild(dc);
row.appendChild(td(r.Tags || 'None'));
// same row colors as the USMT Status page
try { const bg = themes[currentTheme].groupColors[r['Backup Status']]; if (bg) row.style.backgroundColor = bg; } catch (e) {}
row.onclick = () => showDeviceDetails(r['Source Device']);
tb.appendChild(row);
});
table.appendChild(tb);
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', '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')).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;
}
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 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');
// Source Devices is a list column: its filter lists every device name, like the software columns
[['App', '26%'], ['Devices', '90px'], ['Source Devices', ''], ['', '130px']].forEach(([h, w]) => { const th = el('th', null, h); if (w) th.style.width = w; tr.appendChild(th); });
thead.appendChild(tr); table.appendChild(thead);
const tb = el('tbody');
items.forEach(({ app, devs }) => {
const row = el('tr');
const a = el('td'); a.appendChild(el('b', null, app)); row.appendChild(a);
row.appendChild(el('td', null, String(devs.length)));
const d = el('td', 'ov-src-list', devs.slice(0, 8).join(', ') + (devs.length > 8 ? ` +${devs.length - 8} more` : ''));
d.title = devs.join(', ');
d.setAttribute('data-val', devs.join(', ')); // the filter and copy see every name
row.appendChild(d);
const act = el('td', 'no-copy');
const btn = el('button', 'ov-btn', 'Write article'); btn.type = 'button';
btn.onclick = () => {
switchPage('docs');
if (typeof createNewDoc === 'function') {
createNewDoc();
const t = document.getElementById('new-doc-input');
if (t) { t.value = app; t.focus(); }
}
};
act.appendChild(btn); row.appendChild(act);
tb.appendChild(row);
});
table.appendChild(tb);
box.appendChild(table);
if (window.ExcelFilter) ExcelFilter.refresh(table);
}
// ---------------------------------------------------------------------------------
// Wiring: render when shown, and when the data under it changes
// ---------------------------------------------------------------------------------
function visible() { const p = document.getElementById('overview'); return p && p.style.display !== 'none'; }
function signature() {
const a = g('apiData'), sc = g('softwareCache') || {}, docs = g('availableDocs') || [];
return [Array.isArray(a) ? a.length : 0, JSON.stringify(a || []).length, Object.keys(g('advancedDataCache') || {}).length,
Object.keys(sc).length, docs.length, g('globalDataLastUpdated'), g('currentTheme'), Math.floor(Date.now() / 300000)].join('|');
}
function maybeRender(force) {
if (!visible()) return;
const sig = signature();
if (!force && sig === lastSig) return;
lastSig = sig;
render();
}
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('usmt:data', () => { loaded = true; });
document.addEventListener('DOMContentLoaded', () => {
maybeRender(true);
setTimeout(() => { if (!loaded) { loaded = true; maybeRender(true); } }, 8000); // never stuck on the placeholder
const orig = window.switchPage;
if (typeof orig === 'function') {
window.switchPage = function (id) {
const r = orig.apply(this, arguments);
if (id === 'overview') maybeRender(true); else hideTip();
return r;
};
}
setInterval(() => maybeRender(false), 2000);
window.addEventListener('scroll', hideTip, { passive: true });
});
})();