|
|
@@ -0,0 +1,164 @@
|
|
|
+<!doctype html>
|
|
|
+<html data-theme="dark">
|
|
|
+<head>
|
|
|
+<meta charset="utf-8">
|
|
|
+<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
+<title>sm121</title>
|
|
|
+<style>
|
|
|
+ :root[data-theme="dark"] {
|
|
|
+ --bg: #0d1117; --panel: #161b22; --fg: #e6edf3; --muted: #8b949e;
|
|
|
+ --border: #30363d; --accent: #58a6ff;
|
|
|
+ --led-on: #3fb950; --led-off: #f85149; --led-busy: #d29922; --led-free: #30363d;
|
|
|
+ --glow-on: 0 0 8px 2px rgba(63,185,80,.55); --glow-busy: 0 0 8px 2px rgba(210,153,34,.55);
|
|
|
+ }
|
|
|
+ :root[data-theme="light"] {
|
|
|
+ --bg: #f6f8fa; --panel: #ffffff; --fg: #1f2328; --muted: #656d76;
|
|
|
+ --border: #d0d7de; --accent: #0969da;
|
|
|
+ --led-on: #1a7f37; --led-off: #cf222e; --led-busy: #9a6700; --led-free: #d0d7de;
|
|
|
+ --glow-on: 0 0 8px 2px rgba(26,127,55,.35); --glow-busy: 0 0 8px 2px rgba(154,103,0,.35);
|
|
|
+ }
|
|
|
+ * { box-sizing: border-box; }
|
|
|
+ body {
|
|
|
+ margin: 0; background: var(--bg); color: var(--fg);
|
|
|
+ font: 14px/1.45 ui-sans-serif, system-ui, "SF Pro", sans-serif;
|
|
|
+ transition: background .25s, color .25s;
|
|
|
+ }
|
|
|
+ header {
|
|
|
+ display: flex; align-items: center; gap: 12px;
|
|
|
+ padding: 10px 16px; border-bottom: 1px solid var(--border);
|
|
|
+ background: var(--panel);
|
|
|
+ }
|
|
|
+ header h1 { font-size: 15px; font-weight: 600; margin: 0; letter-spacing: .3px; }
|
|
|
+ #sse {
|
|
|
+ display: inline-flex; align-items: center; gap: 6px;
|
|
|
+ font-size: 12px; color: var(--muted);
|
|
|
+ }
|
|
|
+ #sse .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--led-free); transition: background .3s; }
|
|
|
+ #sse.ok .dot { background: var(--led-on); box-shadow: var(--glow-on); }
|
|
|
+ #sse.err .dot { background: var(--led-off); }
|
|
|
+ #theme {
|
|
|
+ margin-left: auto; cursor: pointer; border: 1px solid var(--border);
|
|
|
+ background: var(--panel); color: var(--fg); border-radius: 6px;
|
|
|
+ padding: 4px 10px; font-size: 12px; transition: transform .12s;
|
|
|
+ }
|
|
|
+ #theme:active { transform: scale(.95); }
|
|
|
+
|
|
|
+ .stage { padding: 20px; }
|
|
|
+ .win {
|
|
|
+ background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
|
|
+ box-shadow: 0 8px 28px rgba(0,0,0,.25); overflow: hidden;
|
|
|
+ animation: win-in .28s cubic-bezier(.2,.9,.3,1.2);
|
|
|
+ }
|
|
|
+ @keyframes win-in { from { opacity: 0; transform: scale(.94) translateY(6px); } to { opacity: 1; transform: none; } }
|
|
|
+ .titlebar {
|
|
|
+ padding: 7px 12px; font-size: 12px; color: var(--muted);
|
|
|
+ border-bottom: 1px solid var(--border); display: flex; gap: 8px; align-items: center;
|
|
|
+ user-select: none; cursor: grab;
|
|
|
+ }
|
|
|
+ .titlebar b { color: var(--fg); font-weight: 600; }
|
|
|
+ .grid { padding: 10px 12px; }
|
|
|
+ .cluster { margin-bottom: 14px; }
|
|
|
+ .cluster-head {
|
|
|
+ display: flex; gap: 10px; align-items: baseline; font-size: 13px;
|
|
|
+ font-weight: 600; margin: 0 0 6px 2px;
|
|
|
+ }
|
|
|
+ .cluster-head .free { font-weight: 400; font-size: 12px; color: var(--muted); }
|
|
|
+ .node {
|
|
|
+ display: grid; grid-template-columns: 14px 14px 190px 1fr; gap: 10px; align-items: center;
|
|
|
+ padding: 6px 8px; border-radius: 8px; margin-bottom: 2px;
|
|
|
+ transition: background .15s;
|
|
|
+ }
|
|
|
+ .node:hover { background: color-mix(in srgb, var(--fg) 6%, transparent); }
|
|
|
+ .led { width: 10px; height: 10px; border-radius: 50%; background: var(--led-free); transition: background .35s, box-shadow .35s; }
|
|
|
+ .led.on { background: var(--led-on); box-shadow: var(--glow-on); }
|
|
|
+ .led.off { background: var(--led-off); }
|
|
|
+ .led.busy{ background: var(--led-busy); box-shadow: var(--glow-busy); }
|
|
|
+ .led.pulse { animation: pulse 1.1s ease-in-out infinite; }
|
|
|
+ @keyframes pulse { 50% { opacity: .35; } }
|
|
|
+ .nname { font-weight: 500; font-size: 13px; }
|
|
|
+ .nmeta { font-size: 12px; color: var(--muted); font-family: ui-monospace, monospace; }
|
|
|
+ .nstate { font-size: 12px; }
|
|
|
+ .nstate .up { color: var(--led-on); } .nstate .down { color: var(--led-off); }
|
|
|
+ .nstate .occupied { color: var(--led-busy); }
|
|
|
+ .empty { color: var(--muted); padding: 18px; text-align: center; }
|
|
|
+</style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+<header>
|
|
|
+ <h1>sm121</h1>
|
|
|
+ <span id="sse"><span class="dot"></span><span id="sse-text">connecting…</span></span>
|
|
|
+ <button id="theme">🌙 dark</button>
|
|
|
+</header>
|
|
|
+<div class="stage">
|
|
|
+ <div class="win" style="max-width: 760px">
|
|
|
+ <div class="titlebar"><b>state</b><span id="updated"></span></div>
|
|
|
+ <div class="grid" id="grid"><div class="empty">waiting for state…</div></div>
|
|
|
+ </div>
|
|
|
+</div>
|
|
|
+<script>
|
|
|
+"use strict";
|
|
|
+// ---- theme -------------------------------------------------------------
|
|
|
+const root = document.documentElement;
|
|
|
+const saved = localStorage.getItem("theme");
|
|
|
+const prefersLight = matchMedia("(prefers-color-scheme: light)").matches;
|
|
|
+setTheme(saved || (prefersLight ? "light" : "dark"));
|
|
|
+document.getElementById("theme").onclick = () => setTheme(root.dataset.theme === "dark" ? "light" : "dark");
|
|
|
+function setTheme(t) {
|
|
|
+ root.dataset.theme = t;
|
|
|
+ localStorage.setItem("theme", t);
|
|
|
+ document.getElementById("theme").textContent = t === "dark" ? "🌙 dark" : "☀️ light";
|
|
|
+}
|
|
|
+// ---- state grid ----------------------------------------------------------
|
|
|
+const grid = document.getElementById("grid");
|
|
|
+const updated = document.getElementById("updated");
|
|
|
+function esc(s) { const d = document.createElement("i"); d.textContent = s; return d.innerHTML; }
|
|
|
+function renderState(snap) {
|
|
|
+ if (!snap.clusters || !snap.clusters.length) {
|
|
|
+ grid.innerHTML = '<div class="empty">no clusters in config</div>';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ grid.innerHTML = snap.clusters.map(c => `
|
|
|
+ <div class="cluster">
|
|
|
+ <div class="cluster-head">${esc(c.name)}<span class="free">${c.free}/${c.nodes.length} free</span></div>
|
|
|
+ ${c.nodes.map(n => `
|
|
|
+ <div class="node">
|
|
|
+ <span class="led ${n.reachable ? "on" : "off"} ${!n.reachable ? "pulse" : ""}" title="reachability"></span>
|
|
|
+ <span class="led ${n.occupied ? "busy" : "free"}" title="occupancy"></span>
|
|
|
+ <span class="nname">${esc(n.name)}</span>
|
|
|
+ <span class="nmeta">${esc(n.ssh_ip)}</span>
|
|
|
+ </div>
|
|
|
+ <div class="node" style="padding-top:0;margin-left:38px">
|
|
|
+ <span></span><span></span>
|
|
|
+ <span class="nstate">
|
|
|
+ ${n.reachable
|
|
|
+ ? (n.occupied ? '<span class="occupied">● occupied</span>' : '<span class="up">○ free</span>')
|
|
|
+ : '<span class="down">● unreachable</span>'}
|
|
|
+ </span><span></span>
|
|
|
+ </div>`).join("")}
|
|
|
+ </div>`).join("");
|
|
|
+ updated.textContent = "updated " + new Date(snap.ts * 1000).toLocaleTimeString();
|
|
|
+}
|
|
|
+// ---- SSE -----------------------------------------------------------------
|
|
|
+const TOKEN = new URLSearchParams(location.search).get("token") || "";
|
|
|
+const sseEl = document.getElementById("sse"), sseText = document.getElementById("sse-text");
|
|
|
+function connect() {
|
|
|
+ const url = "/events" + (TOKEN ? `?token=${encodeURIComponent(TOKEN)}` : "");
|
|
|
+ const es = new EventSource(url);
|
|
|
+ es.onopen = () => { sseEl.classList.add("ok"); sseEl.classList.remove("err"); sseText.textContent = "live"; };
|
|
|
+ es.onerror = () => { sseEl.classList.add("err"); sseEl.classList.remove("ok"); sseText.textContent = "reconnecting…"; };
|
|
|
+ es.onmessage = (e) => {
|
|
|
+ try {
|
|
|
+ const ev = JSON.parse(e.data);
|
|
|
+ if (ev.type === "state") renderState(ev.snapshot);
|
|
|
+ } catch (_) {}
|
|
|
+ };
|
|
|
+}
|
|
|
+// Initial snapshot, then live updates.
|
|
|
+fetch("/api/state" + (TOKEN ? `?token=${encodeURIComponent(TOKEN)}` : ""))
|
|
|
+ .then(r => r.ok ? r.json() : null)
|
|
|
+ .then(snap => snap && renderState(snap))
|
|
|
+ .catch(() => {});
|
|
|
+connect();
|
|
|
+</script>
|
|
|
+</body>
|
|
|
+</html>
|