- Every nav page has its own address (/tracked, /status, /devices, ...); the logo and name go to the Overview; back/forward work. - List filters gain an "Only ticked" mode; software columns get a one-click "devices with only documented apps" pick and an easiest-first sort. - Overview: the device list uses the dashboard's table header (sticky, with Excel filters); what opened it is clearly highlighted; locations colored by how much is done (red to green); stages read red / amber / green. - Details: one-line title bar; presence and monitors only in Advanced View. - Presence sweep pings only completed pairs (everything else is pinged by the sweep itself), hourly and at midnight. - Custom View scrollbar smaller, in the table header blue. - Docs: no sideways scroll in the article list; no blank lines around code.
253 lines
13 KiB
JavaScript
253 lines
13 KiB
JavaScript
/*
|
|
* USMT Dash - device info helpers
|
|
*
|
|
* Shared by the details dialog, Custom View, the tables and the Overview page:
|
|
* - Advanced fields say WHY a value is missing ("Not in AD", "Not in SCCM", "No DNS record")
|
|
* instead of a bare N/A, and "Waiting for worker..." when the worker hasn't looked yet.
|
|
* - Presence: last time a device answered a ping and how long it has been offline.
|
|
* - Snapshot markers: values the worker saved while a device was reachable are shown with
|
|
* a clock icon and the time they were captured.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const WAITING = 'Waiting for worker...';
|
|
const AD_FIELDS = new Set(['OU', 'Groups']);
|
|
// Fields computed here rather than stored by the worker (also offered as Custom View columns)
|
|
const COMPUTED = {
|
|
DaysOffline: 'Days Offline',
|
|
LastOnline: 'Last Online',
|
|
LastChecked: 'Last Ping Check',
|
|
FoundIn: 'Found In (AD / SCCM / DNS)'
|
|
};
|
|
// Internal bookkeeping fields that aren't useful as columns
|
|
const HIDDEN = new Set(['InAD', 'InSCCM', 'InDNS', 'Fetched', 'LastPing', 'FirstChecked', 'LastOnlineSource']);
|
|
|
|
const cache = () => (typeof advancedDataCache !== 'undefined' && advancedDataCache) ? advancedDataCache : {};
|
|
const isName = n => !!n && n !== 'N/A' && n !== '---';
|
|
const empty = v => v === undefined || v === null || v === '' || v === 'N/A';
|
|
|
|
function entry(name) {
|
|
if (!isName(name)) return null;
|
|
const c = cache();
|
|
return c[name] || c[String(name).toUpperCase()] || null;
|
|
}
|
|
|
|
function toDate(iso) {
|
|
if (!iso) return null;
|
|
const d = new Date(iso);
|
|
return isNaN(d) ? null : d;
|
|
}
|
|
|
|
function fmtWhen(iso) {
|
|
const d = toDate(iso);
|
|
if (!d) return '';
|
|
const sameYear = d.getFullYear() === new Date().getFullYear();
|
|
return d.toLocaleString(undefined, { month: 'short', day: 'numeric', year: sameYear ? undefined : 'numeric', hour: 'numeric', minute: '2-digit' });
|
|
}
|
|
|
|
function ago(iso) {
|
|
const d = toDate(iso);
|
|
if (!d) return '';
|
|
const s = Math.max(0, (Date.now() - d.getTime()) / 1000);
|
|
if (s < 60) return 'just now';
|
|
if (s < 3600) return Math.floor(s / 60) + ' min ago';
|
|
if (s < 86400) return Math.floor(s / 3600) + ' h ago';
|
|
const days = Math.floor(s / 86400);
|
|
return days + (days === 1 ? ' day ago' : ' days ago');
|
|
}
|
|
|
|
// Why a field is empty, from the worker's lookup flags (null = no particular reason)
|
|
function missingReason(a, key) {
|
|
const noAD = a.InAD === false, noSCCM = a.InSCCM === false, noDNS = a.InDNS === false;
|
|
if (noAD && noSCCM && noDNS) return 'Not in AD, SCCM or DNS';
|
|
if (AD_FIELDS.has(key)) return noAD ? 'Not in AD' : null;
|
|
if (key === 'IP') {
|
|
if (noDNS && noSCCM) return 'No DNS record, not in SCCM';
|
|
if (noDNS) return 'No DNS record';
|
|
return null;
|
|
}
|
|
return noSCCM ? 'Not in SCCM' : null;
|
|
}
|
|
|
|
// { text, state } where state is value | waiting | missing | na
|
|
function field(name, key) {
|
|
if (!isName(name)) return { text: 'N/A', state: 'na' };
|
|
if (COMPUTED[key]) return computed(name, key);
|
|
const a = entry(name);
|
|
if (!a) return { text: WAITING, state: 'waiting' };
|
|
const v = a[key];
|
|
// the worker's default room type isn't real data when SCCM has no record
|
|
if (key === 'RoomType' && a.InSCCM === false && v === 'Standard') return { text: missingReason(a, key), state: 'missing' };
|
|
if (!empty(v)) return { text: String(v), state: 'value' };
|
|
const why = missingReason(a, key);
|
|
return why ? { text: why, state: 'missing' } : { text: 'N/A', state: 'na' };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Presence
|
|
// ---------------------------------------------------------------------------------
|
|
function presence(name) {
|
|
const a = entry(name);
|
|
if (!a || !a.LastChecked) return null;
|
|
const last = toDate(a.LastOnline);
|
|
const online = a.LastPing === 'Online';
|
|
const days = online ? 0 : (last ? (Date.now() - last.getTime()) / 86400000 : null);
|
|
return { online, last, lastIso: a.LastOnline, checked: a.LastChecked, ping: a.LastPing, first: a.FirstChecked, days, source: a.LastOnlineSource };
|
|
}
|
|
|
|
// Skip USMT tags are inventory only: the worker never pings them
|
|
function notPinged(name) {
|
|
try {
|
|
const rows = typeof apiData !== 'undefined' && Array.isArray(apiData) ? apiData : [];
|
|
return rows.some(r => r['Source Device'] === name && r.Pairing === 'Skipped');
|
|
} catch (e) { return false; }
|
|
}
|
|
const NOT_PINGED = 'Not pinged (Skip USMT tag)';
|
|
|
|
// Whole days offline (0 when online or offline less than a day), null if never seen
|
|
function daysOffline(name) {
|
|
const p = presence(name);
|
|
if (!p) return undefined;
|
|
if (p.online) return 0;
|
|
if (p.days === null) return null;
|
|
return Math.floor(p.days);
|
|
}
|
|
|
|
function offlineText(name) {
|
|
if (!isName(name)) return 'N/A';
|
|
const p = presence(name);
|
|
if (!p) return WAITING;
|
|
if (p.online) return 'Online now';
|
|
if (!p.last) return 'Never seen online (tracked since ' + fmtWhen(p.first) + ')';
|
|
const hours = (Date.now() - p.last.getTime()) / 3600000;
|
|
if (hours < 1) return 'Offline less than an hour';
|
|
if (hours < 24) return 'Offline ' + Math.floor(hours) + ' h';
|
|
const d = Math.floor(hours / 24);
|
|
return d + (d === 1 ? ' day' : ' days') + ' offline';
|
|
}
|
|
|
|
function computed(name, key) {
|
|
const a = entry(name);
|
|
if (!a) return { text: WAITING, state: 'waiting' };
|
|
if (['DaysOffline', 'LastOnline', 'LastChecked'].includes(key) && !a.LastChecked && notPinged(name)) return { text: NOT_PINGED, state: 'na' };
|
|
if (key === 'DaysOffline') {
|
|
const d = daysOffline(name);
|
|
if (d === undefined) return { text: WAITING, state: 'waiting' };
|
|
if (d === null) return { text: 'Never seen', state: 'value' };
|
|
if (d === 0) return { text: presence(name).online ? 'Online' : '< 1 day', state: 'value' };
|
|
return { text: d + (d === 1 ? ' day' : ' days'), state: 'value' };
|
|
}
|
|
if (key === 'LastOnline') {
|
|
const p = presence(name);
|
|
if (!p) return { text: WAITING, state: 'waiting' };
|
|
if (p.online) return { text: 'Online now', state: 'value' };
|
|
return p.last ? { text: fmtWhen(p.lastIso), state: 'value' } : { text: 'Never seen', state: 'value' };
|
|
}
|
|
if (key === 'LastChecked') {
|
|
return a.LastChecked ? { text: fmtWhen(a.LastChecked) + ' (' + (a.LastPing || '?') + ')', state: 'value' } : { text: WAITING, state: 'waiting' };
|
|
}
|
|
if (key === 'FoundIn') {
|
|
if (a.InAD === undefined && a.InSCCM === undefined && a.InDNS === undefined) return { text: WAITING, state: 'waiting' };
|
|
const yes = [], no = [];
|
|
[['AD', a.InAD], ['SCCM', a.InSCCM], ['DNS', a.InDNS]].forEach(([n, v]) => { if (v === true) yes.push(n); else if (v === false) no.push(n); });
|
|
if (!no.length) return { text: 'AD, SCCM, DNS', state: 'value' };
|
|
return { text: 'Missing from ' + no.join(', '), state: 'missing' };
|
|
}
|
|
return { text: 'N/A', state: 'na' };
|
|
}
|
|
|
|
// True when AD, SCCM and DNS all said "never heard of it" (not imaged yet, or a typo)
|
|
function unknownEverywhere(name) {
|
|
const a = entry(name);
|
|
return !!a && a.InAD === false && a.InSCCM === false && a.InDNS === false;
|
|
}
|
|
|
|
// HTML for a details-dialog cell
|
|
function html(name, key) {
|
|
const f = field(name, key);
|
|
if (f.state === 'waiting') return `<span class="di-waiting">${f.text}</span>`;
|
|
if (f.state === 'missing') return `<span class="di-missing">${f.text}</span>`;
|
|
return f.text;
|
|
}
|
|
|
|
function presenceHtml(name) {
|
|
if (!isName(name)) return 'N/A';
|
|
const p = presence(name);
|
|
if (!p) return notPinged(name) ? `<span class="di-sub">${NOT_PINGED}</span>` : `<span class="di-waiting">${WAITING}</span>`;
|
|
const txt = offlineText(name);
|
|
const cls = p.online ? 'di-online' : (p.days !== null && p.days >= 3 ? 'di-offline-long' : 'di-offline');
|
|
let sub = '';
|
|
if (!p.online && p.last) sub = `Last online ${fmtWhen(p.lastIso)}${p.source === 'Inventory' ? ' (from client activity)' : ''}<br>`;
|
|
sub += `Last checked ${fmtWhen(p.checked)} (${ago(p.checked)})`;
|
|
return `<b class="${cls}">${txt}</b><br><span class="di-sub">${sub}</span>`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------
|
|
// Snapshot / staleness markers
|
|
// ---------------------------------------------------------------------------------
|
|
const CLOCK = '<svg viewBox="0 0 16 16" aria-hidden="true"><circle cx="8" cy="8" r="6.5" fill="none" stroke="currentColor" stroke-width="1.6"/><path d="M8 4.5V8l2.5 1.6" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"/></svg>';
|
|
|
|
// side: 'Source' | 'Destination'
|
|
function snapshotMark(row, side) {
|
|
const at = row && row[side + ' Snapshot'];
|
|
if (!at) return '';
|
|
const why = row['Backup Status'] === 'Completed' ? 'migration completed' : 'device unreachable';
|
|
return ` <span class="snap-mark" title="Last captured ${fmtWhen(at)} (${why})">${CLOCK}</span>`;
|
|
}
|
|
|
|
// Ping results older than 15 minutes (completed pairs are only re-pinged by the presence sweep)
|
|
function pingMark(row, side) {
|
|
const at = row && row[side + ' Checked'];
|
|
const d = toDate(at);
|
|
if (!d || Date.now() - d.getTime() < 15 * 60000) return '';
|
|
return ` <span class="snap-mark" title="Checked ${fmtWhen(at)}">${CLOCK}</span>`;
|
|
}
|
|
|
|
function snapshotLine(row, side) {
|
|
const at = row && row[side + ' Snapshot'];
|
|
if (!at) return '';
|
|
return `<br><span class="di-sub">${CLOCK.replace('<svg', '<svg class="di-inline-clock"')} Snapshot from ${fmtWhen(at)} (${ago(at)})</span>`;
|
|
}
|
|
|
|
// Software inventory timestamp for a device (Software-Cache.json entries carry one)
|
|
window.DeviceInfo = {
|
|
WAITING, COMPUTED, HIDDEN,
|
|
entry, field, html, unknownEverywhere, presence, presenceHtml, daysOffline, offlineText,
|
|
snapshotMark, snapshotLine, pingMark, fmtWhen, ago, isName
|
|
};
|
|
})();
|
|
|
|
// Data Synchronization dialog: per-type timing of the last cycle (SCCM associations vs
|
|
// manual pairs vs completed snapshots...), the phases, and the presence sweep schedule.
|
|
(function () {
|
|
'use strict';
|
|
const esc = v => String(v).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
|
const secs = s => (s >= 60 ? (s / 60).toFixed(1) + ' min' : (+s).toFixed(1) + ' s');
|
|
const per = ms => (ms >= 1000 ? (ms / 1000).toFixed(1) + ' s' : ms + ' ms');
|
|
const HINT = {
|
|
SCCM: 'Association lookup, ping + disk + log on both devices',
|
|
Manual: 'No SCCM lookup: ping both devices only',
|
|
Cached: 'Completed: shown from its snapshot, no network checks',
|
|
NoAssoc: 'No association: source checked on its own',
|
|
Skipped: 'Inventory only, no network checks'
|
|
};
|
|
window.DeviceInfo.syncBreakdownHtml = function (state) {
|
|
const c = state && state.LastCycle;
|
|
if (!c || !Array.isArray(c.Categories)) return '';
|
|
const rows = c.Categories.map(x => `<tr title="${esc(HINT[x.Key] || '')}"><td>${esc(x.Label)}</td><td>${x.Count}</td><td>${secs(x.Seconds)}</td><td>${per(x.AvgMs)}</td></tr>`).join('');
|
|
const ph = c.Phases || {};
|
|
const ps = c.PresenceSweep || {};
|
|
let presence = '';
|
|
if (ps.Last) {
|
|
const next = new Date(new Date(ps.Last).getTime() + (ps.EveryMinutes || 60) * 60000);
|
|
presence = `Presence sweep: last ${DeviceInfo.fmtWhen(ps.Last)}${ps.Ran ? ` (pinged ${ps.Pinged} more)` : ''}, next after ${next.toLocaleTimeString(undefined, { hour: 'numeric', minute: '2-digit' })}`;
|
|
}
|
|
return `
|
|
<div class="sync-bd-title">Last cycle by type <span>${[c.NewFirst ? `${c.NewFirst} new device(s) swept first` : '', c.FullRefresh ? 'full refresh' : ''].filter(Boolean).join(' · ')}</span></div>
|
|
<table class="sync-bd"><thead><tr><th>Type</th><th>Devices</th><th>Time</th><th>Avg</th></tr></thead><tbody>${rows}</tbody></table>
|
|
<div class="sync-bd-phases">Software ${secs(ph.Software || 0)} · Sweep ${secs(ph.Sweep || 0)} · Presence ${secs(ph.Presence || 0)} · Save ${secs(ph.Save || 0)}</div>
|
|
${presence ? `<div class="sync-bd-phases">${presence}</div>` : ''}`;
|
|
};
|
|
})();
|