(() => { const tokenEl = document.getElementById("token"); const rowsEl = document.getElementById("rows"); const listMsg = document.getElementById("list-msg"); const formMsg = document.getElementById("form-msg"); const uptimeEl = document.getElementById("uptime"); const agentsEl = document.getElementById("agents-status"); const dlg = document.getElementById("edit-dlg"); const editForm = document.getElementById("edit-form"); tokenEl.value = localStorage.getItem("streamd_token") || ""; tokenEl.addEventListener("change", () => { localStorage.setItem("streamd_token", tokenEl.value.trim()); connectUIWS(); }); function token() { return tokenEl.value.trim(); } function headers(json) { const h = {}; if (json) h["Content-Type"] = "application/json"; const t = token(); if (t) h["Authorization"] = "Bearer " + t; return h; } async function api(path, opts = {}) { const res = await fetch(path, opts); const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { data = { raw: text }; } if (!res.ok) { const msg = (data && data.error) || res.statusText || "request failed"; throw new Error(msg); } return data; } function age(s) { if (s == null || s === 0) return "—"; return Number(s).toFixed(1) + "s"; } function claimLabel(s) { if (!s.claimed_by) return '—'; let exp = ""; if (s.claim_expires_at) { const ms = Date.parse(s.claim_expires_at) - Date.now(); if (!Number.isNaN(ms)) { exp = ms > 0 ? ` (${Math.ceil(ms / 1000)}s)` : " (expired)"; } } return `${esc(s.claimed_by)}${esc(exp)}`; } function render(streams) { if (!streams.length) { rowsEl.innerHTML = ""; listMsg.textContent = "No streams yet. Add one above."; return; } listMsg.textContent = streams.length + " stream(s)"; rowsEl.innerHTML = streams.map((s) => { const need = !s.mpd || !s.key ? " needs capture" : ""; return ` ${esc(s.name)}
${esc(s.title || "")}${need}
${esc(s.app)} / ${esc(s.channel)} ${esc(s.health || "stopped")} ${claimLabel(s)} ${age(s.playlist_age_s)} hls ${s.claimed_by ? `` : ""} `; }).join(""); } function esc(v) { return String(v ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); } function renderAgents(list) { if (!agentsEl) return; if (!list || !list.length) { agentsEl.textContent = "agents: none connected"; return; } agentsEl.textContent = "agents: " + list.map((a) => a.agent_id).join(", "); } async function refresh() { try { const [health, list, agents] = await Promise.all([ api("/api/health"), api("/api/streams"), api("/api/agents").catch(() => ({ agents: [] })), ]); uptimeEl.textContent = "up " + Math.floor(health.uptime_s || 0) + "s"; render(list.streams || []); renderAgents(agents.agents || []); listMsg.classList.remove("err"); } catch (e) { listMsg.textContent = e.message; listMsg.classList.add("err"); } } // App/channel pickers come from the binary's compiled-in modules, so the UI // never hardcodes a provider. const appSel = document.getElementById("app-select"); const chanSel = document.getElementById("channel-select"); let apps = []; function fillChannels() { const a = apps.find((x) => x.name === appSel.value); chanSel.innerHTML = ""; if (!a || !a.channels.length) { const o = document.createElement("option"); o.value = ""; o.textContent = a ? "(no module.yaml — no channels)" : "(no apps)"; chanSel.appendChild(o); return; } for (const ch of a.channels) { const o = document.createElement("option"); o.value = ch.id; o.textContent = ch.label && ch.label !== ch.id ? ch.id + " — " + ch.label : ch.id; chanSel.appendChild(o); } } async function loadApps() { try { const data = await api("/api/apps"); apps = data.apps || []; } catch (e) { apps = []; } appSel.innerHTML = ""; if (!apps.length) { const o = document.createElement("option"); o.value = ""; o.textContent = "(no app modules compiled in)"; appSel.appendChild(o); } for (const a of apps) { const o = document.createElement("option"); o.value = a.name; o.textContent = a.name; appSel.appendChild(o); } fillChannels(); } appSel.addEventListener("change", fillChannels); loadApps(); document.getElementById("refresh").addEventListener("click", refresh); document.getElementById("create-form").addEventListener("submit", async (ev) => { ev.preventDefault(); formMsg.textContent = ""; formMsg.classList.remove("err"); const fd = new FormData(ev.target); const body = Object.fromEntries(fd.entries()); try { await api("/api/streams", { method: "POST", headers: headers(true), body: JSON.stringify(body), }); ev.target.reset(); ev.target.headers_json.value = "{}"; fillChannels(); formMsg.textContent = "Created."; refresh(); } catch (e) { formMsg.textContent = e.message; formMsg.classList.add("err"); } }); rowsEl.addEventListener("click", async (ev) => { const btn = ev.target.closest("button[data-act]"); if (!btn) return; const id = btn.dataset.id; const act = btn.dataset.act; try { if (act === "delete") { if (!confirm("Delete stream #" + id + "?")) return; await api("/api/streams/" + id, { method: "DELETE", headers: headers(false) }); } else if (act === "unclaim") { await api("/api/streams/" + id + "/claim/release", { method: "POST", headers: headers(true), body: JSON.stringify({ agent_id: "" }), }); } else if (act === "edit") { const st = await api("/api/streams/" + id); editForm.id.value = st.id; editForm.title.value = st.title || ""; editForm.channel.value = st.channel || ""; editForm.mpd.value = st.mpd || ""; editForm.key.value = st.key || ""; editForm.headers_json.value = st.headers_json || "{}"; dlg.showModal(); return; } else { await api("/api/streams/" + id + "/" + act, { method: "POST", headers: headers(false), }); } refresh(); } catch (e) { listMsg.textContent = e.message; listMsg.classList.add("err"); } }); document.getElementById("edit-cancel").addEventListener("click", () => dlg.close()); editForm.addEventListener("submit", async (ev) => { ev.preventDefault(); const id = editForm.id.value; const body = { title: editForm.title.value, channel: editForm.channel.value, mpd: editForm.mpd.value, key: editForm.key.value, headers_json: editForm.headers_json.value, }; try { await api("/api/streams/" + id, { method: "PATCH", headers: headers(true), body: JSON.stringify(body), }); dlg.close(); refresh(); } catch (e) { alert(e.message); } }); let uiWS = null; let uiWSTimer = null; function connectUIWS() { if (uiWS) { try { uiWS.close(); } catch {} uiWS = null; } const proto = location.protocol === "https:" ? "wss:" : "ws:"; const ws = new WebSocket(proto + "//" + location.host + "/ws/ui"); uiWS = ws; ws.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); if (msg.type === "agents" || msg.type === "agent_online" || msg.type === "agent_offline") { renderAgents(msg.agents || []); } if (msg.type === "claims_changed" || msg.type === "agent_offline") { refresh(); } } catch {} }; ws.onclose = () => { clearTimeout(uiWSTimer); uiWSTimer = setTimeout(connectUIWS, 3000); }; } refresh(); connectUIWS(); setInterval(refresh, 5000); })();