Overview landing page: issue counters, progress, status, locations, deployment calendar
This commit is contained in:
1 parent
7868948bfc
commit
514bc31566
4 files changed
+808
-2
No files matched your search
@@ -76,6 +76,7 @@ foreach ($pair in @(@("Accounting", "Accounting"), @("Sales Floor", "Sales"), @(
|
|||||||
if ($byDept.ContainsKey($pair[1])) { $tags[$pair[0]] = [System.Collections.Generic.List[string]]::new([string[]]@($byDept[$pair[1]] | Select-Object -First 12)) }
|
if ($byDept.ContainsKey($pair[1])) { $tags[$pair[0]] = [System.Collections.Generic.List[string]]::new([string[]]@($byDept[$pair[1]] | Select-Object -First 12)) }
|
||||||
}
|
}
|
||||||
$tags["Broken WMI"] = [System.Collections.Generic.List[string]]::new([string[]]@($pool | Where-Object { (Get-DemoRand $_ 'wmi') -lt 0.03 } | Select-Object -First 5))
|
$tags["Broken WMI"] = [System.Collections.Generic.List[string]]::new([string[]]@($pool | Where-Object { (Get-DemoRand $_ 'wmi') -lt 0.03 } | Select-Object -First 5))
|
||||||
|
$tags["Bad Client"] = [System.Collections.Generic.List[string]]::new([string[]]@($pool | Where-Object { (Get-DemoRand $_ 'badclient') -lt 0.03 } | Select-Object -First 4))
|
||||||
$tags["Public PCs"] = $skipPublic
|
$tags["Public PCs"] = $skipPublic
|
||||||
$tags["Laptops"] = $skipLaptops
|
$tags["Laptops"] = $skipLaptops
|
||||||
|
|
||||||
|
|||||||
+9
-2
@@ -352,8 +352,10 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<link rel="stylesheet" href="/layout.css">
|
<link rel="stylesheet" href="/layout.css">
|
||||||
|
<link rel="stylesheet" href="/overview.css">
|
||||||
<script src="/layout.js"></script>
|
<script src="/layout.js"></script>
|
||||||
<script src="/device-info.js"></script>
|
<script src="/device-info.js"></script>
|
||||||
|
<script src="/overview.js"></script>
|
||||||
<script src="/excel-filter.js" defer></script>
|
<script src="/excel-filter.js" defer></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -373,7 +375,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="nav-links" id="nav-links-menu">
|
<div class="nav-links" id="nav-links-menu">
|
||||||
<button class="nav-btn active" id="btn-tracked" onclick="switchPage('tracked-devices')">Tracked Devices</button>
|
<button class="nav-btn active" id="btn-overview" onclick="switchPage('overview')">Overview</button>
|
||||||
|
<button class="nav-btn" id="btn-tracked" onclick="switchPage('tracked-devices')">Tracked Devices</button>
|
||||||
<button class="nav-btn" id="btn-workbench" onclick="switchPage('workbench')">My Workbench</button>
|
<button class="nav-btn" id="btn-workbench" onclick="switchPage('workbench')">My Workbench</button>
|
||||||
<button class="nav-btn" id="btn-all" onclick="switchPage('all-devices')">USMT Status</button>
|
<button class="nav-btn" id="btn-all" onclick="switchPage('all-devices')">USMT Status</button>
|
||||||
<button class="nav-btn" id="btn-software" onclick="switchPage('software')">All Devices</button>
|
<button class="nav-btn" id="btn-software" onclick="switchPage('software')">All Devices</button>
|
||||||
@@ -389,7 +392,11 @@
|
|||||||
|
|
||||||
<div class="main">
|
<div class="main">
|
||||||
|
|
||||||
<div id="tracked-devices" class="page">
|
<div id="overview" class="page">
|
||||||
|
<div id="overview-root"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tracked-devices" class="page" style="display:none;">
|
||||||
<h1 style="margin-top: 0;">Tracked Devices</h1>
|
<h1 style="margin-top: 0;">Tracked Devices</h1>
|
||||||
<p style="color: var(--text-muted); margin-top:-15px;">Add devices here to register them with the server for active USMT tracking.</p>
|
<p style="color: var(--text-muted); margin-top:-15px;">Add devices here to register them with the server for active USMT tracking.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
+647
@@ -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 ? '<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')));
|
||||||
|
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 });
|
||||||
|
});
|
||||||
|
})();
|
||||||
Reference in new issue
Block a user