497 lines
19 KiB
JavaScript
497 lines
19 KiB
JavaScript
// 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 =
|
|
'<p class="error" style="padding:24px">workshop not configured — missing ?api=&token= in the URL.</p>';
|
|
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 = `<p class="error">Could not load: ${escapeHtml(err.message)}</p>`;
|
|
});
|
|
}
|
|
|
|
function loadProjects() {
|
|
return api("/projects")
|
|
.then((data) => {
|
|
projects = data.projects || [];
|
|
const options =
|
|
'<option value="">— none —</option>' +
|
|
projects
|
|
.map((p) => `<option value="${escapeHtml(p.slug)}">${escapeHtml(p.name)}</option>`)
|
|
.join("");
|
|
$("f-project").innerHTML = options;
|
|
$("filter-project").innerHTML =
|
|
'<option value="">Any project</option>' +
|
|
projects
|
|
.map((p) => `<option value="${escapeHtml(p.slug)}">${escapeHtml(p.name)}</option>`)
|
|
.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 =
|
|
'<p class="muted">Nothing here yet. "Add hardware" records what you own and — the part that ' +
|
|
'actually saves time — where you put it.</p>';
|
|
return;
|
|
}
|
|
|
|
$("list").innerHTML = hardware.map(renderRow).join("");
|
|
wireRows();
|
|
}
|
|
|
|
function renderRow(item) {
|
|
const projectOptions =
|
|
'<option value="">— none —</option>' +
|
|
projects
|
|
.map(
|
|
(p) =>
|
|
`<option value="${escapeHtml(p.slug)}"${p.slug === item.project_slug ? " selected" : ""}>${escapeHtml(
|
|
p.name
|
|
)}</option>`
|
|
)
|
|
.join("");
|
|
const statusOptions = Object.keys(STATUS_LABELS)
|
|
.map(
|
|
(s) =>
|
|
`<option value="${s}"${s === item.status ? " selected" : ""}>${escapeHtml(STATUS_LABELS[s])}</option>`
|
|
)
|
|
.join("");
|
|
|
|
return `<article class="item status-${escapeHtml(item.status)}" data-id="${item.id}">
|
|
<div class="item-head">
|
|
<input class="designation" type="text" value="${escapeHtml(item.designation)}" aria-label="Designation">
|
|
<input class="kind" type="text" value="${escapeHtml(item.kind)}" placeholder="kind" aria-label="Kind">
|
|
<input class="quantity" type="number" min="0" step="1" value="${escapeHtml(item.quantity)}" aria-label="Quantity">
|
|
</div>
|
|
<div class="item-body">
|
|
<label class="field">Where
|
|
<input class="location" type="text" value="${escapeHtml(item.storage_location)}" placeholder="drawer 3, blue box">
|
|
</label>
|
|
<label class="field">Status
|
|
<select class="status">${statusOptions}</select>
|
|
</label>
|
|
<label class="field">Project
|
|
<select class="project">${projectOptions}</select>
|
|
</label>
|
|
</div>
|
|
<p class="hint">${escapeHtml(STATUS_HINTS[item.status] || "")}</p>
|
|
<div class="item-actions">
|
|
<button class="save primary">Save</button>
|
|
<button class="delete danger">Delete</button>
|
|
<span class="row-status muted"></span>
|
|
</div>
|
|
</article>`;
|
|
}
|
|
|
|
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 =
|
|
'<p class="muted">No cameras configured — set WORKSHOP_CAMERAS and GO2RTC_URL in ' +
|
|
"workshop.env. Both are needed: the names live here, the streams live in go2rtc.</p>";
|
|
return;
|
|
}
|
|
el.innerHTML = cameras
|
|
.map(
|
|
(cam) => `<figure class="camera">
|
|
<iframe src="${escapeHtml(base)}/stream.html?src=${encodeURIComponent(cam.stream)}&mode=webrtc"
|
|
loading="lazy" allowfullscreen title="${escapeHtml(cam.name)}"></iframe>
|
|
<figcaption>${escapeHtml(cam.name)}</figcaption>
|
|
</figure>`
|
|
)
|
|
.join("");
|
|
})
|
|
.catch((err) => {
|
|
el.innerHTML = `<p class="error">Could not load cameras: ${escapeHtml(err.message)}</p>`;
|
|
});
|
|
}
|
|
|
|
// --- 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) => `<div class="item ${r.ok ? (r.current ? "status-available" : "status-assigned") : "status-retired"}">
|
|
<div class="item-head">
|
|
<strong>${escapeHtml(r.hostname)}</strong>
|
|
<span class="muted">${escapeHtml(r.platform)} · v${escapeHtml(r.version ?? "?")}</span>
|
|
<span class="muted">${r.ok ? "ok" : "FAILED"}${
|
|
r.ok && !r.current ? " · behind the published version" : ""
|
|
}</span>
|
|
</div>
|
|
<p class="hint">${escapeHtml(relativeTimeish(r.reported_at))}${
|
|
r.detail ? ` — ${escapeHtml(String(r.detail).slice(-200))}` : ""
|
|
}</p>
|
|
</div>`
|
|
)
|
|
.join("")
|
|
: '<p class="muted">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.</p>";
|
|
})
|
|
.catch((err) => {
|
|
$("fleet-slots").innerHTML = `<p class="error">Could not load: ${escapeHtml(err.message)}</p>`;
|
|
});
|
|
}
|
|
|
|
function renderSlot(slot) {
|
|
const live = slot.published_version;
|
|
const versions = (slot.versions || [])
|
|
.map(
|
|
(v) => `<li>
|
|
v${v.version} · <code>${escapeHtml(String(v.sha256).slice(0, 12))}</code>
|
|
${v.published ? '<strong>· published</strong>' : `<button class="row-btn publish-btn"
|
|
data-platform="${escapeHtml(slot.platform)}" data-version="${v.version}">Publish</button>`}
|
|
${v.note ? `<span class="muted"> — ${escapeHtml(v.note)}</span>` : ""}
|
|
</li>`
|
|
)
|
|
.join("");
|
|
|
|
return `<article class="item ${live ? "status-available" : "status-retired"}">
|
|
<div class="item-head">
|
|
<strong>${escapeHtml(slot.label)}</strong>
|
|
<span class="muted">${escapeHtml(slot.covers)}</span>
|
|
</div>
|
|
<p class="hint">${
|
|
live
|
|
? `Published: v${live} · <code>${escapeHtml(String(slot.published_sha256).slice(0, 12))}</code>`
|
|
: "<strong>Nothing published for this platform yet.</strong>"
|
|
}</p>
|
|
${
|
|
slot.runs_on_device
|
|
? ""
|
|
: '<p class="hint"><strong>These devices cannot run a script.</strong> This slot holds the ' +
|
|
"CheckMK-<em>server</em> 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.</p>'
|
|
}
|
|
<label class="field">Paste the script
|
|
<textarea class="script-body" rows="6" data-platform="${escapeHtml(slot.platform)}"
|
|
placeholder="#!/bin/sh # CheckMK agent install for ${escapeHtml(slot.label)}"></textarea>
|
|
</label>
|
|
<div class="item-actions">
|
|
<input class="script-note" type="text" placeholder="what changed (optional)">
|
|
<button class="row-btn upload-btn" data-platform="${escapeHtml(slot.platform)}">Upload as draft</button>
|
|
<span class="row-status muted"></span>
|
|
</div>
|
|
${versions ? `<ul class="versions">${versions}</ul>` : ""}
|
|
</article>`;
|
|
}
|
|
|
|
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 =
|
|
'<p class="muted">No CheckMK server and no firewalls configured. Nothing is being ' +
|
|
"watched, which is not the same as nothing being wrong.</p>";
|
|
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) => `<li>${escapeHtml([p.host, p.service, p.state, p.output].filter(Boolean).join(" · "))}</li>`)
|
|
.join("");
|
|
return `<article class="health-row ${escapeHtml(row.state)}">
|
|
<h3>${escapeHtml(row.target)} <span class="muted">(${escapeHtml(row.source)})</span></h3>
|
|
<p>${escapeHtml(row.state)}${since}</p>
|
|
<p class="muted">${escapeHtml(row.summary)}</p>
|
|
${problems ? `<ul>${problems}</ul>` : ""}
|
|
</article>`;
|
|
})
|
|
.join("") || '<p class="muted">No samples yet — the poller runs every few minutes.</p>';
|
|
})
|
|
.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);
|