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.
694 lines
37 KiB
JavaScript
694 lines
37 KiB
JavaScript
/*
|
||
* 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 });
|
||
});
|
||
})();
|