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.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();