/*
* 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 = '';
// ---------------------------------------------------------------------------------
// 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;
}
function render() {
const root = document.getElementById('overview-root');
if (!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 ? ''
: '';
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 cell = el('div', 'ov-day' + (date.getTime() === today.getTime() ? ' ov-today' : '') + (date.getDay() % 6 === 0 ? ' ov-weekend' : ''));
cell.appendChild(el('span', 'ov-day-num', String(d)));
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('DOMContentLoaded', () => {
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 });
});
})();