Files
usmt-dash/web/demo/demo.js
T
Johan Sandoval 9dc2d88726 Live updates during a cycle; Filter & sort dropdown on phones; layout polish
- Worker publishes the rows swept so far every few seconds (LivePublishSeconds)
  with a DataVersion stamp; the dashboard reloads on each new version, so
  tables, the Overview and open dialogs update mid-cycle. Shared files are
  written to a temp file and swapped in, so readers never see half a file.
- Phones / tablets: one Filter & sort button opens a column list instead of a
  row of chips, on every table.
- Details: a wrapped Simple / Advanced toggle sits on the left.
- Card fields: fixed-height dialog; field list and add list share it evenly.
2026-10-06 21:33:29 +00:00

1046 lines
56 KiB
JavaScript

/*
* USMT Dash - terminal panel + demo sandbox
*
* Loaded before the dashboard's own script. It does two things:
*
* 1. Terminal panel (Live and Demo mode). Streams the real console output of
* USMT-Server.ps1 and USMT-Worker.ps1 from the server's /get-logs route.
*
* 2. Demo sandbox (Demo mode only). The public demo's API is read-only, so every request
* that would change something (tracking tags, docs, images, force refresh) is handled
* here instead, in the visitor's own browser (IndexedDB). Reads are answered from the
* real server merged with the visitor's changes, and the terminals show what the real
* services would have printed. Nothing a visitor does is ever sent to the server or
* seen by anyone else.
*
* In Live mode window.fetch is passed straight through untouched.
*/
(function () {
'use strict';
const realFetch = window.fetch.bind(window);
const configReady = realFetch('/get-config', { cache: 'no-store' })
.then(r => (r.ok ? r.json() : null))
.catch(() => null)
.then(c => c || { Mode: 'Live', Demo: false, Terminals: false });
// =====================================================================================
// Helpers
// =====================================================================================
const sleep = ms => new Promise(r => setTimeout(r, ms));
const clone = o => JSON.parse(JSON.stringify(o));
const jsonResponse = (obj, status = 200) => new Response(JSON.stringify(obj), { status, headers: { 'Content-Type': 'application/json' } });
const textResponse = (txt, status = 200, headers = {}) => new Response(txt, { status, headers });
const pad2 = n => String(n).padStart(2, '0');
function psTime(d = new Date()) { // 03:01:43 PM
let h = d.getHours(); const ampm = h >= 12 ? 'PM' : 'AM'; h = h % 12 || 12;
return `${pad2(h)}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())} ${ampm}`;
}
function psStamp(d = new Date()) { // 10/06/26 03:01 PM
let h = d.getHours(); const ampm = h >= 12 ? 'PM' : 'AM'; h = h % 12 || 12;
return `${pad2(d.getMonth() + 1)}/${pad2(d.getDate())}/${String(d.getFullYear()).slice(-2)} ${pad2(h)}:${pad2(d.getMinutes())} ${ampm}`;
}
// Small deterministic hash (FNV-1a + murmur finalizer) for visitor-added devices
function hash(str) {
let h = 0x811c9dc5;
for (const ch of String(str).toUpperCase()) { h ^= ch.charCodeAt(0); h = Math.imul(h, 0x01000193); }
h ^= h >>> 16; h = Math.imul(h, 0x85ebca6b); h ^= h >>> 13; h = Math.imul(h, 0xc2b2ae35); h ^= h >>> 16;
return h >>> 0;
}
const rand = (name, salt) => hash(name + '|' + salt) / 4294967296;
const ALPHA = '0123456789ABCDEFGHJKLMNPQRSTUVWXYZ';
const serial = seed => Array.from({ length: 8 }, (_, i) => ALPHA[Math.floor(rand(seed, 's' + i) * ALPHA.length)]).join('');
const hex = (seed, n) => Array.from({ length: n }, (_, i) => Math.floor(rand(seed, 'h' + i) * 256).toString(16).toUpperCase().padStart(2, '0'));
// =====================================================================================
// Terminal panel
// =====================================================================================
const TERMINALS = [
{ id: 'server', title: 'USMT-Server.ps1' },
{ id: 'worker', title: 'USMT-Worker.ps1' }
];
const MAX_LINES = 2500;
const Term = {
el: null, open: false, active: 'worker', demo: false,
state: {}, // id -> { lastId, body, hold, held[], unread }
timer: null,
init(demo) {
this.demo = demo;
TERMINALS.forEach(t => { this.state[t.id] = { lastId: 0, body: null, hold: false, held: [], loaded: false }; });
this.build();
try { if (localStorage.getItem('usmt_term_open') === '1') this.toggle(true); } catch (e) {}
try { const tab = localStorage.getItem('usmt_term_tab'); if (tab && this.state[tab]) this.select(tab); } catch (e) {}
this.loop();
},
build() {
const dock = document.createElement('div');
dock.id = 'usmt-term';
dock.className = 'usmt-term collapsed';
dock.innerHTML = `
<div class="usmt-term-resize" title="Drag to resize"></div>
<div class="usmt-term-bar">
<button class="usmt-term-toggle" type="button" title="Show / hide terminals">
<svg class="usmt-term-chevron" viewBox="0 0 10 10" aria-hidden="true"><path d="M1.5 6.5 5 3l3.5 3.5" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg><span>Terminals</span>
</button>
<div class="usmt-term-tabs"></div>
<span class="usmt-term-live" title="Streaming the real PowerShell console output from the server"><i></i>LIVE</span>
</div>
<div class="usmt-term-window">
<div class="usmt-term-titlebar">
<span class="usmt-term-wtitle"></span>
${this.demo ? '<span class="usmt-term-legend" title="Lines marked with a dot were produced by your own actions. They are simulated in your browser and never reach the real services."><i class="usmt-term-legend-dot"></i><span class="t-long">your actions&nbsp;</span>(simulated)</span>' : ''}
<button class="usmt-term-btn usmt-term-follow active" type="button" title="Keep scrolled to the newest output">Follow</button>
</div>
<div class="usmt-term-screens"></div>
</div>`;
document.body.appendChild(dock);
this.el = dock;
const tabs = dock.querySelector('.usmt-term-tabs');
const screens = dock.querySelector('.usmt-term-screens');
TERMINALS.forEach(t => {
const b = document.createElement('button');
b.type = 'button'; b.className = 'usmt-term-tab'; b.dataset.id = t.id; b.title = t.title;
// "USMT-Server.ps1" on wide screens, just "Server" on phones
const m = t.title.match(/^(USMT-)(.+)(\.ps1)$/);
if (m) b.innerHTML = `<span class="t-long">${m[1]}</span>${m[2]}<span class="t-long">${m[3]}</span>`;
else b.textContent = t.title;
b.onclick = () => { this.select(t.id); if (!this.open) this.toggle(true); };
tabs.appendChild(b);
const body = document.createElement('div');
body.className = 'usmt-term-body'; body.dataset.id = t.id;
screens.appendChild(body);
this.state[t.id].body = body;
});
dock.querySelector('.usmt-term-toggle').onclick = () => this.toggle();
const follow = dock.querySelector('.usmt-term-follow');
follow.onclick = () => { follow.classList.toggle('active'); this.scroll(true); };
this.select(this.active);
// drag-to-resize
const handle = dock.querySelector('.usmt-term-resize');
handle.addEventListener('mousedown', e => {
e.preventDefault();
const startY = e.clientY, startH = dock.querySelector('.usmt-term-window').offsetHeight;
const move = ev => {
const h = Math.max(140, Math.min(window.innerHeight - 140, startH + (startY - ev.clientY)));
dock.style.setProperty('--usmt-term-h', h + 'px');
};
const up = () => { document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up); };
document.addEventListener('mousemove', move); document.addEventListener('mouseup', up);
});
},
toggle(force) {
this.open = force === undefined ? !this.open : force;
this.el.classList.toggle('collapsed', !this.open);
document.body.classList.toggle('usmt-term-open', this.open);
try { localStorage.setItem('usmt_term_open', this.open ? '1' : '0'); } catch (e) {}
if (this.open) { this.poll(this.active); this.scroll(true); }
},
select(id) {
this.active = id;
this.el.querySelectorAll('.usmt-term-tab').forEach(b => b.classList.toggle('active', b.dataset.id === id));
this.el.querySelectorAll('.usmt-term-body').forEach(b => b.classList.toggle('active', b.dataset.id === id));
const t = TERMINALS.find(x => x.id === id);
const wtitle = this.el.querySelector('.usmt-term-wtitle');
wtitle.innerHTML = '<span class="t-long"></span><span></span>';
wtitle.firstChild.textContent = this.demo ? 'Administrator: PowerShell (demo container) - ' : 'Administrator: PowerShell - ';
wtitle.lastChild.textContent = t.title;
this.el.querySelector(`.usmt-term-tab[data-id="${id}"]`).classList.remove('unread');
try { localStorage.setItem('usmt_term_tab', id); } catch (e) {}
this.poll(id);
this.scroll(true);
},
following() { return this.el.querySelector('.usmt-term-follow').classList.contains('active'); },
scroll(force) {
const body = this.state[this.active].body;
if (force || this.following()) body.scrollTop = body.scrollHeight;
},
// segs: [[text, ConsoleColor], ...]
render(id, lines, simulated) {
const st = this.state[id];
const frag = document.createDocumentFragment();
for (const ln of lines) {
const row = document.createElement('div');
row.className = 'usmt-term-line' + (simulated ? ' sim' : '');
if (ln.t) row.title = ln.t;
const segs = ln.s || [];
if (segs.length === 0) row.innerHTML = '&nbsp;';
for (const [text, color] of segs) {
const span = document.createElement('span');
span.className = 'c-' + (color || 'Default');
span.textContent = text;
row.appendChild(span);
}
frag.appendChild(row);
}
st.body.appendChild(frag);
while (st.body.childNodes.length > MAX_LINES) st.body.removeChild(st.body.firstChild);
if (id === this.active && this.open) this.scroll();
else if (lines.length) { const tab = this.el.querySelector(`.usmt-term-tab[data-id="${id}"]`); if (tab) tab.classList.add('unread'); }
},
// Simulated output from the visitor's own actions (demo mode)
write(id, segs) {
if (!this.el) return;
this.render(id, [{ s: segs, t: new Date().toLocaleTimeString() }], true);
},
// While a simulated worker cycle is printing, real worker lines are held back so the
// two don't interleave mid-cycle; they're flushed when the simulated cycle ends.
hold(id, on) {
const st = this.state[id];
if (!st) return;
st.hold = on;
if (!on && st.held.length) {
// Live output that arrived while a simulated cycle was printing
this.render(id, [{ s: [[`--- live output resumed: ${st.held.length} line(s) from the real worker arrived during your simulated cycle ---`, 'DarkGray']] }], true);
this.render(id, st.held, false);
st.held = [];
}
},
async poll(id) {
const st = this.state[id];
if (st.busy) return;
st.busy = true;
try {
const r = await realFetch(`/get-logs?source=${id}&after=${st.lastId}&t=${Date.now()}`, { cache: 'no-store' });
if (!r.ok) return;
const data = await r.json();
const lines = (data.lines || []).filter(l => l.i > st.lastId);
if (!lines.length) return;
st.lastId = lines[lines.length - 1].i;
if (!st.loaded) {
st.loaded = true;
if (lines[0].s && lines[0].s[0] && lines[0].s[0][1] !== 'Prompt') {
lines.unshift({ s: [['... (earlier output trimmed) ...', 'DarkGray']] });
}
}
if (st.hold) st.held.push(...lines); else this.render(id, lines, false);
} catch (e) {
} finally { st.busy = false; }
},
async loop() {
let tick = 0;
for (;;) {
if (this.open) {
await this.poll(this.active);
if (tick % 3 === 0) for (const t of TERMINALS) if (t.id !== this.active) await this.poll(t.id);
}
tick++;
await sleep(this.open ? 1500 : 4000);
}
}
};
// =====================================================================================
// Demo sandbox: per-visitor state in IndexedDB
// =====================================================================================
const Store = {
db: null,
state: null,
empty() {
return {
version: 1,
tracked: null, // visitor's copy of Tracked-Devices.json (null = untouched)
appliedTracked: null, // roster the (simulated) worker last swept
rev: 0, // bumps whenever simulated data changes
docs: {}, // title -> { content, modified }
deletedDocs: [], // server docs the visitor archived
images: {}, // name -> { dataUrl, date } (visitor uploads)
renamedImages: {}, // new name -> original server name
deletedImages: [] // server image names the visitor deleted
};
},
loading: null,
load() {
if (!this.loading) this.loading = this.open();
return this.loading;
},
async open() {
this.state = this.empty();
try {
this.db = await new Promise((res, rej) => {
const req = indexedDB.open('usmt-dash-demo', 1);
req.onupgradeneeded = () => req.result.createObjectStore('kv');
req.onsuccess = () => res(req.result);
req.onerror = () => rej(req.error);
});
const saved = await new Promise(res => {
const tx = this.db.transaction('kv', 'readonly').objectStore('kv').get('state');
tx.onsuccess = () => res(tx.result); tx.onerror = () => res(null);
});
if (saved && saved.version === 1) this.state = Object.assign(this.empty(), saved);
} catch (e) { this.db = null; } // private browsing etc. - keep changes in memory only
// The demo container reseeds its fleet on restart; a roster copied from an older
// seed would reference tags that no longer exist, so drop it (docs/images are kept).
if (this.state.tracked) {
const base = await serverGet('/Tracked-Devices.json', true);
if (base && hash(base) !== this.state.baseHash) { this.state.tracked = null; this.state.appliedTracked = null; this.save(); }
}
return this.state;
},
save() {
if (!this.db) return;
try { this.db.transaction('kv', 'readwrite').objectStore('kv').put(this.state, 'state'); } catch (e) {}
},
async reset() {
this.state = this.empty();
if (this.db) await new Promise(res => { const tx = this.db.transaction('kv', 'readwrite'); tx.objectStore('kv').delete('state'); tx.oncomplete = res; tx.onerror = res; });
},
hasChanges() {
const s = this.state;
return !!(s && (s.tracked || Object.keys(s.docs).length || s.deletedDocs.length || Object.keys(s.images).length || Object.keys(s.renamedImages).length || s.deletedImages.length));
}
};
// Short-lived memo of server reads so one dashboard poll doesn't fetch the same file twice
const serverMemo = {};
async function serverGet(path, asText) {
const key = path + (asText ? '#t' : '');
const hit = serverMemo[key];
if (hit && Date.now() - hit.at < 2500) return hit.promise;
const promise = realFetch(path + (path.includes('?') ? '&' : '?') + 't=' + Date.now(), { cache: 'no-store' })
.then(r => (r.ok ? (asText ? r.text() : r.json()) : null))
.catch(() => null);
serverMemo[key] = { at: Date.now(), promise };
return promise;
}
// =====================================================================================
// Demo sandbox: simulated data for the visitor's roster
// =====================================================================================
const SCHEME = /^(PRV|PUB)(DT|LT)-[0-9A-Z]{8}$/;
function parseRoster(tracked) {
const tagMap = {}, manualPairs = {}, skipTags = new Set();
for (const [tag, entry] of Object.entries(tracked || {})) {
const list = Array.isArray(entry) ? entry : (entry.devices || []);
if (!Array.isArray(entry) && entry.skipUSMT) skipTags.add(tag);
for (const raw of list) {
if (!raw || !String(raw).trim()) continue;
const d = String(raw).trim().toUpperCase();
const m = d.match(/^([^,]+)\s*,\s*([^,]+)$/);
const add = n => { (tagMap[n] = tagMap[n] || []).includes(tag) || tagMap[n].push(tag); };
if (m) { const s = m[1].trim(), t = m[2].trim(); manualPairs[s] = t; add(s); add(t); }
else add(d);
}
}
return { tagMap, manualPairs, skipTags };
}
function blankRow(src, dst, tags, pairing) {
return {
"Source Device": src, "Destination Device": dst, "Tags": tags, "Pairing": pairing,
"Source Total Space": "N/A", "Source Free Space": "N/A", "USMT Status": "N/A", "Backup Status": "N/A",
"Source Online": "N/A", "Destination Online": "N/A",
"Source Scanstate Line": "N/A", "Destination Loadstate Line": "N/A",
"Source Scanstate File": "N/A", "Destination Loadstate File": "N/A",
"Source Scanstate Elapsed": "N/A", "Destination Loadstate Elapsed": "N/A"
};
}
// A device the visitor typed that the demo fleet doesn't know: borrow the live status of
// a fleet device with the same prefix, so it keeps moving through the stages like the rest.
function pickTemplate(name, rows) {
const prefix = name.split('-')[0];
const pool = rows.filter(r => r["Source Device"].startsWith(prefix + '-') && r["Destination Device"] !== "N/A"
&& !["Skipped", "N/A"].includes(r["Backup Status"]) && r["USMT Status"] !== "N/A")
.sort((a, b) => a["Source Device"].localeCompare(b["Source Device"]));
return pool.length ? pool[hash(name) % pool.length] : null;
}
const synthDest = name => name.split('-')[0] + '-' + serial(name + '|dest');
function renameIn(value, from, to) {
return typeof value === 'string' ? value.split(from).join(to) : value;
}
function synthAdv(name, templateName, adv) {
const base = adv[templateName];
if (!base) return null;
const a = clone(base);
a.MAC = hex(name + 'mac', 6).join(':');
if (a.IP && a.IP !== 'N/A') { const p = a.IP.split('.'); a.IP = `${p[0]}.${p[1]}.${10 + Math.floor(rand(name, 'ip3') * 240)}.${10 + Math.floor(rand(name, 'ip4') * 240)}`; }
if (a.Disk) a.Disk = a.Disk.replace(/S\/N: [^<]*$/, 'S/N: ' + hex(name + 'sn', 8).join('').replace(/(.{4})/g, '$1_').replace(/_$/, '.'));
for (const k of ['LastUser', 'SCCMPrimaryUsers']) if (typeof a[k] === 'string') a[k] = renameIn(a[k], templateName, name).replace(templateName.toLowerCase(), name.toLowerCase());
a.FirstChecked = new Date().toISOString();
return a;
}
// What the worker records for a name nothing knows (a typo): not in AD, SCCM or DNS
function unknownAdv() {
const now = new Date().toISOString();
return { IP: "N/A", MAC: "N/A", OU: "N/A", Groups: "N/A", Model: "N/A", RAM: "N/A", Disk: "N/A", ClientStatus: "N/A", MonitorCount: "N/A", Monitors: "N/A",
RoomType: "N/A", InAD: false, InSCCM: false, InDNS: false, Fetched: now, LastChecked: now, LastPing: "Not Found", FirstChecked: now, LastOnline: null };
}
// Builds the Data.json rows the worker would produce for a roster, plus synthetic
// advanced/software entries for devices the fleet doesn't know.
function buildRows(tracked, serverRows, serverAdv) {
const { tagMap, manualPairs, skipTags } = parseRoster(tracked);
const bySrc = {};
for (const r of serverRows) bySrc[r["Source Device"].toUpperCase()] = r;
const manualDests = new Set(Object.values(manualPairs));
const out = [], adv = {}, software = {}; // software: name -> template name to copy
for (const dev of Object.keys(tagMap).sort()) {
const tags = tagMap[dev].join(', ');
const srow = bySrc[dev];
if (tagMap[dev].some(t => skipTags.has(t))) {
const r = blankRow(dev, "N/A", tags, "Skipped");
Object.assign(r, { "USMT Status": "Skipped", "Backup Status": "Skipped", "Source Online": "Skipped", "Destination Online": "-" });
out.push(r);
if (!serverAdv[dev]) { const t = pickTemplate(dev, serverRows); if (t && SCHEME.test(dev)) { adv[dev] = synthAdv(dev, t["Source Device"], serverAdv); software[dev] = t["Source Device"]; } }
continue;
}
if (manualPairs[dev]) {
const dst = manualPairs[dev];
if (srow && srow["Destination Device"] === dst && srow["USMT Status"] === "N/A") { out.push(Object.assign(clone(srow), { Tags: tags })); continue; }
const r = blankRow(dev, dst, tags, "Manual");
const now = new Date().toISOString();
for (const [n, col] of [[dev, "Source"], [dst, "Destination"]]) {
const t = SCHEME.test(n) ? pickTemplate(n, serverRows) : null;
r[col + " Online"] = t ? t["Source Online"] : "Not Found";
r[col + " Checked"] = now;
if (t && !serverAdv[n]) { adv[n] = synthAdv(n, t["Source Device"], serverAdv); software[n] = t["Source Device"]; }
else if (!t) { software[n] = null; if (!serverAdv[n]) adv[n] = unknownAdv(); }
}
out.push(r);
continue;
}
if (manualDests.has(dev)) continue;
if (srow && !["Skipped", "N/A"].includes(srow["Backup Status"])) { out.push(Object.assign(clone(srow), { Tags: tags })); continue; }
const t = SCHEME.test(dev) ? pickTemplate(dev, serverRows) : null;
if (!t) {
// Not in the fleet and not a valid name: what the worker reports for a typo
const r = blankRow(dev, "N/A", tags, "None");
Object.assign(r, { "USMT Status": "Not Associated", "Backup Status": "Not Associated", "Source Online": "Not Found", "Destination Online": "-",
"Source Scanstate Line": "Unreachable (Not Found)", "Source Scanstate File": "Unreachable", "Source Checked": new Date().toISOString() });
out.push(r);
software[dev] = null; // SCCM has no inventory for it: an empty list, same as Get-Software.ps1
if (!serverAdv[dev]) adv[dev] = unknownAdv();
continue;
}
const tSrc = t["Source Device"], tDst = t["Destination Device"], dst = synthDest(dev);
const r = clone(t);
for (const k of Object.keys(r)) r[k] = renameIn(renameIn(r[k], tSrc, dev), tDst, dst);
r.Tags = tags;
out.push(r);
if (!serverAdv[dev]) adv[dev] = synthAdv(dev, tSrc, serverAdv);
if (!serverAdv[dst]) adv[dst] = synthAdv(dst, tDst, serverAdv);
software[dev] = tSrc; software[dst] = tDst;
}
return { rows: out, adv, software };
}
async function serverSnapshot() {
const [data, adv] = await Promise.all([serverGet('/Data.json'), serverGet('/Advanced-Cache.json')]);
const rows = data && Array.isArray(data.Devices) ? data.Devices : (Array.isArray(data) ? data : []);
return { data: data || {}, rows, adv: adv || {} };
}
async function visitorTracked() {
const s = Store.state;
if (s.tracked) return s.tracked;
return (await serverGet('/Tracked-Devices.json')) || {};
}
async function ensureOwnRoster() {
const s = Store.state;
if (!s.tracked) {
const text = (await serverGet('/Tracked-Devices.json', true)) || '{}';
const base = JSON.parse(text);
s.baseHash = hash(text);
s.tracked = clone(base);
s.appliedTracked = clone(base);
}
return s.tracked;
}
// =====================================================================================
// Demo sandbox: simulated worker cycle (prints like USMT-Worker.ps1 -v)
// =====================================================================================
const Sim = { running: false, again: null, worker: null, timer: null };
function scheduleCycle(kind, delay) {
clearTimeout(Sim.timer);
Sim.timer = setTimeout(() => runCycle(kind), delay);
}
function pingLine(name, status) {
const color = { "Online": "Green", "Timed Out": "DarkYellow" }[status] || "Red";
const text = { "Online": "Online", "Timed Out": "Timed Out.", "DNS Issue": "DNS Issue (reverse lookup mismatch).", "Not Found": "Not Found (No IP)." }[status] || "Not Found (No IP).";
return [[` > Fast Ping: ${name}... `, 'DarkGray'], [text, color]];
}
function logLines(name, logName, file) {
const out = [[[` > Scraping logs for '${logName}'...`, 'DarkGray']]];
const folders = ['Windows\\CCM\\Logs\\SMSTSLog', 'Windows\\CCM\\Logs'];
for (const f of folders) {
const path = `\\\\${name}\\C$\\${f}`;
const hit = typeof file === 'string' && file.startsWith(path + '\\');
out.push([[` Checking ${path}... `, 'DarkGray'], hit ? ['Found!', 'Green'] : ['Empty/Not there.', 'DarkGray']]);
if (hit) break;
}
return out;
}
function deviceLines(r, isNew, full) {
const W = [];
const add = (...segs) => W.push(segs);
const src = r["Source Device"], dst = r["Destination Device"];
if (r["Backup Status"] === "Skipped") {
add([' -> [SKIP USMT] Tag marked Skip USMT. Caching Advanced/Software only.', 'DarkMagenta']);
if (isNew || full) add([` -> [CACHE MISS / UPDATE] Running AD & SQL lookup for ${src}...`, 'DarkYellow']);
return W;
}
if (r["USMT Status"] === "N/A" && dst !== "N/A") {
add([' -> [MANUAL MATCH] Hardware exception detected. Bypassing SCCM...', 'DarkYellow']);
W.push(pingLine(src, r["Source Online"]), pingLine(dst, r["Destination Online"]));
return W;
}
add([' -> Querying SCCM for associations...', 'DarkGray']);
if (r["Pairing"] === "Cached" || r["Source Online"] === "Cached") {
add([' -> SCCM DB Status: Completed (Raw: 2)', 'DarkGray']);
add([' -> [CACHE HIT] Fast-tracking row and bypassing network sweeps.', 'Green']);
return W;
}
if (r["USMT Status"] === "Not Associated") {
add([' -> No SCCM association found. Sweeping source device anyway...', 'DarkGray']);
W.push(pingLine(src, r["Source Online"]));
if (r["Source Online"] === "Online") {
add([' > Checking C: Drive Storage (SMB Method)... ', 'DarkGray'], [`${(r["Source Free Space"] || '').replace(' GB', 'GB')} Free out of ${(r["Source Total Space"] || '').replace(' GB', 'GB')}`, 'Green']);
W.push(...logLines(src, 'scanstateprogress', r["Source Scanstate File"]));
}
if (isNew || full) add([` -> [CACHE MISS / UPDATE] Running AD & SQL lookup for ${src}...`, 'DarkYellow']);
add([` - Status: ${r["Backup Status"]}`, 'Cyan']);
return W;
}
const raw = { "Not Started": 0, "In Progress": 1, "Completed": 2 }[r["USMT Status"]];
if (isNew) add([` -> [CACHE MISS] Fetching software for Dest Device ${dst}...`, 'DarkCyan']);
add([` -> SCCM DB Status: ${r["USMT Status"]} (Raw: ${raw === undefined ? '?' : raw})`, 'DarkGray']);
add([` -> [SWEEP SOURCE] ${src}`, 'DarkCyan']);
W.push(pingLine(src, r["Source Online"]));
if (r["Source Online"] === "Online") {
add([' > Checking C: Drive Storage (SMB Method)... ', 'DarkGray'], [`${(r["Source Free Space"] || '').replace(' GB', 'GB')} Free out of ${(r["Source Total Space"] || '').replace(' GB', 'GB')}`, 'Green']);
W.push(...logLines(src, 'scanstateprogress', r["Source Scanstate File"]));
}
add([` -> [SWEEP DESTINATION] ${dst}`, 'DarkCyan']);
W.push(pingLine(dst, r["Destination Online"]));
if (r["Destination Online"] === "Online") W.push(...logLines(dst, 'loadstateprogress', r["Destination Loadstate File"]));
add([' -> Calculating Master Status...', 'DarkGray']);
add([' -> Fetching Advanced SQL/AD Data...', 'DarkGray']);
if (isNew || full) {
add([` -> [CACHE MISS / UPDATE] Running AD & SQL lookup for ${src}...`, 'DarkYellow']);
add([` -> [CACHE MISS / UPDATE] Running AD & SQL lookup for ${dst}...`, 'DarkYellow']);
}
add([` - Status: ${r["Backup Status"]}`, 'Cyan']);
return W;
}
async function runCycle(kind) {
if (Sim.running) { Sim.again = kind; return; }
Sim.running = true;
const full = kind === 'full';
const w = segs => Term.write('worker', segs);
Term.hold('worker', true);
try {
const s = Store.state;
const tracked = await visitorTracked();
const applied = s.appliedTracked || tracked;
const snap = await serverSnapshot();
if (!snap.rows.length) { // container just started: nothing to sweep against yet
w([['[Waiting for the first worker sweep to finish...]', 'DarkGray']]);
Sim.again = kind;
await sleep(5000);
return;
}
const before = new Set(Object.keys(parseRoster(applied).tagMap));
const { rows } = buildRows(tracked, snap.rows, snap.adv);
const { tagMap } = parseRoster(tracked);
const total = rows.length; // source devices only, like the worker
const started = new Date();
const state = (status, cur) => { Sim.worker = { Status: status, Current: cur, Total: total }; };
if (kind === 'force') w([['[!] Force Update triggered by Dashboard!', 'Cyan']]);
if (full) w([['[!] Force FULL Refresh triggered by Dashboard!', 'Cyan']]);
w([]); w([]);
w([[`--- Cycle Start: ${psTime(started)} ---`, 'White']]);
state('Preparing...', 0); await sleep(400);
w([]);
w([['[Checking Software Cache Engine...]', 'Cyan']]);
state('Software Check...', 0);
if (full) w([['Dashboard Force FULL Refresh. Triggering global software/advanced update.', 'Magenta']]);
for (const dev of Object.keys(tagMap).sort()) {
if (full || !before.has(dev)) { w([[`Fetching background software for ${dev}...`, 'DarkCyan']]); await sleep(full ? 8 : 60); }
}
w([['[Software Cache Synchronized]', 'Green']]);
w([]);
// Devices the visitor just added are swept first, like the worker does for new devices
const fresh = rows.filter(r => !before.has(r["Source Device"]));
const ordered = fresh.concat(rows.filter(r => before.has(r["Source Device"])));
w([[`--- Commencing Data Sweep (${total} Devices${fresh.length ? `, ${fresh.length} new first` : ''}) ---`, 'White']]);
const delay = Math.max(15, Math.min(90, 9000 / Math.max(1, rows.length)));
let i = 0;
for (const r of ordered) {
i++;
state('Updating Devices...', i);
const src = r["Source Device"];
w([[`[${i}/${total}] Processing: ${src}`, 'Yellow']]);
w([[` -> Tags: [${r.Tags}]`, 'DarkCyan']]);
for (const segs of deviceLines(r, !before.has(src), full)) w(segs);
await sleep(delay);
if (fresh.length && i === fresh.length && i < ordered.length) {
w([[` -> [PRIORITY] ${fresh.length} new device(s) swept. Publishing early...`, 'Green']]);
if (!s.tracked) await ensureOwnRoster();
s.appliedTracked = clone(tracked);
s.rev++;
Store.save();
}
}
w([[' -> Pruning and Compressing Software Cache...', 'DarkGray']]);
w([[' -> Pruning and Saving Advanced Info Cache...', 'DarkGray']]);
w([[' -> Writing payload to Data.json...', 'DarkGray']]);
const secs = (new Date() - started) / 1000;
w([]);
w([[`Cycle Complete in ${secs.toFixed(1)}s.`, 'Gray']]);
const next = new Date(Date.now() + 45000);
w([[`Next Cycle Scheduled For: ${psStamp(next)}`, 'DarkGray']]);
w([]); w([]);
if (!s.tracked) await ensureOwnRoster();
s.appliedTracked = clone(tracked);
s.rev++;
Store.save();
} finally {
Sim.worker = null;
Sim.running = false;
Term.hold('worker', false);
}
if (Sim.again) { const k = Sim.again; Sim.again = null; scheduleCycle(k, 1500); }
}
// =====================================================================================
// Demo sandbox: route handlers
// =====================================================================================
const srv = (text, color) => Term.write('server', [[text, color]]);
const DOC_NAME = /[<>:"/\\|?*]/g;
const IMG_TYPES = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', bmp: 'image/bmp' };
async function readBody(init) {
try { return JSON.parse((init && init.body) || '{}'); } catch (e) { return {}; }
}
async function serverDocNames() {
const list = await serverGet('/get-docs-list');
return Array.isArray(list) ? list : (list ? [list] : []);
}
async function visibleDocNames() {
const s = Store.state;
const names = new Set((await serverDocNames()).filter(d => !s.deletedDocs.includes(d)));
Object.keys(s.docs).forEach(d => names.add(d));
return [...names].sort((a, b) => a.localeCompare(b));
}
async function docContent(title) {
const s = Store.state;
if (s.docs[title]) return s.docs[title].content;
if (s.deletedDocs.includes(title)) return null;
const r = await realFetch(`/Documentation/${encodeURIComponent(title)}.md?t=${Date.now()}`);
return r.ok ? r.text() : null;
}
async function serverImages() {
const list = await serverGet('/list-images');
return Array.isArray(list) ? list : (list ? [list] : []);
}
// name the visitor sees -> what backs it ({ dataUrl } or { server: originalName })
async function imageMap() {
const s = Store.state;
const map = {};
const renamedFrom = new Set(Object.values(s.renamedImages));
for (const img of await serverImages()) {
if (s.deletedImages.includes(img.Name) || renamedFrom.has(img.Name)) continue;
map[img.Name] = { server: img.Name, Date: img.Date };
}
for (const [name, orig] of Object.entries(s.renamedImages)) map[name] = { server: orig, Date: new Date().toISOString() };
for (const [name, img] of Object.entries(s.images)) map[name] = { dataUrl: img.dataUrl, Date: img.date };
return map;
}
const routes = {
// ---- tracking roster ----
'GET Tracked-Devices.json': async () => {
const s = Store.state;
const text = s.tracked ? JSON.stringify(s.tracked, null, 2) : ((await serverGet('/Tracked-Devices.json', true)) || '{}');
// Trailing whitespace makes the text differ after a simulated cycle, which is how
// the dashboard notices it should reload Data.json.
return textResponse(text + ' '.repeat(s.rev), 200, { 'Content-Type': 'application/json' });
},
'GET get-all-tags': async () => jsonResponse(Object.keys(await visitorTracked())),
'GET get-tracked': async url => {
const entry = (await visitorTracked())[url.searchParams.get('tech')];
if (!entry) return jsonResponse([]);
return jsonResponse(Array.isArray(entry) ? entry : (entry.devices || []));
},
'GET get-tag-meta': async url => {
const entry = (await visitorTracked())[url.searchParams.get('tech')];
return jsonResponse({ skipUSMT: !!(entry && !Array.isArray(entry) && entry.skipUSMT) });
},
'POST update-tracked': async (url, init) => {
const body = await readBody(init);
const tracked = await ensureOwnRoster();
const tech = body.tech;
const skip = Object.prototype.hasOwnProperty.call(body, 'skipUSMT') ? !!body.skipUSMT : false;
if (!body.devices || body.devices.length === 0) delete tracked[tech];
else tracked[tech] = { devices: body.devices, skipUSMT: skip };
Store.save();
srv(`Updated tracking roster for Tech/Tag: ${tech}`, 'Green');
scheduleCycle('roster', 2500); // the worker picks the change up on its next sweep
return textResponse('', 200);
},
// ---- worker data ----
'GET Worker-State.json': async () => {
const state = (await serverGet('/Worker-State.json')) || {};
// the visitor's own (simulated) changes count as new data too
if (state.DataVersion && Store.state.appliedTracked) state.DataVersion += '#' + Store.state.rev;
if (Sim.worker) return jsonResponse(Object.assign({}, state, Sim.worker));
return jsonResponse(state);
},
'GET Data.json': async () => {
const s = Store.state;
const snap = await serverSnapshot();
// Until the worker's first sweep there are no rows to merge with: pass through so
// the dashboard shows its "Waiting for Worker..." placeholders
if (!s.appliedTracked || !snap.rows.length) return jsonResponse(snap.data);
const { rows } = buildRows(s.appliedTracked, snap.rows, snap.adv);
return jsonResponse({ LastUpdated: snap.data.LastUpdated || psStamp(), DataVersion: (snap.data.DataVersion || '') + '#' + s.rev, Devices: rows });
},
'GET Advanced-Cache.json': async () => {
const s = Store.state;
const snap = await serverSnapshot();
if (!s.appliedTracked || !snap.rows.length) return jsonResponse(snap.adv);
const { adv } = buildRows(s.appliedTracked, snap.rows, snap.adv);
for (const [k, val] of Object.entries(adv)) if (val) snap.adv[k] = val;
return jsonResponse(snap.adv);
},
'GET Software-Cache.json': async () => {
const s = Store.state;
const cache = (await serverGet('/Software-Cache.json')) || {};
if (s.appliedTracked) {
const snap = await serverSnapshot();
const { software } = buildRows(s.appliedTracked, snap.rows, snap.adv);
for (const [name, template] of Object.entries(software)) {
if (cache[name]) continue;
cache[name] = (template && cache[template]) || { Timestamp: new Date().toISOString(), Data: [] };
}
}
return jsonResponse(cache);
},
'GET get-software': async url => {
const name = (url.searchParams.get('name') || '').toUpperCase();
const cache = await (await routes['GET Software-Cache.json']()).json();
srv(`Fetching Software for: ${name}`, 'Yellow');
const entry = cache[name];
return jsonResponse(entry ? (Array.isArray(entry) ? entry : [].concat(entry.Data || [])) : []);
},
'POST force-update': async () => {
srv('Received Force Update Command from Dashboard', 'Cyan');
scheduleCycle('force', 800);
return textResponse('', 200);
},
'POST force-full': async () => {
srv('Received Force FULL Refresh Command from Dashboard', 'Cyan');
scheduleCycle('full', 800);
return textResponse('', 200);
},
// ---- knowledge base ----
'GET get-docs-list': async () => jsonResponse(await visibleDocNames()),
'GET Documentation': async url => {
const rel = decodeURIComponent(url.pathname.replace(/^\/Documentation\//, ''));
if (rel.startsWith('images/')) return null; // passthrough (handled by the <img> rewriter)
if (!rel.endsWith('.md') || rel.includes('/')) return null;
const s = Store.state;
const title = rel.slice(0, -3);
if (s.docs[title]) return textResponse(s.docs[title].content, 200, { 'Content-Type': 'text/markdown', 'Last-Modified': new Date(s.docs[title].modified).toUTCString() });
if (s.deletedDocs.includes(title)) return textResponse('', 404);
return null;
},
'POST save-doc': async (url, init) => {
const body = await readBody(init);
const s = Store.state;
const title = String(body.title || '').replace(DOC_NAME, '');
const original = String(body.originalTitle || '').replace(DOC_NAME, '');
if (!title.trim()) return textResponse('', 400);
if (original && original !== title) {
delete s.docs[original];
if ((await serverDocNames()).includes(original) && !s.deletedDocs.includes(original)) s.deletedDocs.push(original);
}
s.docs[title] = { content: String(body.content || ''), modified: Date.now() };
s.deletedDocs = s.deletedDocs.filter(d => d !== title);
Store.save();
srv(`Saved documentation: ${title}.md`, 'Green');
return textResponse('', 200);
},
'POST delete-doc': async (url, init) => {
const body = await readBody(init);
const s = Store.state;
const title = String(body.title || '').replace(DOC_NAME, '');
if (!title.trim()) return textResponse('', 400);
const onServer = (await serverDocNames()).includes(title) && !s.deletedDocs.includes(title);
if (!s.docs[title] && !onServer) return textResponse('', 404);
delete s.docs[title];
if (onServer) s.deletedDocs.push(title);
Store.save();
srv(`Archived documentation: ${title}.md`, 'Yellow');
return textResponse('', 200);
},
// ---- images ----
'GET list-images': async () => {
const map = await imageMap();
return jsonResponse(Object.entries(map).map(([Name, v]) => ({ Name, Date: v.Date })).sort((a, b) => a.Name.localeCompare(b.Name)));
},
'POST upload-image': async (url, init) => {
const body = await readBody(init);
const name = String(body.filename || '').replace(DOC_NAME, '');
if (!name || !body.data) return textResponse('', 500);
const ext = (name.split('.').pop() || '').toLowerCase();
Store.state.images[name] = { dataUrl: `data:${IMG_TYPES[ext] || 'application/octet-stream'};base64,${body.data}`, date: new Date().toISOString() };
delete Store.state.renamedImages[name];
Store.save();
ImageRewriter.refresh();
srv(`Uploaded Image: ${name}`, 'Cyan');
return textResponse('', 200);
},
'POST rename-image': async (url, init) => {
const body = await readBody(init);
const s = Store.state;
const oldRaw = String(body.oldName || ''), oldName = oldRaw.replace(DOC_NAME, '');
const newName = String(body.newName || '').replace(DOC_NAME, '');
const map = await imageMap();
if (!map[oldName] || !newName) return textResponse('', 404);
if (s.images[oldName]) { s.images[newName] = s.images[oldName]; delete s.images[oldName]; }
else { const orig = map[oldName].server; delete s.renamedImages[oldName]; s.renamedImages[newName] = orig; }
// Same markdown crawler as the server: fix links in every article
const encOld = encodeURIComponent(oldRaw), encNew = encodeURIComponent(newName);
for (const title of await visibleDocNames()) {
const content = await docContent(title);
if (content == null) continue;
let next = content.split('images/' + oldRaw).join('images/' + newName);
next = next.split('images/' + encOld).join('images/' + encNew);
if (next !== content) {
s.docs[title] = { content: next, modified: Date.now() };
srv(`Auto-fixed image link in ${title}.md`, 'Magenta');
}
}
Store.save();
ImageRewriter.refresh();
return textResponse('', 200);
},
'POST delete-image': async (url, init) => {
const body = await readBody(init);
const s = Store.state;
const name = String(body.filename || '').replace(DOC_NAME, '');
const map = await imageMap();
if (!map[name]) return textResponse('', 404);
if (s.images[name]) delete s.images[name];
else if (s.renamedImages[name]) { s.deletedImages.push(s.renamedImages[name]); delete s.renamedImages[name]; }
else s.deletedImages.push(name);
Store.save();
ImageRewriter.refresh();
srv(`Deleted Image: ${body.filename}`, 'Yellow');
return textResponse('', 200);
}
};
function findRoute(url, method) {
const path = decodeURIComponent(url.pathname.replace(/^\//, ''));
if (routes[`${method} ${path}`]) return routes[`${method} ${path}`];
if (method === 'GET' && path.startsWith('Documentation/')) return routes['GET Documentation'];
return null;
}
// <img> requests can't be intercepted with fetch, so images the visitor uploaded or
// renamed are swapped in as their elements appear.
const ImageRewriter = {
map: {},
async refresh() {
this.map = await imageMap();
document.querySelectorAll('img').forEach(img => this.fix(img));
},
fix(img) {
const src = img.getAttribute('src') || '';
if (img.dataset.usmtFor === src) return; // already rewritten by us
const m = src.match(/^\/?Documentation\/images\/([^?#]+)/);
if (!m) return;
let name; try { name = decodeURIComponent(m[1]); } catch (e) { return; }
const entry = this.map[name];
let want;
if (!entry) want = 'data:,'; // deleted in this sandbox
else if (entry.dataUrl) want = entry.dataUrl; // uploaded by this visitor
else if (entry.server !== name) want = '/Documentation/images/' + encodeURIComponent(entry.server);
if (want) { img.dataset.usmtFor = want; img.setAttribute('src', want); }
},
start() {
this.refresh();
new MutationObserver(muts => {
for (const m of muts) {
if (m.type === 'attributes') this.fix(m.target);
else m.addedNodes.forEach(n => {
if (n.nodeType !== 1) return;
if (n.tagName === 'IMG') this.fix(n); else n.querySelectorAll && n.querySelectorAll('img').forEach(i => this.fix(i));
});
}
}).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['src'] });
}
};
// =====================================================================================
// Demo banner + about dialog
// =====================================================================================
const Demo = {
build() {
const controls = document.querySelector('.nav-right-controls');
if (controls) {
const pill = document.createElement('button');
pill.type = 'button'; pill.className = 'usmt-demo-pill'; pill.textContent = 'DEMO';
pill.title = 'About this demo';
pill.onclick = () => this.about(true);
controls.insertBefore(pill, controls.firstChild);
}
const modal = document.createElement('div');
modal.className = 'usmt-demo-modal';
modal.innerHTML = `
<div class="usmt-demo-card" role="dialog" aria-labelledby="usmt-demo-title">
<h2 id="usmt-demo-title">You're looking at a live demo</h2>
<p>This is the real dashboard, served by the real <code>USMT-Server.ps1</code> HttpListener and fed by the real
<code>USMT-Worker.ps1</code> polling loop, running in PowerShell 7 in a container. The only difference is the data
source: instead of SCCM, Active Directory and SMB, the worker talks to a simulated fleet of 200 devices whose
migrations progress in real time.</p>
<p><strong>Your changes stay in your browser.</strong> The demo API is read-only. Adding devices, editing articles,
uploading images and forcing refreshes are simulated in this browser only, and the terminals show what the
services would print. No one else sees your changes, and they're never sent to the server.
Run it yourself without <code>-Demo</code> and every action works against the real backend.</p>
<p class="usmt-demo-try"><strong>Things to try:</strong> open <em>Terminals</em> at the bottom to watch the worker sweep the
fleet &middot; add a device such as <code>PRVDT-7QK4M2XA</code> under <em>Tracked Devices</em> &middot;
click a device for its details &middot; <em>All Devices</em> shows apps missing from replacement machines &middot;
edit an article under <em>Documentation</em>.</p>
<div class="usmt-demo-actions">
<button type="button" class="usmt-demo-reset">Reset my changes</button>
<button type="button" class="usmt-demo-terms">Open terminals</button>
<button type="button" class="usmt-demo-close">Explore the dashboard</button>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener('click', e => { if (e.target === modal) this.about(false); });
modal.querySelector('.usmt-demo-close').onclick = () => this.about(false);
modal.querySelector('.usmt-demo-terms').onclick = () => { this.about(false); Term.toggle(true); };
modal.querySelector('.usmt-demo-reset').onclick = async () => {
await Store.reset();
try { ['usmt_workbench_stacks', 'usmt_custom_columns', 'usmt_custom_filters', 'usmt_custom_widths', 'usmt_custom_widths_locked', 'usmt_tech_id'].forEach(k => localStorage.removeItem(k)); } catch (e) {}
location.reload();
};
this.modal = modal;
let seen = false;
try { seen = localStorage.getItem('usmt_demo_seen') === '1'; } catch (e) {}
if (!seen) this.about(true);
this.seedWorkbench();
},
// First visit: put 6 PCs on the Workbench (one per migration stage) so it isn't empty.
// Only when the visitor has no saved stacks; "Reset my changes" clears them again.
async seedWorkbench() {
try { if (localStorage.getItem('usmt_workbench_stacks')) return; } catch (e) { return; }
for (let i = 0; i < 60; i++) {
if (typeof apiData !== 'undefined' && apiData.filter(r => r["Backup Status"] && r["Backup Status"] !== "No Data").length > 20) break;
await sleep(500);
}
if (typeof apiData === 'undefined' || typeof myStacks === 'undefined') return;
try { if (localStorage.getItem('usmt_workbench_stacks')) return; } catch (e) { return; } // created meanwhile
const wanted = ["Backup In Progress", "Restore In Progress", "Backup Completed", "Completed", "Not Started", "Not Associated"];
const picks = [];
for (const status of wanted) {
const row = apiData.find(r => r["Backup Status"] === status && !picks.includes(r["Source Device"])
&& (status === "Completed" || r["Source Online"] === "Online"));
if (row) picks.push(row["Source Device"]);
}
for (const r of apiData) { // top up to 6 if a stage is empty
if (picks.length >= 6) break;
if (!["Skipped", "No Data"].includes(r["Backup Status"]) && !picks.includes(r["Source Device"])) picks.push(r["Source Device"]);
}
if (!picks.length) return;
myStacks = [{ id: 1, name: "Stack 1", list: picks.slice(0, 6).join('\n') }];
saveAndRender();
myStacks.forEach(st => refreshStackTable(st.id));
},
about(show) {
this.modal.classList.toggle('show', show);
if (!show) { try { localStorage.setItem('usmt_demo_seen', '1'); } catch (e) {} }
}
};
// =====================================================================================
// Wire-up
// =====================================================================================
window.fetch = async function (input, init) {
const cfg = await configReady;
if (!cfg.Demo) return realFetch(input, init);
let url;
try { url = new URL(typeof input === 'string' ? input : input.url, document.baseURI); } catch (e) { return realFetch(input, init); }
if (url.origin !== location.origin) return realFetch(input, init);
await Store.load();
const method = ((init && init.method) || (input && input.method) || 'GET').toUpperCase();
const handler = findRoute(url, method);
if (handler) {
try {
const res = await handler(url, init);
if (res) return res;
} catch (e) {
console.error('[demo sandbox]', e);
return textResponse('', 500);
}
}
return realFetch(input, init);
};
function onReady(fn) { if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fn); else fn(); }
onReady(async () => {
const cfg = await configReady;
if (cfg.Terminals) Term.init(!!cfg.Demo);
if (cfg.Demo) {
document.documentElement.classList.add('usmt-demo');
await Store.load();
Demo.build();
ImageRewriter.start();
}
});
})();