${escapeHtml(STATUS_HINTS[item.status] || "")}
// 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] || "")}
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) => `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(relativeTimeish(r.reported_at))}${ r.detail ? ` — ${escapeHtml(String(r.detail).slice(-200))}` : "" }
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) => `${escapeHtml(String(v.sha256).slice(0, 12))}
${v.published ? '· published' : ``}
${v.note ? ` — ${escapeHtml(v.note)}` : ""}
${
live
? `Published: v${live} · ${escapeHtml(String(slot.published_sha256).slice(0, 12))}`
: "Nothing published for this platform yet."
}
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.
' }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(row.state)}${since}
${escapeHtml(row.summary)}
${problems ? `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);