aboutsummaryrefslogtreecommitdiffstats
path: root/static
diff options
context:
space:
mode:
Diffstat (limited to 'static')
-rw-r--r--static/deck.css119
-rw-r--r--static/modules/k1c.js199
-rw-r--r--static/modules/llm.js10
-rw-r--r--static/modules/obs.js15
-rw-r--r--static/modules/sbbuilder.js10
-rw-r--r--static/offline.js37
6 files changed, 378 insertions, 12 deletions
diff --git a/static/deck.css b/static/deck.css
index 2484e1b..f0a9fdc 100644
--- a/static/deck.css
+++ b/static/deck.css
@@ -447,11 +447,122 @@ select {
.chan input[type=range] { flex: 1; min-width: 60px; accent-color: var(--udt-accent); }
.chan-pct { flex: none; min-width: 40px; text-align: right; font-size: 0.857rem; }
-.deck-offline {
- flex: 1;
+/* What a panel shows while the thing it watches is off (static/offline.js). */
+.offline {
+ height: 100%;
+ min-height: 140px;
display: flex;
+ flex-direction: column;
align-items: center;
justify-content: center;
- min-height: 120px;
- color: var(--udt-subtext0);
+ gap: 4px;
+ padding: 16px;
+ text-align: center;
+}
+.off-icon {
+ width: 48px;
+ height: 48px;
+ margin-bottom: 6px;
+ filter: grayscale(1);
+ opacity: .45;
+}
+.off-title { font-size: 1.143rem; font-weight: 600; color: var(--udt-subtext1, var(--udt-text)); }
+.off-detail { max-width: 32ch; font-size: 0.857rem; color: var(--udt-overlay0); }
+.off-poll {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-top: 8px;
+ font-size: 0.714rem;
+ text-transform: uppercase;
+ letter-spacing: .08em;
+ color: var(--udt-overlay0);
+}
+.off-poll i {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--udt-overlay0);
+ animation: off-pulse 2s ease-in-out infinite;
+}
+@keyframes off-pulse { 50% { opacity: .2; } }
+@media (prefers-reduced-motion: reduce) { .off-poll i { animation: none; } }
+
+/* The K1C tab. */
+.k1c { display: flex; flex-direction: column; gap: 14px; padding: 4px 2px; }
+.k1c-on { display: flex; flex-direction: column; gap: 14px; }
+.k1c-on[hidden], .k1c-off[hidden] { display: none; }
+.k1c-head { display: flex; align-items: center; gap: 12px; }
+.k1c-state {
+ flex: none;
+ padding: 4px 12px;
+ border-radius: 999px;
+ background: var(--udt-surface1);
+ font-weight: 600;
+}
+.k1c-state.good { background: var(--udt-green); color: var(--udt-crust); }
+.k1c-state.warn { background: var(--udt-yellow); color: var(--udt-crust); }
+.k1c-state.bad { background: var(--udt-red); color: var(--udt-crust); }
+.k1c-file { flex: 1; min-width: 0; font-size: 1.143rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.k1c-alert {
+ padding: 10px 14px;
+ border-radius: 12px;
+ border: 1px solid var(--udt-red);
+ color: var(--udt-red);
}
+.k1c-job { display: flex; align-items: center; gap: 16px; }
+.k1c-thumb {
+ flex: none;
+ width: 120px;
+ height: 120px;
+ object-fit: contain;
+ padding: 6px;
+ border-radius: 14px;
+ background: var(--udt-surface0);
+ border: 1px solid var(--udt-surface1);
+}
+.k1c-thumb[hidden] { display: none; }
+.k1c-progress { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
+.k1c-progress[hidden] { display: none; }
+.k1c-pct { font-size: 2.571rem; font-weight: 600; line-height: 1; }
+.k1c-progress .gbar { height: 10px; border-radius: 5px; }
+.k1c-times { font-size: 0.857rem; color: var(--udt-subtext0); }
+.k1c-body { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: start; }
+@media (max-width: 900px) { .k1c-body { grid-template-columns: 1fr; } }
+.k1c-tiles { grid-template-columns: 1fr 1fr; }
+.k1c-cam { width: 100%; border-radius: 12px; background: var(--udt-crust); }
+
+/* Press and hold: the fill grows across the button over --hold. */
+.estop {
+ flex: none;
+ position: relative;
+ overflow: hidden;
+ min-width: 150px;
+ min-height: 64px;
+ padding: 0 18px;
+ border: 2px solid var(--udt-red);
+ border-radius: 16px;
+ background: color-mix(in srgb, var(--udt-red) 22%, transparent);
+ color: var(--udt-text);
+ font: inherit;
+ font-weight: 700;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ touch-action: none;
+ cursor: pointer;
+}
+.estop::before {
+ content: "";
+ position: absolute;
+ inset: 0;
+ background: var(--udt-red);
+ transform: scaleX(0);
+ transform-origin: left;
+}
+.estop.holding::before { transform: scaleX(1); transition: transform var(--hold, 800ms) linear; }
+.estop.sent { background: var(--udt-red); color: var(--udt-crust); }
+.estop > span { position: relative; }
+.estop-label { font-size: 1.071rem; text-transform: uppercase; letter-spacing: .04em; }
+.estop-hint { font-size: 0.714rem; font-weight: 400; opacity: .7; }
diff --git a/static/modules/k1c.js b/static/modules/k1c.js
new file mode 100644
index 0000000..e7d3ed1
--- /dev/null
+++ b/static/modules/k1c.js
@@ -0,0 +1,199 @@
+// deck-it - the K1C tab: a Klipper printer, read-only bar the emergency stop
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program; if not, write to the Free Software Foundation, Inc.,
+// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+import { offline } from "../offline.js";
+
+// Holding the stop for this long fires it: fast in an emergency, but a
+// stray touch on an always-on tablet cannot kill a print.
+const HOLD_MS = 800;
+
+const STATES = {
+ printing: ["Printing", "good"],
+ paused: ["Paused", "warn"],
+ complete: ["Complete", "good"],
+ cancelled: ["Cancelled", "warn"],
+ error: ["Error", "bad"],
+ standby: ["Idle", ""],
+};
+
+const temp = (h) => (h?.temp == null ? "—" : `${Math.round(h.temp)}°`);
+const target = (h) => (h?.target ? `→ ${Math.round(h.target)}° · ${Math.round((h.power ?? 0) * 100)}%` : "off");
+function dur(s) {
+ if (s == null) return "—";
+ s = Math.round(s);
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ return h ? `${h}h ${String(m).padStart(2, "0")}m` : `${m}m`;
+}
+
+function tile(label, value, sub) {
+ const d = document.createElement("div");
+ d.className = "mtile";
+ d.innerHTML = '<span class="mlabel"></span><span class="mvalue num"></span><span class="msub"></span>';
+ d.children[0].textContent = label;
+ d.children[1].textContent = value;
+ d.children[2].textContent = sub ?? "";
+ return d;
+}
+
+async function emergencyStop(btn) {
+ btn.classList.add("sent");
+ btn.querySelector(".estop-label").textContent = "Stopping…";
+ try {
+ const r = await fetch("/api/k1c/emergency_stop", { method: "POST", headers: { "Content-Type": "application/json" }, body: "{}" });
+ btn.querySelector(".estop-label").textContent = r.ok ? "Stop sent" : "Stop FAILED";
+ } catch {
+ btn.querySelector(".estop-label").textContent = "Stop FAILED";
+ }
+ setTimeout(() => {
+ btn.classList.remove("sent");
+ btn.querySelector(".estop-label").textContent = "Emergency stop";
+ }, 4000);
+}
+
+// Press and hold: the ring fills over HOLD_MS, releasing early cancels.
+function armHold(btn) {
+ let timer = null;
+ const cancel = () => {
+ clearTimeout(timer);
+ timer = null;
+ btn.classList.remove("holding");
+ };
+ btn.addEventListener("pointerdown", (e) => {
+ e.preventDefault();
+ btn.setPointerCapture(e.pointerId);
+ btn.classList.add("holding");
+ timer = setTimeout(() => {
+ cancel();
+ emergencyStop(btn);
+ }, HOLD_MS);
+ });
+ btn.addEventListener("pointerup", cancel);
+ btn.addEventListener("pointercancel", cancel);
+ btn.style.setProperty("--hold", `${HOLD_MS}ms`);
+}
+
+export default {
+ title: "K1C",
+ tab: "K1C",
+ interval: 2,
+
+ mount(el) {
+ el.innerHTML = `<div class="k1c">
+ <div class="k1c-off"></div>
+ <div class="k1c-on">
+ <div class="k1c-head">
+ <span class="k1c-state"></span>
+ <span class="k1c-file"></span>
+ <button class="estop" aria-label="Emergency stop: press and hold">
+ <span class="estop-label">Emergency stop</span><span class="estop-hint">hold</span>
+ </button>
+ </div>
+ <div class="k1c-alert" hidden></div>
+ <div class="k1c-job">
+ <img class="k1c-thumb" alt="Model preview" hidden>
+ <div class="k1c-progress">
+ <div class="k1c-pct num"></div>
+ <div class="gbar"><i></i></div>
+ <div class="k1c-times"></div>
+ </div>
+ </div>
+ <div class="k1c-body">
+ <div class="stats k1c-tiles"></div>
+ <img class="k1c-cam" alt="Printer camera" hidden>
+ </div>
+ </div>
+ </div>`;
+ armHold(el.querySelector(".estop"));
+ },
+
+ update(st, el) {
+ const off = el.querySelector(".k1c-off");
+ const on = el.querySelector(".k1c-on");
+ off.hidden = !st.offline;
+ on.hidden = Boolean(st.offline);
+ if (st.offline) {
+ offline(off, st.unconfigured
+ ? { icon: "/icons/prusa-slicer.svg", title: "No printer configured",
+ detail: "Set the K1C's Moonraker address on keystone's deck-it page." }
+ : { icon: "/icons/prusa-slicer.svg", title: "K1C is off",
+ detail: "Switch the printer on and its progress, temperatures and camera appear here." });
+ return;
+ }
+
+ // Klipper itself down (after an emergency stop, or a config error) wins
+ // over the print state, which then reads stale.
+ const klippyDown = st.klippy && st.klippy !== "ready";
+ const [label, cls] = klippyDown ? [st.klippy === "shutdown" ? "Stopped" : "Klipper " + st.klippy, "bad"]
+ : STATES[st.state] ?? [st.state ?? "?", ""];
+ const pill = el.querySelector(".k1c-state");
+ pill.textContent = label;
+ pill.className = `k1c-state ${cls}`;
+ el.querySelector(".k1c-file").textContent = st.file ? st.file.replace(/\.gcode$/i, "") : "no job";
+ const alert = el.querySelector(".k1c-alert");
+ alert.hidden = !klippyDown;
+ alert.textContent = klippyDown ? `${st.message || "Klipper is not ready"}` : "";
+
+ // The slicer's preview of the model, loaded from the printer like the
+ // camera; set only when the URL changes, so it does not reload each poll.
+ const thumb = el.querySelector(".k1c-thumb");
+ if (!st.thumbnail || !st.file) thumb.hidden = true;
+ else if (thumb.dataset.src !== st.thumbnail) {
+ thumb.dataset.src = st.thumbnail;
+ thumb.hidden = true;
+ thumb.onload = () => { thumb.hidden = false; };
+ thumb.onerror = () => { thumb.hidden = true; };
+ thumb.src = st.thumbnail;
+ } else if (thumb.complete && thumb.naturalWidth) thumb.hidden = false;
+
+ const active = st.state === "printing" || st.state === "paused";
+ const pct = Math.round((st.progress ?? 0) * 100);
+ el.querySelector(".k1c-progress").hidden = !active && !(st.state === "complete");
+ el.querySelector(".k1c-pct").textContent = `${pct}%`;
+ el.querySelector(".k1c-progress .gbar > i").style.width = `${pct}%`;
+ const eta = st.remaining != null
+ ? new Date(Date.now() + st.remaining * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : null;
+ el.querySelector(".k1c-times").textContent = [
+ `elapsed ${dur(st.elapsed)}`,
+ st.remaining != null ? `left ${dur(st.remaining)} · done ${eta}` : null,
+ st.layers ? `layer ${st.layer ?? "?"} / ${st.layers}` : null,
+ ].filter(Boolean).join(" · ");
+
+ const tiles = el.querySelector(".k1c-tiles");
+ tiles.textContent = "";
+ const [x, y, z] = st.position ?? [];
+ tiles.append(
+ tile("Nozzle", temp(st.extruder), target(st.extruder)),
+ tile("Bed", temp(st.bed), target(st.bed)),
+ tile("Part fan", st.fan == null ? "—" : `${Math.round(st.fan * 100)}%`),
+ tile("Speed", st.speed_factor == null ? "—" : `${Math.round(st.speed_factor * 100)}%`),
+ tile("Z", z == null ? "—" : `${z.toFixed(2)}`, x == null ? "" : `X ${x.toFixed(0)} · Y ${y.toFixed(0)}`),
+ tile("Filament", st.filament ? `${(st.filament / 1000).toFixed(2)} m` : "—"),
+ );
+
+ // A fresh snapshot each poll, fetched by the tablet from the printer
+ // directly. ponytail: needs the tablet on the printer's LAN; proxy it
+ // through the server if it ever is not.
+ const cam = el.querySelector(".k1c-cam");
+ if (st.snapshot && !cam._loading) {
+ cam._loading = true;
+ cam.onload = () => { cam._loading = false; cam.hidden = false; };
+ cam.onerror = () => { cam._loading = false; cam.hidden = true; };
+ cam.src = `${st.snapshot}${st.snapshot.includes("?") ? "&" : "?"}t=${Date.now()}`;
+ }
+ if (!st.snapshot) cam.hidden = true;
+ },
+};
diff --git a/static/modules/llm.js b/static/modules/llm.js
index 22f8737..9ee28e1 100644
--- a/static/modules/llm.js
+++ b/static/modules/llm.js
@@ -14,6 +14,8 @@
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+import { offline } from "../offline.js";
+
const num = (v) => (v == null ? null : Number(v));
const money = (v) => (num(v) == null ? "—" : `$${num(v).toFixed(2)}`);
const tokens = (n) => {
@@ -83,11 +85,15 @@ export default {
update(state, el) {
const box = el.querySelector(".llm");
- box.textContent = "";
if (state.offline) {
- box.textContent = "llm-cost-dashboard offline";
+ offline(box, {
+ icon: "/logos/claude.svg",
+ title: "Usage dashboard is off",
+ detail: "Start llm-cost-dashboard and Claude and opencode usage appear here.",
+ });
return;
}
+ box.textContent = "";
const c = state.claude ?? {};
const o = state.opencode ?? {};
diff --git a/static/modules/obs.js b/static/modules/obs.js
index 3cc4197..5005ffc 100644
--- a/static/modules/obs.js
+++ b/static/modules/obs.js
@@ -17,6 +17,8 @@
// OBS as a stream-deck tab: icon-forward buttons and volume sliders, sized
// for a finger rather than a mouse.
+import { offline } from "../offline.js";
+
const ICONS = {
record: '<circle cx="12" cy="12" r="7"/>',
stream: '<path d="M2 8.8a15 15 0 0 1 20 0"/><path d="M5 12.5a10 10 0 0 1 14 0"/><path d="M8.5 16.2a5 5 0 0 1 7 0"/><circle cx="12" cy="20" r="1"/>',
@@ -154,11 +156,16 @@ export default {
update(state, el) {
const transport = el.querySelector(".deck-transport");
+ // Off, the empty Scenes/Audio/Sources/Transition blocks would only be
+ // headings, so they go and the card says what brings OBS back.
+ for (const cols of el.querySelectorAll(".deck-cols")) cols.hidden = !state.connected;
if (!state.connected) {
- transport.innerHTML = `<div class="deck-offline">${state.error ? `OBS offline: ${state.error}` : "OBS offline"}</div>`;
- for (const box of el.querySelectorAll(".scenes, .sources, .mixer, .transitions")) {
- box.textContent = "";
- }
+ offline(transport, {
+ icon: "/icons/com.obsproject.Studio.svg",
+ title: "OBS is not running",
+ detail: "Start OBS and its scenes, sources and audio appear here.",
+ reason: state.error,
+ });
return;
}
renderTransport(transport, state);
diff --git a/static/modules/sbbuilder.js b/static/modules/sbbuilder.js
index 9e49d90..6285dd5 100644
--- a/static/modules/sbbuilder.js
+++ b/static/modules/sbbuilder.js
@@ -14,6 +14,8 @@
// with this program; if not, write to the Free Software Foundation, Inc.,
// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+import { offline } from "../offline.js";
+
const gib = (b) => (b == null ? "?" : (b / 1073741824).toFixed(1));
const pct = (n) => (n == null ? "?" : `${Math.round(n)}%`);
const ago = (ts) => {
@@ -52,11 +54,15 @@ export default {
update(state, el) {
const box = el.querySelector(".sbb");
- box.textContent = "";
if (state.offline) {
- box.textContent = "build VM unreachable";
+ offline(box, {
+ icon: "/icons/virt-manager.svg",
+ title: "Build VM is off",
+ detail: "Start the VM and its jobs and package counts appear here.",
+ });
return;
}
+ box.textContent = "";
const s = state.stats ?? {};
const p = s.packages ?? {};
const sr = s.slackrepo ?? {};
diff --git a/static/offline.js b/static/offline.js
new file mode 100644
index 0000000..f3e7f62
--- /dev/null
+++ b/static/offline.js
@@ -0,0 +1,37 @@
+// deck-it - the card a panel shows while what it watches is switched off
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License along
+// with this program; if not, write to the Free Software Foundation, Inc.,
+// 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+
+// Replaces box's content with a dimmed app icon, a title, a line on what
+// brings it back, and a pulsing "checking" note, since the poll keeps going
+// and the panel returns by itself. icon is a URL; a missing one is hidden.
+// The raw reason, if any, is kept in the tooltip rather than shown.
+export function offline(box, { icon, title, detail, reason }) {
+ const key = `${icon}|${title}|${detail}|${reason ?? ""}`;
+ if (box._offline === key && box.firstElementChild?.classList.contains("offline")) return;
+ box._offline = key;
+ box.innerHTML = `<div class="offline">
+ <img class="off-icon" alt="">
+ <div class="off-title"></div>
+ <div class="off-detail"></div>
+ <div class="off-poll"><i></i>checking</div>
+ </div>`;
+ const img = box.querySelector(".off-icon");
+ img.onerror = () => img.remove();
+ img.src = icon;
+ box.querySelector(".off-title").textContent = title;
+ box.querySelector(".off-detail").textContent = detail;
+ if (reason) box.firstElementChild.title = reason;
+}