// workshop inventory editor. Vanilla JS, no framework, no build step — same choice as // pantry-vision/frontend and the canvas SDKs. // // Config comes from URL query params (?api=...&token=...), never hardcoded: this file // is a generic static asset served read-only, with no secret in it. // // WHY THIS PAGE EXISTS AT ALL, given the assistant can write the same rows: the // inventory is the one table that describes *physical reality* — where a thing is, // whether it is still on the shelf — and physical reality drifts without telling // anybody. Every other table here records something that was said or decided, and // those are only ever wrong if someone recorded them wrong. This one goes stale by // itself, so it needs somewhere a person can sit down and fix it. "use strict"; const params = new URLSearchParams(location.search); const API = (params.get("api") || "").replace(/\/$/, ""); const TOKEN = params.get("token") || ""; if (!API || !TOKEN) { document.body.innerHTML = '

workshop not configured — missing ?api=&token= in the URL.

'; throw new Error("workshop: missing ?api=/&token= query params"); } const $ = (id) => document.getElementById(id); const STATUS_LABELS = { available: "Available", assigned: "Reserved", in_use: "In use", retired: "Retired", }; // The sentence under each status, shown on the row. Reserved and in-use look similar // in a list and mean very different things when you are deciding whether you can grab // something right now, so the list says which out loud rather than relying on a colour. const STATUS_HINTS = { available: "on the shelf, unpromised", assigned: "spoken for, but still on the shelf", in_use: "installed and working — taking it back means taking something apart", retired: "dead, sold or given away", }; function api(path, options) { options = options || {}; options.headers = Object.assign({ Authorization: `Bearer ${TOKEN}` }, options.headers || {}); return fetch(`${API}${path}`, options).then((res) => res.json().catch(() => ({})).then((body) => { if (!res.ok) throw new Error(body.message || body.error || `${res.status} ${res.statusText}`); return body; }) ); } const send = (path, body, method) => api(path, { method: method || "POST", headers: { "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body), }); function escapeHtml(s) { return String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]) ); } function setStatus(text, isError) { $("status").textContent = text || ""; $("status").className = isError ? "error" : "muted"; } // --- state ------------------------------------------------------------------------- let hardware = []; let projects = []; function load() { const query = new URLSearchParams(); if ($("search").value.trim()) query.set("q", $("search").value.trim()); if ($("filter-status").value) query.set("status", $("filter-status").value); if ($("filter-project").value) query.set("project", $("filter-project").value); return api(`/hardware?${query}`) .then((data) => { hardware = data.hardware || []; render(); }) .catch((err) => { $("list").innerHTML = `

Could not load: ${escapeHtml(err.message)}

`; }); } function loadProjects() { return api("/projects") .then((data) => { projects = data.projects || []; const options = '' + projects .map((p) => ``) .join(""); $("f-project").innerHTML = options; $("filter-project").innerHTML = '' + projects .map((p) => ``) .join(""); }) .catch(() => { // A workshop with no projects yet is the normal first-run state, not an error. }); } function render() { const counts = hardware.reduce((acc, h) => { acc[h.status] = (acc[h.status] || 0) + 1; return acc; }, {}); $("counts").textContent = hardware.length ? Object.keys(STATUS_LABELS) .filter((s) => counts[s]) .map((s) => `${counts[s]} ${STATUS_LABELS[s].toLowerCase()}`) .join(" · ") : ""; if (!hardware.length) { $("list").innerHTML = '

Nothing here yet. "Add hardware" records what you own and — the part that ' + 'actually saves time — where you put it.

'; return; } $("list").innerHTML = hardware.map(renderRow).join(""); wireRows(); } function renderRow(item) { const projectOptions = '' + projects .map( (p) => `` ) .join(""); const statusOptions = Object.keys(STATUS_LABELS) .map( (s) => `` ) .join(""); return `

${escapeHtml(STATUS_HINTS[item.status] || "")}

`; } function wireRows() { $("list").querySelectorAll(".item").forEach((el) => { const id = Number(el.dataset.id); const note = el.querySelector(".row-status"); // Changing the project is the edit people come here to make, so it saves on the // spot rather than waiting for a Save nobody remembers to press. Everything else // is free text mid-typing and waits. el.querySelector(".project").addEventListener("change", () => saveRow(el, id, note)); el.querySelector(".status").addEventListener("change", () => saveRow(el, id, note)); el.querySelector(".save").addEventListener("click", () => saveRow(el, id, note)); el.querySelector(".delete").addEventListener("click", () => { const name = el.querySelector(".designation").value.trim(); if (!window.confirm(`Delete ${name}? This removes the record, not the thing.`)) return; send(`/hardware/${id}`, undefined, "DELETE") .then(() => load()) .catch((err) => { note.textContent = err.message; note.className = "row-status error"; }); }); }); } function saveRow(el, id, note) { const body = { designation: el.querySelector(".designation").value.trim(), kind: el.querySelector(".kind").value.trim(), quantity: Number(el.querySelector(".quantity").value) || 0, storage_location: el.querySelector(".location").value.trim(), // Both are sent together so the server's own coupling rule decides the outcome — // including the one that matters, that naming a project on something already // `in_use` does not quietly demote it to merely reserved. status: el.querySelector(".status").value, project_slug: el.querySelector(".project").value || null, }; note.textContent = "Saving…"; note.className = "row-status muted"; send(`/hardware/${id}`, body) .then(() => load()) .catch((err) => { note.textContent = err.message; note.className = "row-status error"; }); } // --- add --------------------------------------------------------------------------- $("add-btn").addEventListener("click", () => { $("add-form").hidden = !$("add-form").hidden; if (!$("add-form").hidden) $("f-designation").focus(); }); $("add-cancel").addEventListener("click", () => { $("add-form").hidden = true; }); $("add-form").addEventListener("submit", (event) => { event.preventDefault(); setStatus("Adding…"); send("/hardware", { designation: $("f-designation").value.trim(), kind: $("f-kind").value.trim(), quantity: Number($("f-quantity").value) || 1, storage_location: $("f-location").value.trim(), status: $("f-status").value, project_slug: $("f-project").value || null, notes: $("f-notes").value.trim(), }) .then(() => { $("add-form").reset(); $("add-form").hidden = true; setStatus(""); return load(); }) .catch((err) => setStatus(err.message, true)); }); // --- filters ----------------------------------------------------------------------- let searchTimer = null; $("search").addEventListener("input", () => { clearTimeout(searchTimer); searchTimer = setTimeout(load, 200); }); $("filter-status").addEventListener("change", load); $("filter-project").addEventListener("change", load); // --- Tabs --------------------------------------------------------------------------- document.querySelectorAll(".tab").forEach((tab) => { tab.addEventListener("click", () => { document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t === tab)); document.querySelectorAll(".panel").forEach((p) => p.classList.toggle("active", p.id === tab.dataset.tab)); if (tab.dataset.tab === "cameras") loadCameras(); if (tab.dataset.tab === "fleet") loadFleet(); }); }); // --- Cameras -------------------------------------------------------------------------- // go2rtc's own stream page, in an iframe, one per camera. Deliberately NOT a hand-rolled // WebRTC client: go2rtc already ships a player that negotiates WebRTC and falls back to // MSE on its own, and reimplementing that here would be a second thing to keep working // against a moving browser target. The workshop service never proxies the video — a // Python HTTP server in the path of an H.264 stream is how a working camera starts // stuttering. function loadCameras() { const el = $("camera-grid"); api("/cameras") .then((data) => { const cameras = data.cameras || []; const base = (data.go2rtc_url || "").replace(/\/$/, ""); if (!cameras.length || !base) { el.innerHTML = '

No cameras configured — set WORKSHOP_CAMERAS and GO2RTC_URL in ' + "workshop.env. Both are needed: the names live here, the streams live in go2rtc.

"; return; } el.innerHTML = cameras .map( (cam) => `
${escapeHtml(cam.name)}
` ) .join(""); }) .catch((err) => { el.innerHTML = `

Could not load cameras: ${escapeHtml(err.message)}

`; }); } // --- Fleet scripts -------------------------------------------------------------------- // The admin surface for getting every machine into CheckMK. Two deliberate properties // show up in this UI rather than being buried: an empty slot is LISTED (the thing a // person setting this up needs to see is which platforms are still uncovered), and // publish is a separate button from upload. function loadFleet() { api("/fleet") .then((data) => { $("fleet-slots").innerHTML = (data.slots || []).map(renderSlot).join(""); wireFleet(); const reports = data.reports || []; $("fleet-reports").innerHTML = reports.length ? reports .map( (r) => `
${escapeHtml(r.hostname)} ${escapeHtml(r.platform)} · v${escapeHtml(r.version ?? "?")} ${r.ok ? "ok" : "FAILED"}${ r.ok && !r.current ? " · behind the published version" : "" }

${escapeHtml(relativeTimeish(r.reported_at))}${ r.detail ? ` — ${escapeHtml(String(r.detail).slice(-200))}` : "" }

` ) .join("") : '

Nothing has reported yet. An endpoint appears here the first ' + "time its timer runs, which is also how you find out the timer is working.

"; }) .catch((err) => { $("fleet-slots").innerHTML = `

Could not load: ${escapeHtml(err.message)}

`; }); } function renderSlot(slot) { const live = slot.published_version; const versions = (slot.versions || []) .map( (v) => `
  • v${v.version} · ${escapeHtml(String(v.sha256).slice(0, 12))} ${v.published ? '· published' : ``} ${v.note ? ` — ${escapeHtml(v.note)}` : ""}
  • ` ) .join(""); return `
    ${escapeHtml(slot.label)} ${escapeHtml(slot.covers)}

    ${ live ? `Published: v${live} · ${escapeHtml(String(slot.published_sha256).slice(0, 12))}` : "Nothing published for this platform yet." }

    ${ slot.runs_on_device ? "" : '

    These devices cannot run a script. This slot holds the ' + "CheckMK-server side instead — an SNMP config or a special agent that polls them. " + "Nothing fetches it, so no endpoint will ever report against it; the slot exists so " + '"are these monitored?" has a visible answer.

    ' }
    ${versions ? `` : ""}
    `; } function wireFleet() { $("fleet-slots").querySelectorAll(".upload-btn").forEach((btn) => btn.addEventListener("click", () => { const card = btn.closest(".item"); const note = card.querySelector(".row-status"); const body = card.querySelector(".script-body").value; if (!body.trim()) { note.textContent = "Nothing to upload."; return; } note.textContent = "Uploading…"; send("/fleet/upload", { platform: btn.dataset.platform, body, note: card.querySelector(".script-note").value.trim(), }) .then((r) => { note.textContent = r.message || "Stored as a draft."; return loadFleet(); }) .catch((err) => { note.textContent = err.message; note.className = "row-status error"; }); }) ); $("fleet-slots").querySelectorAll(".publish-btn").forEach((btn) => btn.addEventListener("click", () => { if ( !window.confirm( `Publish v${btn.dataset.version} for ${btn.dataset.platform}?\n\n` + "Every endpoint of this platform will run it as root the next time its timer fires." ) ) return; send("/fleet/publish", { platform: btn.dataset.platform, version: Number(btn.dataset.version), }) .then(() => loadFleet()) .catch((err) => window.alert(err.message)); }) ); } // --- System health -------------------------------------------------------------------- // One pill in the header rather than a page you have to remember to open: health you // only see when you go looking for it is health you find out about from the failure. const HEALTH_LABELS = { ok: "All OK", problem: "Problems", unreachable: "Unreachable" }; function loadHealth() { return api("/health") .then((data) => { const pill = $("health-pill"); if (!data.configured) { // Nothing being watched and everything being fine look identical from here, and // only one of them is good news — so this says which. pill.textContent = "health: not configured"; pill.className = "pill unknown"; $("health-body").innerHTML = '

    No CheckMK server and no firewalls configured. Nothing is being ' + "watched, which is not the same as nothing being wrong.

    "; return; } const overall = data.overall || "ok"; pill.textContent = HEALTH_LABELS[overall] || overall; pill.className = `pill ${overall}`; $("health-body").innerHTML = (data.results || []) .map((row) => { const since = row.since ? ` — since ${escapeHtml(relativeTimeish(row.since))}` : ""; const problems = (row.detail || []) .map((p) => `
  • ${escapeHtml([p.host, p.service, p.state, p.output].filter(Boolean).join(" · "))}
  • `) .join(""); return `

    ${escapeHtml(row.target)} (${escapeHtml(row.source)})

    ${escapeHtml(row.state)}${since}

    ${escapeHtml(row.summary)}

    ${problems ? `` : ""}
    `; }) .join("") || '

    No samples yet — the poller runs every few minutes.

    '; }) .catch(() => { $("health-pill").textContent = "health: unknown"; $("health-pill").className = "pill unknown"; }); } function relativeTimeish(iso) { const then = Date.parse(iso || ""); if (!Number.isFinite(then)) return iso || "an unknown time"; const mins = Math.round((Date.now() - then) / 60000); if (mins < 60) return `${mins} min ago`; const hours = Math.round(mins / 60); return hours < 48 ? `${hours}h ago` : `${Math.round(hours / 24)}d ago`; } $("health-pill").addEventListener("click", () => { loadHealth(); $("health-dialog").showModal(); }); loadHealth(); setInterval(loadHealth, 60000); loadProjects().then(load);