- Replace the text triangle with an SVG chevron so it centers with the label - Make the "your actions (simulated)" legend dot the same yellow as the markers on simulated lines - When live worker output was held back during a visitor's simulated cycle, print a divider before flushing it so the jump is explained
977 lines
51 KiB
JavaScript
977 lines
51 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>
|
|
${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>your actions (simulated)</span>' : ''}
|
|
<button class="usmt-term-btn usmt-term-follow active" type="button" title="Auto-scroll">Follow</button>
|
|
</div>
|
|
<div class="usmt-term-window">
|
|
<div class="usmt-term-titlebar"><span class="usmt-term-wtitle"></span></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.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);
|
|
this.el.querySelector('.usmt-term-wtitle').textContent = (this.demo ? 'Administrator: PowerShell (demo container) - ' : 'Administrator: PowerShell - ') + 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 = ' ';
|
|
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) {
|
|
return {
|
|
"Source Device": src, "Destination Device": dst, "Tags": tags,
|
|
"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());
|
|
return a;
|
|
}
|
|
|
|
// 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);
|
|
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);
|
|
for (const [n, col] of [[dev, "Source Online"], [dst, "Destination Online"]]) {
|
|
const t = SCHEME.test(n) ? pickTemplate(n, serverRows) : null;
|
|
r[col] = t ? t["Source Online"] : "Not Found";
|
|
if (t && !serverAdv[n]) { adv[n] = synthAdv(n, t["Source Device"], serverAdv); software[n] = t["Source Device"]; }
|
|
else if (!t) software[n] = null;
|
|
}
|
|
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);
|
|
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" });
|
|
out.push(r);
|
|
software[dev] = null; // SCCM has no inventory for it: an empty list, same as Get-Software.ps1
|
|
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["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();
|
|
const before = new Set(Object.keys(parseRoster(applied).tagMap));
|
|
const { rows } = buildRows(tracked, snap.rows, snap.adv);
|
|
const { tagMap } = parseRoster(tracked);
|
|
const total = Object.keys(tagMap).length;
|
|
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([]);
|
|
w([[`--- Commencing Data Sweep (${total} Devices) ---`, 'White']]);
|
|
|
|
const delay = Math.max(15, Math.min(90, 9000 / Math.max(1, rows.length)));
|
|
let i = 0;
|
|
for (const r of rows) {
|
|
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);
|
|
}
|
|
|
|
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')) || {};
|
|
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();
|
|
if (!s.appliedTracked) return jsonResponse(snap.data);
|
|
const { rows } = buildRows(s.appliedTracked, snap.rows, snap.adv);
|
|
return jsonResponse({ LastUpdated: snap.data.LastUpdated || psStamp(), Devices: rows });
|
|
},
|
|
'GET Advanced-Cache.json': async () => {
|
|
const s = Store.state;
|
|
const snap = await serverSnapshot();
|
|
if (!s.appliedTracked) 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 · add a device such as <code>PRVDT-7QK4M2XA</code> under <em>Tracked Devices</em> ·
|
|
click a device for its details · <em>All Devices</em> shows apps missing from replacement machines ·
|
|
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);
|
|
},
|
|
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, location.href); } 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();
|
|
}
|
|
});
|
|
})();
|