Demo: migrations follow the schedule, worked by a simulated team

Each device now migrates on the day of its date tag instead of on a
looping 24-hour clock: earlier days are finished, today's devices go
through backup and restore during working hours, later days haven't
started. The few that don't finish have a cause the dashboard shows
(broken client, broken WMI, low disk, a USMT error, unreachable PC).

A simulated team keeps two weeks of working days tagged. During working
hours it adds devices, reschedules or drops one, tags stuck PCs with
their problem, then fixes them and moves them to a new day; each morning
finished days are closed out and a new day is scheduled. Actions print
in the worker terminal as [Team] lines. Settings live in Demo.Team.

The visitor sandbox now keeps per-tag edits laid over the live roster,
so the team's changes keep showing around a visitor's own edits.

Overview calendar: today is a filled badge on the date number, so it no
longer looks like the selected day (outline plus tint).
This commit is contained in:
Johan Sandoval committed 2026-10-07 20:41:07 +00:00
1 parent 9731368687
commit 41ff9e2b49
7 files changed
+563 -155

No files matched your search

+59 -46
View File
@@ -253,9 +253,11 @@
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
version: 2,
// The visitor's roster edits, per tag, laid over the live roster (which the demo's
// simulated team keeps changing): tag -> { devices, skipUSMT, created } or { deleted: true }
overrides: {},
applied: null, // the overrides the (simulated) worker last swept (null = none yet)
rev: 0, // bumps whenever simulated data changes
docs: {}, // title -> { content, modified }
deletedDocs: [], // server docs the visitor archived
@@ -282,15 +284,12 @@
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);
if (saved && saved.version === 2) this.state = Object.assign(this.empty(), saved);
// version 1 kept a full copy of the roster: keep its docs and images, drop the copy
else if (saved && saved.version === 1) this.state = Object.assign(this.empty(), { docs: saved.docs || {}, deletedDocs: saved.deletedDocs || [],
images: saved.images || {}, renamedImages: saved.renamedImages || {}, deletedImages: saved.deletedImages || [] });
} 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() {
@@ -303,7 +302,7 @@
},
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));
return !!(s && (Object.keys(s.overrides).length || Object.keys(s.docs).length || s.deletedDocs.length || Object.keys(s.images).length || Object.keys(s.renamedImages).length || s.deletedImages.length));
}
};
@@ -355,11 +354,18 @@
// 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) {
// Prefers a device scheduled on the same day (a date tag such as 10/14), so a PC added to a
// future day looks not started and one added to a past day looks done.
const DATE_TAG = /^\d{1,2}\/\d{1,2}$/;
function pickTemplate(name, rows, tags) {
const prefix = name.split('-')[0];
const pool = rows.filter(r => r["Source Device"].startsWith(prefix + '-') && r["Destination Device"] !== "N/A"
const usable = rows.filter(r => 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"]));
const days = (tags || []).filter(t => DATE_TAG.test(t));
const sameDay = days.length ? usable.filter(r => String(r.Tags || '').split(', ').some(t => days.includes(t))) : [];
const healthy = sameDay.filter(r => !/error/i.test((r["Source Scanstate Line"] || '') + (r["Destination Loadstate Line"] || '')));
const pool = healthy.length ? healthy : sameDay.length ? sameDay : usable.filter(r => r["Source Device"].startsWith(prefix + '-'));
return pool.length ? pool[hash(name) % pool.length] : null;
}
const synthDest = name => name.split('-')[0] + '-' + serial(name + '|dest');
@@ -427,7 +433,7 @@
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;
const t = SCHEME.test(dev) ? pickTemplate(dev, serverRows, tagMap[dev]) : 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");
@@ -456,22 +462,26 @@
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);
// The live roster with the visitor's per-tag edits on top. An edit to a tag the team has
// since closed out is dropped; a tag the visitor created stays until they delete it.
function mergeRoster(base, overrides) {
const out = {};
for (const [tag, entry] of Object.entries(base || {})) {
const o = overrides[tag];
if (!o) out[tag] = entry;
else if (!o.deleted) out[tag] = { devices: o.devices, skipUSMT: o.skipUSMT };
}
return s.tracked;
for (const [tag, o] of Object.entries(overrides)) {
if (!(tag in (base || {})) && o.created && !o.deleted) out[tag] = { devices: o.devices, skipUSMT: o.skipUSMT };
}
return out;
}
async function visitorTracked() {
return mergeRoster((await serverGet('/Tracked-Devices.json')) || {}, Store.state.overrides);
}
async function appliedTracked() {
const s = Store.state;
return s.applied ? mergeRoster((await serverGet('/Tracked-Devices.json')) || {}, s.applied) : null;
}
// =====================================================================================
@@ -564,7 +574,7 @@
try {
const s = Store.state;
const tracked = await visitorTracked();
const applied = s.appliedTracked || tracked;
const applied = (await 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']]);
@@ -610,8 +620,7 @@
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.applied = clone(s.overrides);
s.rev++;
Store.save();
}
@@ -629,8 +638,7 @@
w([[`Next Cycle Scheduled For: ${nextText}`, 'DarkGray']]);
w([]); w([]);
if (!s.tracked) await ensureOwnRoster();
s.appliedTracked = clone(tracked);
s.applied = clone(s.overrides);
s.rev++;
Store.save();
} finally {
@@ -692,7 +700,7 @@
// ---- 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)) || '{}');
const text = Object.keys(s.overrides).length ? JSON.stringify(await visitorTracked(), 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' });
@@ -709,11 +717,16 @@
},
'POST update-tracked': async (url, init) => {
const body = await readBody(init);
const tracked = await ensureOwnRoster();
const s = Store.state;
const base = (await serverGet('/Tracked-Devices.json')) || {};
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 };
const prev = s.overrides[tech];
const created = prev && !prev.deleted ? !!prev.created : !(tech in base); // a tag the visitor made
if (!body.devices || body.devices.length === 0) {
if (tech in base) s.overrides[tech] = { deleted: true };
else delete s.overrides[tech];
} else s.overrides[tech] = { devices: body.devices, skipUSMT: skip, created };
Store.save();
srv(`Updated tracking roster for Tech/Tag: ${tech}`, 'Green');
scheduleCycle('roster', 2500); // the worker picks the change up on its next sweep
@@ -724,7 +737,7 @@
'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 (state.DataVersion && Store.state.applied) state.DataVersion += '#' + Store.state.rev;
if (Sim.worker) return jsonResponse(Object.assign({}, state, Sim.worker));
return jsonResponse(state);
},
@@ -733,24 +746,24 @@
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);
if (!s.applied || !snap.rows.length) return jsonResponse(snap.data);
const { rows } = buildRows(await 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);
if (!s.applied || !snap.rows.length) return jsonResponse(snap.adv);
const { adv } = buildRows(await 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) {
if (s.applied) {
const snap = await serverSnapshot();
const { software } = buildRows(s.appliedTracked, snap.rows, snap.adv);
const { software } = buildRows(await 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: [] };
@@ -949,7 +962,7 @@
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;
fleet and a simulated team work the schedule &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>
+4 -2
View File
@@ -123,8 +123,10 @@ table.ov-heat td { padding: 0; text-align: center; border: 0; background: none;
.ov-day-bar { height: 6px; margin-top: 3px; }
.ov-has-day { cursor: pointer; background: var(--bg-card); border: 1px solid var(--border-color); }
.ov-has-day:hover, .ov-has-day:focus-visible, .ov-day.ov-selected { border-color: #3498db; box-shadow: 0 0 0 1px #3498db; }
.ov-today { box-shadow: inset 0 0 0 2px #3498db; }
.ov-today .ov-day-num { color: #3498db; font-weight: 700; }
/* Today: the date number in a filled badge (like a calendar app), so it can't be mistaken for
the selected day, which gets the outline and a tint */
.ov-today .ov-day-num { align-self: flex-start; background: #3498db; color: #fff; font-weight: 700; border-radius: 999px; min-width: 1.6em; padding: 0 0.4em; box-sizing: border-box; text-align: center; line-height: 1.55; }
.ov-day.ov-selected { background: rgba(52,152,219,0.16); }
/* tooltip */