const POLL_INTERVAL_MS = 1500; const DRIVER_KIND_OPTIONS = [ { value: "pending_validation", label: "Pending Validation" }, { value: "gpio", label: "GPIO" }, { value: "rmt_channel", label: "RMT Channel" }, { value: "i2s_lane", label: "I2S Lane" }, { value: "uart_port", label: "UART Port" }, { value: "spi_bus", label: "SPI Bus" }, { value: "external_driver", label: "External Driver" }, ]; const DIRECTION_OPTIONS = [ { value: "forward", label: "Forward" }, { value: "reverse", label: "Reverse" }, ]; const COLOR_ORDER_OPTIONS = [ { value: "rgb", label: "RGB" }, { value: "rbg", label: "RBG" }, { value: "grb", label: "GRB" }, { value: "gbr", label: "GBR" }, { value: "brg", label: "BRG" }, { value: "bgr", label: "BGR" }, ]; const appState = { snapshot: null, outputDraft: null, outputDirty: false, outputSaving: false, nodeDrafts: new Map(), panelDrafts: new Map(), discovery: { subnet: "192.168.40.0/24", scanning: false, scannedHosts: 0, reachableHosts: 0, results: [], assignmentDrafts: new Map(), }, feedback: { level: "info", message: "" }, pollHandle: null, }; const elements = { projectName: document.getElementById("technical-project-name"), backendPill: document.getElementById("technical-backend-pill"), outputPill: document.getElementById("technical-output-pill"), nodesPill: document.getElementById("technical-nodes-pill"), summaryGrid: document.getElementById("technical-summary-grid"), backendModeSelect: document.getElementById("backend-mode-select"), outputEnabledInput: document.getElementById("output-enabled-input"), outputFpsInput: document.getElementById("output-fps-input"), liveStatus: document.getElementById("technical-live-status"), backendSemantics: document.getElementById("technical-backend-semantics"), feedbackBanner: document.getElementById("technical-feedback-banner"), nodeTableBody: document.getElementById("node-table-body"), panelTableBody: document.getElementById("panel-table-body"), eventList: document.getElementById("technical-event-list"), refreshButton: document.getElementById("technical-refresh-button"), saveOutputSettingsButton: document.getElementById("save-output-settings-button"), discoverySubnetInput: document.getElementById("discovery-subnet-input"), discoveryScanButton: document.getElementById("discovery-scan-button"), discoverySummary: document.getElementById("discovery-summary"), discoveryTableBody: document.getElementById("discovery-table-body"), }; function init() { bindEvents(); void loadState(); appState.pollHandle = window.setInterval(() => { void loadState(); }, POLL_INTERVAL_MS); } function bindEvents() { elements.refreshButton.addEventListener("click", () => { void loadState(); }); elements.backendModeSelect.addEventListener("change", () => { ensureOutputDraft(); appState.outputDraft.backend_mode = elements.backendModeSelect.value; appState.outputDirty = true; renderOutputControls(); }); elements.outputEnabledInput.addEventListener("change", () => { ensureOutputDraft(); appState.outputDraft.output_enabled = elements.outputEnabledInput.checked; appState.outputDirty = true; renderOutputControls(); }); elements.outputFpsInput.addEventListener("input", () => { ensureOutputDraft(); appState.outputDraft.output_fps = parseInteger(elements.outputFpsInput.value, 40); appState.outputDirty = true; renderOutputControls(); }); elements.saveOutputSettingsButton.addEventListener("click", () => { void saveOutputSettings(); }); elements.nodeTableBody.addEventListener("input", (event) => { const row = event.target.closest("[data-node-id]"); if (!row) { return; } const nodeId = row.dataset.nodeId; const draft = ensureNodeDraft(nodeId); draft.reserved_ip = row.querySelector("[data-node-reserved-ip]").value.trim(); draft.dirty = true; updateNodeRowState(row, draft); }); elements.nodeTableBody.addEventListener("click", (event) => { const button = event.target.closest("[data-save-node]"); if (!button) { return; } void saveNode(button.dataset.saveNode); }); elements.panelTableBody.addEventListener("input", handlePanelDraftInput); elements.panelTableBody.addEventListener("change", handlePanelDraftInput); elements.panelTableBody.addEventListener("click", (event) => { const button = event.target.closest("[data-save-panel]"); if (!button) { return; } void savePanel(button.dataset.savePanel); }); elements.discoveryScanButton.addEventListener("click", () => { void runDiscoveryScan(); }); elements.discoverySubnetInput.addEventListener("keydown", (event) => { if (event.key === "Enter") { event.preventDefault(); void runDiscoveryScan(); } }); elements.discoveryTableBody.addEventListener("change", (event) => { const row = event.target.closest("[data-discovery-ip]"); if (!row) { return; } const select = row.querySelector("[data-discovery-assignment]"); const ip = row.dataset.discoveryIp; appState.discovery.assignmentDrafts.set(ip, select.value); renderDiscoveryTable(); }); elements.discoveryTableBody.addEventListener("click", (event) => { const button = event.target.closest("[data-apply-discovery]"); if (!button) { return; } void applyDiscoveryAssignment(button.dataset.applyDiscovery); }); } async function loadState() { try { const response = await fetch("/api/v1/state", { cache: "no-store" }); if (!response.ok) { throw new Error(`state request failed with ${response.status}`); } const payload = await response.json(); appState.snapshot = payload.state; syncDraftsFromState(payload.state); render(); } catch (error) { setFeedback("error", `Technical state could not be loaded: ${error.message}`); render(); } } function syncDraftsFromState(snapshot) { const system = snapshotSystem(snapshot); const technical = snapshotTechnical(snapshot); const nodes = snapshotNodes(snapshot); const panels = snapshotPanels(snapshot); elements.projectName.textContent = `${system.project_name} | ${system.topology_label}`; if (!appState.outputDirty && !appState.outputSaving) { appState.outputDraft = { backend_mode: technical.backend_mode, output_enabled: technical.output_enabled, output_fps: technical.output_fps, }; } for (const node of nodes) { const existing = appState.nodeDrafts.get(node.node_id); if (!existing || !existing.dirty) { appState.nodeDrafts.set(node.node_id, { reserved_ip: node.reserved_ip ?? "", dirty: false, }); } } for (const panel of panels) { const key = panelKey(panel.node_id, panel.panel_position); const existing = appState.panelDrafts.get(key); if (!existing || !existing.dirty) { appState.panelDrafts.set(key, { physical_output_name: panel.physical_output_name, driver_kind: panel.driver_kind, driver_reference: panel.driver_reference, led_count: panel.led_count, direction: panel.direction, color_order: panel.color_order, enabled: panel.enabled, dirty: false, }); } } for (const result of appState.discovery.results) { if (!appState.discovery.assignmentDrafts.has(result.ip)) { appState.discovery.assignmentDrafts.set(result.ip, assignedNodeForIp(result.ip) || ""); } } } function render() { renderTopbar(); renderSummaryCards(); renderFeedback(); renderEvents(); renderDiscoverySummary(); if (!isEditingOutputControls()) { renderOutputControls(); } if (!isEditingInside(elements.nodeTableBody)) { renderNodeTable(); } if (!isEditingInside(elements.panelTableBody)) { renderPanelTable(); } if (!isEditingInside(elements.discoveryTableBody)) { renderDiscoveryTable(); } } function renderTopbar() { const snapshot = appState.snapshot; if (!snapshot) { setChip(elements.backendPill, "loading", "warning"); setChip(elements.outputPill, "loading", "warning"); setChip(elements.nodesPill, "0/0 online", "warning"); return; } const technical = snapshotTechnical(snapshot); const nodes = snapshotNodes(snapshot); const onlineCount = nodes.filter((node) => node.connection === "online").length; setChip( elements.backendPill, backendModeLabel(technical.backend_mode), technical.backend_mode === "preview_only" ? "idle" : "live" ); setChip( elements.outputPill, technical.output_enabled ? "enabled" : "disabled", technical.output_enabled ? "success" : "warning" ); setChip( elements.nodesPill, `${onlineCount}/${nodes.length} online`, onlineCount > 0 ? "success" : "warning" ); } function renderSummaryCards() { if (!appState.snapshot) { elements.summaryGrid.innerHTML = ""; return; } const technical = snapshotTechnical(appState.snapshot); const nodes = snapshotNodes(appState.snapshot); const panels = snapshotPanels(appState.snapshot); const onlineCount = nodes.filter((node) => node.connection === "online").length; const enabledOutputs = panels.filter((panel) => panel.enabled).length; const liveOutputs = panels.filter( (panel) => panel.enabled && panel.connection === "online" ).length; elements.summaryGrid.innerHTML = [ summaryCard("Backend", backendModeLabel(technical.backend_mode), technical.backend_mode), summaryCard( "Live Status", technical.live_status, technical.output_enabled ? "active" : "idle" ), summaryCard( "Nodes", `${onlineCount}/${nodes.length} online`, onlineCount > 0 ? "active" : "idle" ), summaryCard( "Outputs", `${liveOutputs}/${enabledOutputs} enabled outputs live`, enabledOutputs > 0 ? "active" : "idle" ), ].join(""); } function renderOutputControls() { if (!appState.snapshot) { return; } ensureOutputDraft(); elements.backendModeSelect.value = appState.outputDraft.backend_mode; elements.outputEnabledInput.checked = Boolean(appState.outputDraft.output_enabled); elements.outputFpsInput.value = String(appState.outputDraft.output_fps); const technical = snapshotTechnical(appState.snapshot); elements.liveStatus.textContent = technical.live_status; elements.liveStatus.className = `status-banner ${bannerLevelForTechnical(technical)}`; elements.backendSemantics.textContent = backendSemanticsText(appState.outputDraft); const controlsDisabled = appState.outputSaving; elements.backendModeSelect.disabled = controlsDisabled; elements.outputEnabledInput.disabled = controlsDisabled; elements.outputFpsInput.disabled = controlsDisabled; elements.saveOutputSettingsButton.disabled = controlsDisabled || !appState.outputDirty; } function renderNodeTable() { if (!appState.snapshot) { elements.nodeTableBody.innerHTML = ""; return; } elements.nodeTableBody.innerHTML = snapshotNodes(appState.snapshot) .map((node) => { const draft = ensureNodeDraft(node.node_id); return ` ${escapeHtml(node.node_id)} ${escapeHtml(node.display_name)} ${connectionBadge(node.connection)}
${escapeHtml(node.error_status ?? "no active error")}
`; }) .join(""); } function renderPanelTable() { if (!appState.snapshot) { elements.panelTableBody.innerHTML = ""; return; } elements.panelTableBody.innerHTML = snapshotPanels(appState.snapshot) .map((panel) => { const key = panelKey(panel.node_id, panel.panel_position); const draft = ensurePanelDraft(key, panel); return ` ${escapeHtml(panel.node_id)} ${escapeHtml(panel.panel_position)} ${escapeHtml(panel.validation_state)}
${connectionBadge(panel.connection)} ${escapeHtml(panel.error_status ?? "no active error")}
`; }) .join(""); } function renderDiscoverySummary() { const discovery = appState.discovery; elements.discoverySubnetInput.value = discovery.subnet; if (discovery.scanning) { elements.discoveryScanButton.disabled = true; elements.discoveryScanButton.textContent = "Scanning..."; elements.discoverySummary.textContent = `Scanning subnet ${discovery.subnet}...`; return; } elements.discoveryScanButton.disabled = false; elements.discoveryScanButton.textContent = "Discover / Scan"; if (!discovery.results.length) { elements.discoverySummary.textContent = "No scan executed yet."; return; } elements.discoverySummary.textContent = `Scanned ${discovery.scannedHosts} hosts in ${discovery.subnet}, ` + `${discovery.reachableHosts} reachable. No automatic assignments are applied.`; } function renderDiscoveryTable() { if (!appState.discovery.results.length) { elements.discoveryTableBody.innerHTML = 'Run a subnet scan to list discoverable IP targets.'; return; } elements.discoveryTableBody.innerHTML = appState.discovery.results .map((result) => { const assignedNode = assignedNodeForIp(result.ip); const selectedNode = appState.discovery.assignmentDrafts.get(result.ip) || assignedNode || ""; const changed = selectedNode && selectedNode !== assignedNode; const canApply = Boolean(selectedNode) && !appState.discovery.scanning; return ` ${escapeHtml(result.ip)} ${result.reachable ? connectionBadge("online") : connectionBadge("offline")} ${escapeHtml(discoveredTypeLabel(result.detected_type))} ${escapeHtml(result.hostname || "n/a")}
${assignedNode ? `currently ${escapeHtml(assignedNode)}` : "currently unassigned"}
`; }) .join(""); } function renderEvents() { if (!appState.snapshot) { elements.eventList.innerHTML = ""; return; } const recentEvents = snapshotRecentEvents(appState.snapshot); elements.eventList.innerHTML = recentEvents .map( (event) => `
${escapeHtml(event.kind)} ${escapeHtml(event.code ?? "event")} | ${escapeHtml(String(event.at_millis))} ms
${escapeHtml(event.message)}
` ) .join(""); } function renderFeedback() { const { message, level } = appState.feedback; if (!message) { elements.feedbackBanner.classList.add("hidden"); elements.feedbackBanner.textContent = ""; return; } elements.feedbackBanner.className = `status-banner ${level}`; elements.feedbackBanner.textContent = message; } async function runDiscoveryScan() { const subnet = elements.discoverySubnetInput.value.trim(); if (!subnet) { setFeedback("warning", "Subnet is required, e.g. 192.168.40.0/24."); renderFeedback(); return; } appState.discovery.subnet = subnet; appState.discovery.scanning = true; renderDiscoverySummary(); renderDiscoveryTable(); try { const response = await fetch("/api/v1/discovery/scan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ subnet }), }); const body = await response.json(); if (!response.ok) { setFeedback( "error", `${body.error?.code ?? "discovery_scan_failed"}: ${body.error?.message ?? "request failed"}` ); return; } appState.discovery.subnet = body.subnet || subnet; appState.discovery.scannedHosts = body.scanned_hosts || 0; appState.discovery.reachableHosts = body.reachable_hosts || 0; appState.discovery.results = Array.isArray(body.results) ? body.results : []; appState.discovery.assignmentDrafts.clear(); for (const result of appState.discovery.results) { appState.discovery.assignmentDrafts.set(result.ip, assignedNodeForIp(result.ip) || ""); } setFeedback( "success", `Discovery finished: ${appState.discovery.reachableHosts}/${appState.discovery.scannedHosts} reachable.` ); } catch (error) { setFeedback("error", `Discovery scan failed: ${error.message}`); } finally { appState.discovery.scanning = false; renderDiscoverySummary(); renderDiscoveryTable(); renderFeedback(); } } async function applyDiscoveryAssignment(ip) { if (!appState.snapshot) { return; } const selectedNode = appState.discovery.assignmentDrafts.get(ip) || ""; if (!selectedNode) { setFeedback("warning", `Choose a node slot for ${ip} before applying.`); renderFeedback(); return; } const response = await sendCommand("set_node_reserved_ip", { node_id: selectedNode, reserved_ip: ip, }); if (!response.ok) { return; } setFeedback("success", `Assigned ${ip} to ${selectedNode}.`); await loadState(); renderDiscoveryTable(); } async function saveOutputSettings() { if (!appState.snapshot || appState.outputSaving) { return; } ensureOutputDraft(); const current = snapshotTechnical(appState.snapshot); const draft = appState.outputDraft; const commands = []; if (draft.backend_mode !== current.backend_mode) { commands.push({ type: "set_output_backend_mode", payload: { mode: draft.backend_mode }, }); } if (Boolean(draft.output_enabled) !== Boolean(current.output_enabled)) { commands.push({ type: "set_live_output_enabled", payload: { enabled: Boolean(draft.output_enabled) }, }); } if (Number(draft.output_fps) !== Number(current.output_fps)) { commands.push({ type: "set_output_fps", payload: { output_fps: parseInteger(draft.output_fps, current.output_fps) }, }); } if (commands.length === 0) { setFeedback("info", "No backend/output changes to apply."); renderFeedback(); return; } appState.outputSaving = true; renderOutputControls(); try { for (const command of commands) { const response = await sendCommand(command.type, command.payload); if (!response.ok) { return; } } appState.outputDirty = false; setFeedback("success", "Backend/output settings applied."); await loadState(); } finally { appState.outputSaving = false; renderOutputControls(); } } async function saveNode(nodeId) { if (!appState.snapshot) { return; } const draft = ensureNodeDraft(nodeId); const payload = { node_id: nodeId, reserved_ip: draft.reserved_ip || null, }; const response = await sendCommand("set_node_reserved_ip", payload); if (!response.ok) { return; } draft.dirty = false; setFeedback("success", `Node target updated for ${nodeId}.`); await loadState(); } async function savePanel(key) { if (!appState.snapshot) { return; } const [nodeId, panelPosition] = key.split(":"); const draft = appState.panelDrafts.get(key); if (!draft) { return; } const payload = { node_id: nodeId, panel_position: panelPosition, physical_output_name: draft.physical_output_name.trim(), driver_kind: draft.driver_kind, driver_reference: draft.driver_reference.trim(), led_count: parseInteger(draft.led_count, 106), direction: draft.direction, color_order: draft.color_order, enabled: Boolean(draft.enabled), }; const response = await sendCommand("update_panel_mapping", payload); if (!response.ok) { return; } draft.dirty = false; setFeedback("success", `Panel mapping updated for ${nodeId}:${panelPosition}.`); await loadState(); } async function sendCommand(type, payload) { try { const response = await fetch("/api/v1/command", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ command: { type, payload } }), }); const body = await response.json(); if (!response.ok) { setFeedback( "error", `${body.error?.code ?? "command_failed"}: ${body.error?.message ?? "request failed"}` ); renderFeedback(); return { ok: false, body }; } setFeedback("success", body.summary || `${type} accepted`); renderFeedback(); return { ok: true, body }; } catch (error) { setFeedback("error", `Command ${type} failed: ${error.message}`); renderFeedback(); return { ok: false, body: null }; } } function handlePanelDraftInput(event) { const row = event.target.closest("[data-panel-key]"); if (!row) { return; } const key = row.dataset.panelKey; const draft = appState.panelDrafts.get(key); if (!draft) { return; } const field = event.target.dataset.field; if (!field) { return; } draft[field] = event.target.type === "checkbox" ? event.target.checked : event.target.value; if (field === "led_count") { draft.led_count = parseInteger(draft.led_count, 106); } draft.dirty = true; updatePanelRowState(row, draft); } function updateNodeRowState(row, draft) { const button = row.querySelector("[data-save-node]"); if (button) { button.disabled = !draft.dirty; } } function updatePanelRowState(row, draft) { const button = row.querySelector("[data-save-panel]"); if (button) { button.disabled = !draft.dirty; } const toggleLabel = row.querySelector(".table-checkbox span"); if (toggleLabel) { toggleLabel.textContent = draft.enabled ? "on" : "off"; } } function ensureOutputDraft() { if (!appState.outputDraft) { appState.outputDraft = { backend_mode: "preview_only", output_enabled: false, output_fps: 40, }; } } function snapshotSystem(snapshot) { return snapshot?.system ?? { project_name: "Infinity Vis", topology_label: "Technical surface waiting for snapshot", }; } function snapshotTechnical(snapshot) { return snapshot?.technical ?? { backend_mode: "preview_only", output_enabled: false, output_fps: 40, live_status: "Waiting for technical state...", }; } function snapshotNodes(snapshot) { return Array.isArray(snapshot?.nodes) ? snapshot.nodes : []; } function snapshotPanels(snapshot) { return Array.isArray(snapshot?.panels) ? snapshot.panels : []; } function snapshotRecentEvents(snapshot) { return Array.isArray(snapshot?.recent_events) ? snapshot.recent_events : []; } function ensureNodeDraft(nodeId) { const existing = appState.nodeDrafts.get(nodeId); if (existing) { return existing; } const draft = { reserved_ip: "", dirty: false }; appState.nodeDrafts.set(nodeId, draft); return draft; } function ensurePanelDraft(key, panel = null) { const existing = appState.panelDrafts.get(key); if (existing) { return existing; } const draft = { physical_output_name: panel?.physical_output_name || "", driver_kind: panel?.driver_kind || "pending_validation", driver_reference: panel?.driver_reference || "", led_count: panel?.led_count || 106, direction: panel?.direction || "forward", color_order: panel?.color_order || "grb", enabled: Boolean(panel?.enabled), dirty: false, }; appState.panelDrafts.set(key, draft); return draft; } function assignedNodeForIp(ip) { if (!appState.snapshot || !ip) { return null; } const match = snapshotNodes(appState.snapshot).find((node) => node.reserved_ip === ip); return match ? match.node_id : null; } function renderNodeOptions(selectedNodeId) { const nodes = snapshotNodes(appState.snapshot); return nodes .map((node) => { const selected = node.node_id === selectedNodeId ? "selected" : ""; return ``; }) .join(""); } function discoveredTypeLabel(type) { switch (type) { case "wled": return "WLED"; case "native_node": return "native node"; default: return "unknown"; } } function setFeedback(level, message) { appState.feedback = { level, message }; } function setChip(element, text, tone) { element.className = `status-chip ${chipClassForTone(tone)}`; element.textContent = text; } function chipClassForTone(tone) { switch (tone) { case "success": return "status-chip-success"; case "live": return "status-chip-live"; case "idle": return "status-chip-idle"; case "alert": return "status-chip-alert"; default: return "status-chip-warning"; } } function eventKindChipClass(kind) { switch (kind) { case "info": return "status-chip-live"; case "warning": return "status-chip-warning"; case "error": return "status-chip-alert"; default: return "status-chip-idle"; } } function connectionBadge(connection) { const tone = connection === "online" ? "success" : connection === "degraded" ? "warning" : "idle"; return `${escapeHtml(connection)}`; } function bannerLevelForTechnical(technical) { if (technical.backend_mode === "preview_only") { return "info"; } return technical.output_enabled ? "success" : "warning"; } function backendModeLabel(mode) { return mode === "ddp_wled" ? "DDP (WLED)" : "Preview Only"; } function backendSemanticsText(draft) { if (draft.backend_mode === "preview_only") { return "Preview Only keeps the renderer local and sends no live output."; } if (!draft.output_enabled) { return "DDP (WLED) is selected, but live output stays disabled until explicitly armed."; } return "DDP (WLED) is armed for live output. Node status is shown below without simulation."; } function summaryCard(label, value, tone) { return `
${escapeHtml(label)} ${escapeHtml(value)}
`; } function renderOptions(options, selectedValue) { return options .map( (option) => ` ` ) .join(""); } function panelKey(nodeId, panelPosition) { return `${nodeId}:${panelPosition}`; } function isEditingInside(container) { const activeElement = document.activeElement; return Boolean(activeElement && container.contains(activeElement)); } function isEditingOutputControls() { const activeElement = document.activeElement; return Boolean( activeElement && (activeElement === elements.backendModeSelect || activeElement === elements.outputEnabledInput || activeElement === elements.outputFpsInput) ); } function parseInteger(value, fallback) { const parsed = Number.parseInt(String(value), 10); return Number.isFinite(parsed) ? parsed : fallback; } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } init();