/* * 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 = '

Overview

Loading the fleet...

' + '
'; } 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 ? '' : ''; 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 }); }); })();