diff options
Diffstat (limited to 'static')
| -rw-r--r-- | static/deck.css | 119 | ||||
| -rw-r--r-- | static/modules/k1c.js | 199 | ||||
| -rw-r--r-- | static/modules/llm.js | 10 | ||||
| -rw-r--r-- | static/modules/obs.js | 15 | ||||
| -rw-r--r-- | static/modules/sbbuilder.js | 10 | ||||
| -rw-r--r-- | static/offline.js | 37 |
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; +} |
