+
+
+
+
+ Tracked Devices
Add devices here to register them with the server for active USMT tracking.
diff --git a/web/overview.css b/web/overview.css new file mode 100644 index 0000000..d905652 --- /dev/null +++ b/web/overview.css @@ -0,0 +1,151 @@ +/* ---------- Overview page ---------- */ +/* Chart colors: an ordinal blue ramp for migration stage (not started -> in progress -> + completed) and a sequential blue ramp for counts, each stepped separately for dark mode + (validated with the dataviz palette checker against the card surfaces). */ +#overview { + --ov-todo: #86b6ef; --ov-doing: #2a78d6; --ov-done: #104281; + --ov-bar: #2a78d6; --ov-track: rgba(127,127,127,0.12); + --ov-good: #0ca30c; --ov-serious: #ec835a; +} +[data-theme="dark"] #overview { + --ov-todo: #184f95; --ov-doing: #3987e5; --ov-done: #9ec5f4; + --ov-bar: #3987e5; --ov-track: rgba(255,255,255,0.07); +} +.ov-s0 { background: #cde2fb; color: #0b0b0b; } .ov-s1 { background: #b7d3f6; color: #0b0b0b; } +.ov-s2 { background: #9ec5f4; color: #0b0b0b; } .ov-s3 { background: #6da7ec; color: #0b0b0b; } +.ov-s4 { background: #3987e5; color: #fff; } .ov-s5 { background: #256abf; color: #fff; } +.ov-s6 { background: #184f95; color: #fff; } +[data-theme="dark"] .ov-s0 { background: #104281; color: #fff; } [data-theme="dark"] .ov-s1 { background: #184f95; color: #fff; } +[data-theme="dark"] .ov-s2 { background: #1c5cab; color: #fff; } [data-theme="dark"] .ov-s3 { background: #256abf; color: #fff; } +[data-theme="dark"] .ov-s4 { background: #3987e5; color: #fff; } [data-theme="dark"] .ov-s5 { background: #6da7ec; color: #0b0b0b; } +[data-theme="dark"] .ov-s6 { background: #9ec5f4; color: #0b0b0b; } + +#overview-root { max-width: 1500px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; } +.ov-title { margin: 0; color: var(--text-main); } +.ov-subtitle { margin: 4px 0 0; color: var(--text-muted); } +.ov-card { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 10px; padding: 18px 20px; min-width: 0; box-sizing: border-box; } +.ov-card-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; } +.ov-card-head h2 { margin: 0; font-size: 1.05em; color: var(--text-main); } +.ov-card-sub { color: var(--text-muted); font-size: 0.85em; } +.ov-head-btns { margin-left: auto; display: flex; gap: 8px; } +.ov-btn { border: 1px solid var(--border-color); background: transparent; color: var(--text-main); border-radius: 6px; padding: 5px 12px; cursor: pointer; font: inherit; font-size: 0.85em; } +.ov-btn:hover { border-color: #3498db; color: #3498db; } +.ov-note, .ov-empty-text { color: var(--text-muted); font-size: 0.85em; margin: 10px 0 0; } + +/* progress */ +.ov-progress { display: grid; grid-template-columns: auto 1fr; gap: 6px 28px; align-items: center; } +.ov-hero { grid-row: span 2; } +.ov-hero-num { font-size: 52px; font-weight: 700; line-height: 1; color: var(--text-main); font-variant-numeric: tabular-nums; } +.ov-hero-label { color: var(--text-muted); margin-top: 6px; font-size: 0.9em; } +.ov-stack { display: flex; gap: 2px; overflow: hidden; } +.ov-stack .ov-seg { min-width: 4px; cursor: pointer; } +.ov-stack .ov-seg:first-child { border-radius: 4px 0 0 4px; } +.ov-stack .ov-seg:last-child { border-radius: 0 4px 4px 0; } +.ov-stack .ov-seg:only-child { border-radius: 4px; } +.ov-stack .ov-seg:hover { filter: brightness(1.12); } +.ov-meter { height: 18px; } +.ov-legend { display: flex; flex-wrap: wrap; gap: 6px 16px; align-items: center; } +.ov-legend-item { display: inline-flex; align-items: center; gap: 6px; background: none; border: 0; padding: 2px 0; color: var(--text-muted); font: inherit; font-size: 0.88em; cursor: pointer; } +span.ov-legend-item { cursor: default; } +.ov-legend-item b { color: var(--text-main); } +.ov-legend-note { color: var(--text-muted); font-size: 0.82em; } +.ov-legend-small { margin-left: auto; } +.ov-key { display: inline-block; width: 12px; height: 12px; border-radius: 3px; flex: 0 0 12px; } + +/* issue tiles */ +.ov-issues { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; } +.ov-tile { background: var(--bg-card); border: 1px solid var(--border-color); border-radius: 10px; padding: 12px 14px; cursor: pointer; transition: border-color 0.15s, transform 0.15s; outline: none; } +.ov-tile:hover, .ov-tile:focus-visible { border-color: #3498db; transform: translateY(-1px); } +.ov-tile.ov-selected { border-color: #3498db; box-shadow: 0 0 0 2px rgba(52,152,219,0.35); } +.ov-tile-top { display: flex; align-items: center; gap: 7px; } +.ov-status-icon { display: inline-flex; width: 16px; height: 16px; flex: 0 0 16px; } +.ov-status-icon svg { width: 16px; height: 16px; } +.ov-has .ov-status-icon { color: var(--ov-serious); } +.ov-zero .ov-status-icon { color: var(--ov-good); } +.ov-tile-label { font-size: 0.85em; font-weight: 600; color: var(--text-main); line-height: 1.2; } +.ov-tile-num { font-size: 34px; font-weight: 700; color: var(--text-main); line-height: 1.1; margin-top: 8px; font-variant-numeric: tabular-nums; } +.ov-zero .ov-tile-num { color: var(--text-muted); } +.ov-tile-sub { font-size: 0.8em; color: var(--text-muted); margin-top: 2px; } + +.ov-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr); gap: 18px; } + +/* status bars */ +.ov-bars { display: flex; flex-direction: column; gap: 4px; } +.ov-bar-row { display: grid; grid-template-columns: 150px 1fr 44px; align-items: center; gap: 10px; padding: 5px 6px; border-radius: 6px; cursor: pointer; outline: none; } +.ov-bar-row:hover, .ov-bar-row:focus-visible { background: var(--ov-track); } +.ov-bar-label { font-size: 0.88em; color: var(--text-main); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.ov-bar-track { height: 14px; } +.ov-bar { display: block; height: 14px; background: var(--ov-bar); border-radius: 0 4px 4px 0; } +.ov-bar-value { text-align: right; font-weight: 700; color: var(--text-main); font-variant-numeric: tabular-nums; } + +/* heatmap */ +.ov-toggle { margin-left: auto; display: inline-flex; border: 1px solid var(--border-color); border-radius: 6px; overflow: hidden; } +.ov-toggle button { border: 0; background: transparent; color: var(--text-muted); padding: 5px 10px; font: inherit; font-size: 0.82em; cursor: pointer; } +.ov-toggle button.on { background: #3498db; color: #fff; } +.ov-heat-wrap { overflow-x: auto; } +table.ov-heat { border-collapse: separate; border-spacing: 2px; width: 100%; } +#overview table.ov-heat th { font-size: 0.8em; color: var(--text-muted); font-weight: 600; padding: 4px 6px; text-align: center; white-space: nowrap; background: none !important; position: static !important; border: 0; text-transform: none; } +#overview table.ov-heat tbody th { text-align: left; color: var(--text-main); font-size: 0.88em; } +table.ov-heat td { padding: 0; text-align: center; border: 0; background: none; } +.ov-heat-cell { display: flex; align-items: center; justify-content: center; min-width: 44px; height: 36px; border-radius: 4px; font-weight: 700; font-variant-numeric: tabular-nums; cursor: pointer; outline: none; } +.ov-heat-cell:hover, .ov-heat-cell:focus-visible { box-shadow: 0 0 0 2px var(--bg-card), 0 0 0 4px #3498db; } +.ov-heat-none { color: var(--text-muted); opacity: 0.5; } +.ov-heat-sum { font-weight: 700; color: var(--text-main); cursor: pointer; padding: 6px; border-radius: 4px; } +.ov-heat-sum:hover { background: var(--ov-track); } + +/* calendar */ +.ov-months { display: grid; grid-template-columns: repeat(auto-fill, minmax(330px, 1fr)); gap: 22px; } +.ov-month-name { font-weight: 700; color: var(--text-main); margin-bottom: 8px; } +.ov-cal { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 3px; } +.ov-dow { font-size: 0.72em; color: var(--text-muted); text-align: center; padding-bottom: 2px; } +.ov-day { position: relative; min-height: 50px; border-radius: 6px; background: var(--ov-track); padding: 4px 5px; box-sizing: border-box; display: flex; flex-direction: column; outline: none; } +.ov-day.ov-pad { background: none; } +.ov-day.ov-weekend:not(.ov-has-day) { opacity: 0.45; } +.ov-day-num { font-size: 0.72em; color: var(--text-muted); } +.ov-day-count { font-size: 1.05em; font-weight: 700; color: var(--text-main); margin-top: auto; font-variant-numeric: tabular-nums; } +.ov-day-bar { height: 6px; margin-top: 3px; } +.ov-has-day { cursor: pointer; background: var(--bg-card); border: 1px solid var(--border-color); } +.ov-has-day:hover, .ov-has-day:focus-visible, .ov-day.ov-selected { border-color: #3498db; box-shadow: 0 0 0 1px #3498db; } +.ov-today { box-shadow: inset 0 0 0 2px #3498db; } +.ov-today .ov-day-num { color: #3498db; font-weight: 700; } + +/* drill-down */ +.ov-drill[hidden] { display: none; } +.ov-table-wrap { max-height: 520px; overflow: auto; border-top: none; } + +/* tooltip */ +.ov-tip { position: fixed; z-index: 4000; display: none; pointer-events: none; background: var(--bg-card); color: var(--text-main); border: 1px solid var(--border-color); border-radius: 8px; padding: 8px 10px; box-shadow: 0 6px 20px rgba(0,0,0,0.25); font-size: 0.85em; max-width: 260px; } +.ov-tip-value { font-weight: 700; font-size: 1.15em; } +.ov-tip-label { color: var(--text-muted); margin-bottom: 4px; } +.ov-tip-row { display: flex; align-items: center; gap: 6px; } +.ov-tip-row .ov-key { width: 10px; height: 3px; border-radius: 2px; flex-basis: 10px; } + +@media (max-width: 1100px) { + .ov-grid { grid-template-columns: minmax(0, 1fr); } +} +@media (max-width: 900px) { + .ov-issues { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 700px) { + .ov-progress { grid-template-columns: 1fr; } + .ov-hero { grid-row: auto; } + .ov-hero-num { font-size: 44px; } + .ov-issues { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; } + .ov-tile { padding: 10px; } + .ov-tile-num { font-size: 28px; } + .ov-card { padding: 14px; } + .ov-bar-row { grid-template-columns: 112px 1fr 36px; gap: 6px; } + .ov-months { grid-template-columns: minmax(0, 1fr); } + .ov-day { min-height: 44px; padding: 3px; } + .ov-legend-small { margin-left: 0; } + .ov-head-btns { margin-left: 0; } +} +@media (max-width: 700px) { + #overview table.ov-heat th { padding: 4px 3px; font-size: 0.72em; } + #overview table.ov-heat tbody th { font-size: 0.78em; padding-right: 6px; } + .ov-heat-cell { min-width: 34px; height: 34px; } + .ov-bar-row { grid-template-columns: 128px 1fr 32px; } + .ov-bar-label { font-size: 0.82em; } +} +/* the drill-down list scrolls inside its card, so its header sticks to the card's top */ +#overview .ov-table thead th { top: 0 !important; z-index: 2; } diff --git a/web/overview.js b/web/overview.js new file mode 100644 index 0000000..bf25b0d --- /dev/null +++ b/web/overview.js @@ -0,0 +1,647 @@ +/* + * 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); + 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'))); + 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'); + 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', 'ov-card 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 devices']].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); + + const cells = {}, buildings = new Set(), floors = new Set(); + let unknown = 0; + all.forEach(r => { + if (state.map === 'left' && (stageOf(r) === 'done' || stageOf(r) === null)) return; + 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 max = Math.max(1, ...Object.values(cells).map(v => v.length)); + const wrap = el('div', 'ov-heat-wrap'); + const table = el('table', 'ov-heat'); + const thead = el('thead'), hr = el('tr'); + hr.appendChild(el('th', 'ov-heat-corner', 'Building')); + fList.forEach(f => hr.appendChild(el('th', null, f === '?' ? 'Unknown' : 'Floor ' + f))); + hr.appendChild(el('th', 'ov-heat-total', 'Total')); + thead.appendChild(hr); table.appendChild(thead); + const tb = el('tbody'); + bList.forEach(b => { + const tr = el('tr'); + tr.appendChild(el('th', null, b)); + let total = 0; + fList.forEach(f => { + const list = cells[b + '|' + f] || []; + total += list.length; + const td = el('td'); + if (list.length) { + const cell = el('span', 'ov-heat-cell', String(list.length)); + const t = list.length / max; + // sequential blue: 7 steps, light -> dark; text flips to white on dark steps + const step = Math.min(6, Math.floor(t * 6.999)); + cell.classList.add('ov-s' + step); + const c3 = { todo: 0, doing: 0, done: 0 }; + list.forEach(r => { const s = stageOf(r); if (s) c3[s]++; }); + hover(cell, e => showTip(e, String(list.length), `${b}, ${f === '?' ? 'floor unknown' : 'floor ' + f}`, + STAGES.filter(s => c3[s.key]).map(s => [s.key, `${c3[s.key]} ${s.label.toLowerCase()}`]))); + clickable(cell, () => openList('place', { b, f })); + td.appendChild(cell); + } else td.appendChild(el('span', 'ov-heat-none', '·')); + tr.appendChild(td); + }); + const tt = el('td', 'ov-heat-total'); + const tbtn = el('span', 'ov-heat-sum', String(total)); + 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 note = el('p', 'ov-note', (state.map === 'left' ? 'Devices not yet completed, by source location. ' : 'Every tracked source, by location. ') + + 'The stronger the shade, the more devices.' + (unknown ? ` ${plural(unknown, 'device')} without a building yet.` : '')); + 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 (state.map === 'left' && (stageOf(r) === 'done' || stageOf(r) === null)) 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', 'Nothing here. That one is at zero.')); return; } + const wrap = el('div', 'table-wrapper ov-table-wrap'); + const table = el('table', 'master-table ov-table'); + const 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))); + row.appendChild(td(String(detail || ''))); + row.appendChild(td(r.Tags || 'None')); + row.onclick = () => showDeviceDetails(r['Source Device']); + tb.appendChild(row); + }); + table.appendChild(tb); + wrap.appendChild(table); + box.appendChild(wrap); + } + + function listHead(title, n) { + const h = el('div', 'ov-card-head'); + h.appendChild(el('h2', null, `${title} (${n})`)); + const copy = el('button', 'ov-btn', 'Copy names'); copy.type = 'button'; + copy.onclick = () => { + const names = Array.from(document.querySelectorAll('#ov-drill tbody tr td:first-child')).map(td => td.textContent.trim()); + 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); + 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', 'Specialty apps found on source devices that have no knowledge-base article with the same name. Write one so techs know how to install it.')); + const wrap = el('div', 'table-wrapper ov-table-wrap'); + const table = el('table', 'master-table ov-table'); + const thead = el('thead'), tr = el('tr'); + ['App', 'Devices', 'Sources with it', ''].forEach(h => tr.appendChild(el('th', null, h))); + thead.appendChild(tr); table.appendChild(thead); + 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', null, devs.slice(0, 6).join(', ') + (devs.length > 6 ? ` +${devs.length - 6} more` : '')); d.title = devs.join(', '); + 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); + wrap.appendChild(table); + box.appendChild(wrap); + } + + // --------------------------------------------------------------------------------- + // 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'), 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) }; + + 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 }); + }); +})();