SmartestHome/workshop/frontend/app.js

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) =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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&#10;# 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);