diff options
Diffstat (limited to 'static')
| -rw-r--r-- | static/app.js | 47 | ||||
| -rw-r--r-- | static/deck.css | 5 | ||||
| -rw-r--r-- | static/modules/music.js | 48 | ||||
| -rw-r--r-- | static/modules/obs.js | 2 | ||||
| -rw-r--r-- | static/modules/stats.js | 49 | ||||
| -rw-r--r-- | static/modules/windows.js | 65 |
6 files changed, 195 insertions, 21 deletions
diff --git a/static/app.js b/static/app.js index 7a9ff40..ed19c60 100644 --- a/static/app.js +++ b/static/app.js @@ -3,31 +3,34 @@ // mount(el), update(state, el)}. A missing or broken panel is skipped. const grid = document.getElementById("grid"); +const refreshers = []; async function mountModule(name) { - let panel; try { - panel = (await import(`./modules/${name}.js`)).default; + const panel = (await import(`./modules/${name}.js`)).default; + const el = document.createElement("section"); + el.className = "panel"; + el.dataset.module = name; + const title = document.createElement("h2"); + title.textContent = panel.title ?? name; + el.append(title); + grid.append(el); + panel.mount?.(el); + + const poll = async () => { + try { + const r = await fetch(`/api/${name}`); + if (r.ok) panel.update?.(await r.json(), el); + } catch { + // keep the last state; the next tick retries + } + }; + refreshers.push(poll); + await poll(); + setInterval(poll, (panel.interval ?? 1) * 1000); } catch { - return; + // a broken panel must not take down the rest of the dashboard } - const el = document.createElement("section"); - el.className = "panel"; - el.dataset.module = name; - el.innerHTML = `<h2>${panel.title ?? name}</h2>`; - grid.append(el); - panel.mount?.(el); - - const poll = async () => { - try { - const r = await fetch(`/api/${name}`); - if (r.ok) panel.update?.(await r.json(), el); - } catch { - // keep the last state; the next tick retries - } - }; - await poll(); - setInterval(poll, (panel.interval ?? 1) * 1000); } async function boot() { @@ -36,4 +39,8 @@ async function boot() { for (const { name } of await r.json()) await mountModule(name); } +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "visible") refreshers.forEach((fn) => fn()); +}); + boot(); diff --git a/static/deck.css b/static/deck.css index abc4566..afe9379 100644 --- a/static/deck.css +++ b/static/deck.css @@ -79,6 +79,11 @@ select { .sbb .id { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.stats .row, .windows .row { margin-bottom: 6px; } +.now b { display: block; font-size: 16px; } +.wt { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; } +.windows .row.on { color: var(--udt-accent); } + .llm .meter { height: 6px; background: var(--udt-surface1); border-radius: 3px; overflow: hidden; margin: 2px 0 8px; } .llm .meter > i { display: block; height: 100%; background: var(--udt-accent); } diff --git a/static/modules/music.js b/static/modules/music.js new file mode 100644 index 0000000..de9df7f --- /dev/null +++ b/static/modules/music.js @@ -0,0 +1,48 @@ +async function post(action) { + try { + await fetch(`/api/music/${action}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + } catch { + // the next poll shows the real state + } +} + +function button(label, action) { + const b = document.createElement("button"); + b.textContent = label; + b.onclick = () => post(action); + return b; +} + +export default { + title: "Music", + interval: 2, + + mount(el) { + const box = document.createElement("div"); + box.className = "music"; + box.innerHTML = ` + <div class="now"><b class="title">—</b><span class="artist muted"></span></div> + <div class="row controls"></div> + <div class="row vol"></div>`; + box.querySelector(".controls").append( + button("⏮", "prev"), button("⏯", "play"), button("⏭", "next")); + box.querySelector(".vol").append( + button("Vol−", "voldown"), button("Mute", "mute"), button("Vol+", "volup")); + el.append(box); + }, + + update(st, el) { + const box = el.querySelector(".music"); + box.querySelector(".title").textContent = st.title || "Nothing playing"; + box.querySelector(".artist").textContent = st.artist || ""; + box.querySelector(".controls button:nth-child(2)").textContent = + st.status === "Playing" ? "⏸" : "▶"; + const mute = box.querySelector(".vol button:nth-child(2)"); + mute.classList.toggle("on", Boolean(st.volume?.muted)); + mute.textContent = st.volume?.muted ? "Unmute" : "Mute"; + }, +}; diff --git a/static/modules/obs.js b/static/modules/obs.js index c6a3ebc..9c5c34c 100644 --- a/static/modules/obs.js +++ b/static/modules/obs.js @@ -40,7 +40,7 @@ export default { const sources = el.querySelector(".obs-sources"); if (!state.connected) { - controls.textContent = "OBS offline"; + controls.textContent = state.error ? `OBS offline: ${state.error}` : "OBS offline"; scenes.textContent = audio.textContent = sources.textContent = ""; return; } diff --git a/static/modules/stats.js b/static/modules/stats.js new file mode 100644 index 0000000..b29b4ae --- /dev/null +++ b/static/modules/stats.js @@ -0,0 +1,49 @@ +const gb = (kb) => (kb / 1048576).toFixed(1); +const rate = (b) => (b >= 1048576 ? `${(b / 1048576).toFixed(1)} MB/s` : `${Math.round(b / 1024)} KB/s`); +const uptime = (s) => { + const d = Math.floor(s / 86400); + const h = Math.floor((s % 86400) / 3600); + const m = Math.floor((s % 3600) / 60); + return d ? `${d}d ${h}h` : `${h}h ${m}m`; +}; +const gpu = (g) => { + if (!g) return "—"; + if (g.asleep) return `${g.name} (asleep)`; + return `${g.name} ${g.util}%${g.temp != null ? ` · ${g.temp}°C` : ""}`; +}; + +function row(label, value) { + const div = document.createElement("div"); + div.className = "row"; + const name = document.createElement("span"); + name.className = "muted"; + name.textContent = label; + const val = document.createElement("span"); + val.className = "num"; + val.textContent = value; + div.append(name, val); + return div; +} + +export default { + title: "System", + interval: 1, + + mount(el) { + const box = document.createElement("div"); + box.className = "stats"; + el.append(box); + }, + + update(st, el) { + const box = el.querySelector(".stats"); + box.textContent = ""; + box.append( + row("CPU", `${st.cpu}%${st.cpuTemp != null ? ` · ${st.cpuTemp}°C` : ""}`), + row("Memory", `${gb(st.memUsed)} / ${gb(st.memTotal)} GiB`), + row("GPU", gpu(st.gpu)), + row("Network", `↓ ${rate(st.net.down)} ↑ ${rate(st.net.up)}`), + row("Uptime", uptime(st.uptime)), + ); + }, +}; diff --git a/static/modules/windows.js b/static/modules/windows.js new file mode 100644 index 0000000..7a2eeb8 --- /dev/null +++ b/static/modules/windows.js @@ -0,0 +1,65 @@ +async function post(action, body = {}) { + try { + await fetch(`/api/windows/${action}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + } catch { + // the next poll shows the real state + } +} + +function button(label, onclick) { + const b = document.createElement("button"); + b.textContent = label; + b.onclick = onclick; + return b; +} + +export default { + title: "Windows", + interval: 1, + + mount(el) { + const box = document.createElement("div"); + box.className = "windows"; + box.innerHTML = ` + <div class="row ws"></div> + <div class="winlist"></div> + <div class="row launches"></div>`; + el.append(box); + }, + + update(st, el) { + const box = el.querySelector(".windows"); + + const ws = box.querySelector(".ws"); + ws.textContent = ""; + for (const id of st.workspaces ?? []) { + const b = button(String(id), () => post("workspace", { n: id })); + b.classList.toggle("on", (st.windows ?? []).some((w) => w.workspace === id)); + ws.append(b); + } + + const list = box.querySelector(".winlist"); + list.textContent = ""; + for (const w of (st.windows ?? []).slice(0, 12)) { + const row = document.createElement("div"); + row.className = "row"; + row.classList.toggle("on", Boolean(w.active)); + const title = document.createElement("span"); + title.className = "wt"; + title.textContent = w.title || w.class; + title.onclick = () => post("focus", { address: w.address }); + row.append(title, button("×", () => post("close", { address: w.address }))); + list.append(row); + } + + const launches = box.querySelector(".launches"); + launches.textContent = ""; + for (const key of st.launches ?? []) { + launches.append(button(key, () => post("launch", { key }))); + } + }, +}; |
