Files
usmt-dash/web/device-info.js
T

243 lines
12 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 };
}
// 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 (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 `<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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[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(' &middot; ')}</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)} &middot; Sweep ${secs(ph.Sweep || 0)} &middot; Presence ${secs(ph.Presence || 0)} &middot; Save ${secs(ph.Save || 0)}</div>
${presence ? `<div class="sync-bd-phases">${presence}</div>` : ''}`;
};
})();