Stabilize control surface and external bridge v1
This commit is contained in:
2347
web/v1/app.js
2347
web/v1/app.js
File diff suppressed because it is too large
Load Diff
@@ -3,219 +3,276 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Infinity Vis Creative Console</title>
|
||||
<title>Infinity Vis Control</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="page-shell">
|
||||
<header class="hero">
|
||||
<div class="hero-copy">
|
||||
<p class="eyebrow">Infinity Vis / Creative Surface</p>
|
||||
<h1 id="project-name">Loading project...</h1>
|
||||
<p id="topology-label" class="hero-subtitle">
|
||||
Shared host API bootstrap in progress.
|
||||
</p>
|
||||
</div>
|
||||
<div class="hero-status">
|
||||
<div class="status-card">
|
||||
<span class="status-label">API stream</span>
|
||||
<span id="connection-pill" class="pill pill-offline">connecting</span>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<span class="status-label">Preview refresh</span>
|
||||
<span id="preview-updated">waiting for data</span>
|
||||
</div>
|
||||
<button id="refresh-button" class="ghost-button" type="button">
|
||||
Refresh snapshot
|
||||
<div class="app-shell">
|
||||
<header class="topbar topbar-creative">
|
||||
<div class="topbar-actions">
|
||||
<a href="/technical" class="toolbar-button toolbar-link">Mapping Settings</a>
|
||||
|
||||
<label class="toolbar-control toolbar-control-inline">
|
||||
<span class="toolbar-label">Tempo</span>
|
||||
<input id="tempo-bpm-input" type="number" min="10" max="300" step="1" />
|
||||
<strong id="tempo-bpm-label">120 BPM</strong>
|
||||
</label>
|
||||
|
||||
<label class="toolbar-control toolbar-control-inline">
|
||||
<span class="toolbar-label">Mode</span>
|
||||
<select id="work-mode-select">
|
||||
<option value="test_edit">Test/Edit</option>
|
||||
<option value="show_event">Show/Event</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<button id="go-button" class="toolbar-button" type="button">Go</button>
|
||||
<button id="fade-go-button" class="toolbar-button toolbar-button-primary" type="button">
|
||||
Fade Go
|
||||
</button>
|
||||
|
||||
<label class="toolbar-control toolbar-control-inline toolbar-control-fade">
|
||||
<span class="toolbar-label">Fade</span>
|
||||
<input id="transition-seconds-input" type="number" min="0.1" max="30" step="0.1" />
|
||||
<strong id="transition-seconds-label">2.0 s</strong>
|
||||
</label>
|
||||
|
||||
<label class="toolbar-control toolbar-control-inline">
|
||||
<span class="toolbar-label">Style</span>
|
||||
<select id="transition-style-select">
|
||||
<option value="snap">Snap</option>
|
||||
<option value="crossfade">Crossfade</option>
|
||||
<option value="chase">Chase</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<span id="edit-context-label" class="status-chip status-chip-edit">Edit: Live</span>
|
||||
|
||||
<button id="blackout-button" class="toolbar-button toolbar-button-alert" type="button">
|
||||
Blackout
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="layout">
|
||||
<section class="panel controls-panel">
|
||||
<div class="section-heading">
|
||||
<h2>Global Look</h2>
|
||||
<p>Pattern, preset, group and transition control against the shared host API.</p>
|
||||
</div>
|
||||
|
||||
<div class="control-grid">
|
||||
<label class="field">
|
||||
<main class="workspace">
|
||||
<aside class="workspace-rail workspace-rail-left">
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Pattern</h2>
|
||||
<p>Old-tool style mode access on top of the stable show-control primitives.</p>
|
||||
</div>
|
||||
<label class="control-field">
|
||||
<span>Pattern</span>
|
||||
<select id="pattern-select"></select>
|
||||
</label>
|
||||
</section>
|
||||
|
||||
<label class="field">
|
||||
<span>Transition Duration</span>
|
||||
<input id="transition-slider" type="range" min="0" max="3000" step="10" />
|
||||
<strong id="transition-value">0 ms</strong>
|
||||
</label>
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Look & Motion</h2>
|
||||
<p>Pattern behavior and movement parameters.</p>
|
||||
</div>
|
||||
<div id="motion-params" class="parameter-stack"></div>
|
||||
</section>
|
||||
|
||||
<label class="field">
|
||||
<span>Transition Style</span>
|
||||
<select id="transition-style-select">
|
||||
<option value="snap">Snap</option>
|
||||
<option value="crossfade">Crossfade</option>
|
||||
<option value="chase">Chase</option>
|
||||
</select>
|
||||
</label>
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Colors</h2>
|
||||
<p>Palette and color-facing controls.</p>
|
||||
</div>
|
||||
<div id="color-params" class="parameter-stack"></div>
|
||||
</section>
|
||||
|
||||
<label class="field">
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Brightness</h2>
|
||||
<p>Global intensity plus pattern-level brightness controls.</p>
|
||||
</div>
|
||||
<label class="control-field">
|
||||
<span>Master Brightness</span>
|
||||
<input id="brightness-slider" type="range" min="0" max="1" step="0.01" />
|
||||
<strong id="brightness-value">0%</strong>
|
||||
</label>
|
||||
<div id="brightness-params" class="parameter-stack"></div>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<div class="field">
|
||||
<span>Blackout</span>
|
||||
<button id="blackout-button" class="danger-button" type="button">
|
||||
Enable blackout
|
||||
<section class="workspace-stage">
|
||||
<section class="stage-panel stage-panel-preview">
|
||||
<div class="stage-header">
|
||||
<div>
|
||||
<h2>Preview</h2>
|
||||
<p id="project-name">Loading project...</p>
|
||||
<p id="topology-label">Shared host API bootstrap in progress.</p>
|
||||
</div>
|
||||
<div class="stage-meta">
|
||||
<span class="stage-meta-label">Refresh</span>
|
||||
<strong id="preview-updated">waiting for data</strong>
|
||||
<button id="refresh-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="preview-stage">
|
||||
<div id="preview-grid" class="preview-grid preview-grid-mode-leds"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stage-panel stage-panel-events">
|
||||
<div class="stage-header">
|
||||
<div>
|
||||
<h2>Status & Eventfeed</h2>
|
||||
<p>Live status messages stay hot without rebuilding the whole workbench.</p>
|
||||
</div>
|
||||
<div class="status-chip-row">
|
||||
<span id="connection-pill" class="status-chip status-chip-warning">connecting</span>
|
||||
<span id="control-mode-pill" class="status-chip status-chip-live">Test/Edit</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="event-filter-bar">
|
||||
<select id="event-kind-filter">
|
||||
<option value="all">All kinds</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
<input
|
||||
id="event-search-filter"
|
||||
class="filter-input"
|
||||
type="text"
|
||||
placeholder="Filter by code or message"
|
||||
/>
|
||||
</div>
|
||||
<div id="event-list" class="event-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<aside class="workspace-rail workspace-rail-right">
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Pending Transition</h2>
|
||||
<p id="pending-panel-description">Stage direct edits locally and commit them consciously.</p>
|
||||
</div>
|
||||
<div class="pending-status-row">
|
||||
<div class="mini-status">
|
||||
<span class="toolbar-label">Scope</span>
|
||||
<strong id="session-scope-label">local browser session</strong>
|
||||
</div>
|
||||
<div class="mini-status">
|
||||
<span class="toolbar-label">Buffer</span>
|
||||
<strong id="pending-compact-label">empty</strong>
|
||||
</div>
|
||||
<div class="mini-status">
|
||||
<span class="toolbar-label">Commit</span>
|
||||
<span id="pending-commit-pill" class="status-chip status-chip-idle">idle</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pending-session-summary" class="pending-session-summary"></div>
|
||||
<div id="primitive-error-banner" class="primitive-error-banner hidden"></div>
|
||||
<div class="button-stack">
|
||||
<button id="trigger-transition-button" class="toolbar-button toolbar-button-primary" type="button">
|
||||
Trigger Transition
|
||||
</button>
|
||||
<button id="clear-staged-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
Clear Staged
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="subsection">
|
||||
<div class="subsection-heading">
|
||||
<h3>Pending Transition</h3>
|
||||
<p>Stage primitives locally and commit them with one explicit trigger.</p>
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Presets</h2>
|
||||
<p>Quick recall for curated looks plus compact save controls.</p>
|
||||
</div>
|
||||
<div class="session-panel">
|
||||
<div class="session-status-row">
|
||||
<div class="status-card">
|
||||
<span class="status-label">Control mode</span>
|
||||
<span id="control-mode-pill" class="pill pill-online">stateful</span>
|
||||
</div>
|
||||
<div class="status-card">
|
||||
<span class="status-label">Commit state</span>
|
||||
<span id="pending-commit-pill" class="pill pill-offline">idle</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="pending-session-summary" class="pending-session-summary"></div>
|
||||
<div id="primitive-error-banner" class="primitive-error-banner hidden"></div>
|
||||
<div class="session-actions">
|
||||
<button id="trigger-transition-button" class="ghost-button" type="button">
|
||||
Trigger Transition
|
||||
</button>
|
||||
<button id="clear-staged-button" class="ghost-button" type="button">
|
||||
Clear Staged
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="subsection">
|
||||
<div class="subsection-heading">
|
||||
<h3>Presets</h3>
|
||||
<p>Recall look snapshots without leaving the creative console.</p>
|
||||
</div>
|
||||
<div id="preset-list" class="pill-row"></div>
|
||||
</div>
|
||||
|
||||
<div class="subsection">
|
||||
<div class="subsection-heading">
|
||||
<h3>Preset Capture</h3>
|
||||
<p>Store or overwrite the current scene as a reusable preset through the same API.</p>
|
||||
</div>
|
||||
<div class="capture-grid">
|
||||
<label class="field">
|
||||
<span>Preset ID</span>
|
||||
<input id="preset-id-input" type="text" placeholder="e.g. sunset_chase" />
|
||||
</label>
|
||||
<label class="field inline-checkbox">
|
||||
<span>Overwrite Existing</span>
|
||||
<div id="preset-list" class="list-stack"></div>
|
||||
<div class="compact-form compact-form-presets">
|
||||
<input id="preset-id-input" type="text" placeholder="preset id" />
|
||||
<label class="inline-toggle">
|
||||
<input id="preset-overwrite-input" type="checkbox" />
|
||||
<span>Overwrite</span>
|
||||
</label>
|
||||
<button id="save-preset-button" class="ghost-button" type="button">
|
||||
Save Current Scene As Preset
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="save-preset-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
Save Current
|
||||
</button>
|
||||
<button id="load-preset-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
Load
|
||||
</button>
|
||||
<button id="delete-preset-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="subsection">
|
||||
<div class="subsection-heading">
|
||||
<h3>Groups</h3>
|
||||
<p>Focus looks on a subset while keeping the core scene model shared.</p>
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Selected Tile</h2>
|
||||
<p>Stable tile focus for operator actions and diagnostics.</p>
|
||||
</div>
|
||||
<input
|
||||
id="group-filter-input"
|
||||
class="filter-input"
|
||||
type="text"
|
||||
placeholder="Filter groups by id or tag"
|
||||
/>
|
||||
<div id="group-list" class="pill-row"></div>
|
||||
</div>
|
||||
<div id="selected-tile-card" class="selected-tile-card empty-state">
|
||||
Click a tile in the preview.
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<button id="white-test-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
White Test
|
||||
</button>
|
||||
<button id="live-pattern-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
Live Pattern
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="subsection">
|
||||
<div class="subsection-heading">
|
||||
<h3>Creative Snapshots</h3>
|
||||
<p>Capture exploratory variants without replacing curated presets.</p>
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Utilities</h2>
|
||||
<p>Fast operator actions aligned with the old desk workflow.</p>
|
||||
</div>
|
||||
<div class="capture-grid">
|
||||
<label class="field">
|
||||
<span>Snapshot ID</span>
|
||||
<input id="snapshot-id-input" type="text" placeholder="e.g. variant_afterglow" />
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Label</span>
|
||||
<input id="snapshot-label-input" type="text" placeholder="Readable label" />
|
||||
</label>
|
||||
<label class="field inline-checkbox">
|
||||
<span>Overwrite Existing</span>
|
||||
<div class="button-stack">
|
||||
<button id="utility-blackout-button" class="toolbar-button toolbar-button-alert" type="button">
|
||||
Blackout
|
||||
</button>
|
||||
<button id="utility-go-button" class="toolbar-button" type="button">Go</button>
|
||||
<button id="utility-fade-go-button" class="toolbar-button toolbar-button-primary" type="button">
|
||||
Fade Go
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>Creative Snapshots</h2>
|
||||
<p>Capture and recall exploratory variants without changing architecture.</p>
|
||||
</div>
|
||||
<div class="compact-form compact-form-two">
|
||||
<input id="snapshot-id-input" type="text" placeholder="snapshot id" />
|
||||
<input id="snapshot-label-input" type="text" placeholder="label" />
|
||||
<label class="inline-toggle">
|
||||
<input id="snapshot-overwrite-input" type="checkbox" />
|
||||
<span>Overwrite</span>
|
||||
</label>
|
||||
<button id="save-snapshot-button" class="ghost-button" type="button">
|
||||
Save Creative Snapshot
|
||||
<button id="save-snapshot-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
Save Snapshot
|
||||
</button>
|
||||
</div>
|
||||
<div id="snapshot-list" class="snapshot-list"></div>
|
||||
</div>
|
||||
<div id="snapshot-list" class="list-stack"></div>
|
||||
</section>
|
||||
|
||||
<div class="subsection">
|
||||
<div class="subsection-heading">
|
||||
<h3>Scene Parameters</h3>
|
||||
<p>Rendered from the active scene schema, not hardcoded per frontend.</p>
|
||||
<section class="dock-section">
|
||||
<div class="dock-header">
|
||||
<h2>View & Output</h2>
|
||||
<p>Read-only state and output context for live operation.</p>
|
||||
</div>
|
||||
<div id="scene-params" class="parameter-grid"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel preview-panel">
|
||||
<div class="section-heading">
|
||||
<h2>Preview</h2>
|
||||
<p>Live panel previews from the host snapshot and stream feed.</p>
|
||||
</div>
|
||||
<div id="preview-grid" class="preview-grid"></div>
|
||||
</section>
|
||||
|
||||
<section class="panel summary-panel">
|
||||
<div class="section-heading">
|
||||
<h2>Snapshot</h2>
|
||||
<p>Operator-friendly scene state with a raw API view underneath.</p>
|
||||
</div>
|
||||
<div id="summary-cards" class="summary-cards"></div>
|
||||
<pre id="snapshot-json" class="snapshot-json"></pre>
|
||||
</section>
|
||||
|
||||
<section class="panel event-panel">
|
||||
<div class="section-heading">
|
||||
<h2>Event Stream</h2>
|
||||
<p>Recent notices from the websocket feed.</p>
|
||||
</div>
|
||||
<div class="event-filter-bar">
|
||||
<select id="event-kind-filter">
|
||||
<option value="all">All kinds</option>
|
||||
<option value="info">Info</option>
|
||||
<option value="warning">Warning</option>
|
||||
<option value="error">Error</option>
|
||||
</select>
|
||||
<input
|
||||
id="event-search-filter"
|
||||
class="filter-input"
|
||||
type="text"
|
||||
placeholder="Filter by code or message"
|
||||
/>
|
||||
</div>
|
||||
<div id="event-list" class="event-list"></div>
|
||||
</section>
|
||||
<span id="preview-mode-label" class="hidden">LEDs Only</span>
|
||||
<div id="view-output-list" class="info-list"></div>
|
||||
<details class="raw-snapshot">
|
||||
<summary>Raw Snapshot</summary>
|
||||
<pre id="snapshot-json" class="snapshot-json"></pre>
|
||||
</details>
|
||||
</section>
|
||||
</aside>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
|
||||
1489
web/v1/styles.css
1489
web/v1/styles.css
File diff suppressed because it is too large
Load Diff
206
web/v1/technical.html
Normal file
206
web/v1/technical.html
Normal file
@@ -0,0 +1,206 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Infinity Vis Mapping Settings</title>
|
||||
<link rel="stylesheet" href="/styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="technical-shell">
|
||||
<header class="topbar topbar-technical">
|
||||
<div class="topbar-brand">
|
||||
<div class="brand-title">Infinity Vis Mapping Settings</div>
|
||||
<div id="technical-project-name" class="brand-project">Loading technical surface...</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-strip">
|
||||
<div class="toolbar-group">
|
||||
<span class="toolbar-label">Backend</span>
|
||||
<span id="technical-backend-pill" class="status-chip status-chip-warning">loading</span>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<span class="toolbar-label">Output</span>
|
||||
<span id="technical-output-pill" class="status-chip status-chip-warning">loading</span>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<span class="toolbar-label">Nodes</span>
|
||||
<span id="technical-nodes-pill" class="status-chip status-chip-warning">0/0 online</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="topbar-actions">
|
||||
<a href="/" class="toolbar-button toolbar-link">Creative Surface</a>
|
||||
<button id="technical-refresh-button" class="toolbar-button toolbar-button-ghost" type="button">
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="technical-workspace">
|
||||
<section class="technical-stack">
|
||||
<section class="dock-section technical-section">
|
||||
<div class="dock-header">
|
||||
<div>
|
||||
<h2>Backend & Output</h2>
|
||||
<p>Preview Only and DDP (WLED) stay explicit and honest.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="technical-summary-grid" class="technical-summary-grid"></div>
|
||||
|
||||
<div class="technical-form-grid">
|
||||
<label class="technical-field">
|
||||
<span class="toolbar-label">Backend Mode</span>
|
||||
<select id="backend-mode-select">
|
||||
<option value="preview_only">Preview Only</option>
|
||||
<option value="ddp_wled">DDP (WLED)</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="technical-field">
|
||||
<span class="toolbar-label">Output FPS</span>
|
||||
<input id="output-fps-input" type="number" min="1" max="240" step="1" />
|
||||
</label>
|
||||
|
||||
<label class="technical-checkbox technical-field-wide">
|
||||
<input id="output-enabled-input" type="checkbox" />
|
||||
<span>Output Enabled</span>
|
||||
</label>
|
||||
|
||||
<div class="technical-field technical-field-wide">
|
||||
<span class="toolbar-label">Live Status</span>
|
||||
<div id="technical-live-status" class="status-banner">Waiting for state...</div>
|
||||
</div>
|
||||
|
||||
<div class="technical-field technical-field-wide">
|
||||
<span class="toolbar-label">Semantics</span>
|
||||
<div id="technical-backend-semantics" class="technical-note">
|
||||
Preview Only means no live output. DDP (WLED) is armed only when explicitly enabled.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="button-row">
|
||||
<button id="save-output-settings-button" class="toolbar-button toolbar-button-primary" type="button">
|
||||
Apply Output Settings
|
||||
</button>
|
||||
</div>
|
||||
<div id="technical-feedback-banner" class="status-banner hidden"></div>
|
||||
</section>
|
||||
|
||||
<section class="dock-section technical-section">
|
||||
<div class="dock-header">
|
||||
<div>
|
||||
<h2>Node / IP Discovery</h2>
|
||||
<p>Scan subnet ranges and assign discovered IPs explicitly to node slots.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="technical-form-grid">
|
||||
<label class="technical-field">
|
||||
<span class="toolbar-label">Subnet</span>
|
||||
<input id="discovery-subnet-input" type="text" value="192.168.40.0/24" />
|
||||
</label>
|
||||
<div class="technical-field">
|
||||
<span class="toolbar-label">Discovery</span>
|
||||
<button id="discovery-scan-button" class="toolbar-button toolbar-button-primary" type="button">
|
||||
Discover / Scan
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="discovery-summary" class="technical-note">
|
||||
No scan executed yet.
|
||||
</div>
|
||||
|
||||
<div class="technical-table-wrap">
|
||||
<table class="technical-table technical-table-dense">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>IP</th>
|
||||
<th>Reachable</th>
|
||||
<th>Type</th>
|
||||
<th>Hostname / mDNS</th>
|
||||
<th>Assign to Node Slot</th>
|
||||
<th>Apply</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="discovery-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dock-section technical-section">
|
||||
<div class="dock-header">
|
||||
<div>
|
||||
<h2>Node Targets</h2>
|
||||
<p>Reserved IPs and honest connection status per node.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="technical-table-wrap">
|
||||
<table class="technical-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Display</th>
|
||||
<th>Reserved IP</th>
|
||||
<th>Status</th>
|
||||
<th>Live Note</th>
|
||||
<th>Apply</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="node-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
<section class="technical-stack technical-stack-wide">
|
||||
<section class="stage-panel technical-section">
|
||||
<div class="stage-header">
|
||||
<div>
|
||||
<h2>Panel Mapping</h2>
|
||||
<p>Real output slots with backend-facing routing details.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="technical-table-wrap technical-table-wrap-grow">
|
||||
<table class="technical-table technical-table-dense">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Node</th>
|
||||
<th>Panel</th>
|
||||
<th>Output</th>
|
||||
<th>Driver Kind</th>
|
||||
<th>GPIO / Channel</th>
|
||||
<th>LEDs</th>
|
||||
<th>Direction</th>
|
||||
<th>Color</th>
|
||||
<th>Enabled</th>
|
||||
<th>Validation</th>
|
||||
<th>Status</th>
|
||||
<th>Apply</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="panel-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stage-panel technical-section technical-events-panel">
|
||||
<div class="stage-header">
|
||||
<div>
|
||||
<h2>Recent Events</h2>
|
||||
<p>Live backend and mapping changes without fake node traffic.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="technical-event-list" class="event-list technical-event-list"></div>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/technical.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
951
web/v1/technical.js
Normal file
951
web/v1/technical.js
Normal file
@@ -0,0 +1,951 @@
|
||||
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,
|
||||
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) {
|
||||
elements.projectName.textContent = `${snapshot.system.project_name} | ${snapshot.system.topology_label}`;
|
||||
|
||||
if (!appState.outputDirty) {
|
||||
appState.outputDraft = {
|
||||
backend_mode: snapshot.technical.backend_mode,
|
||||
output_enabled: snapshot.technical.output_enabled,
|
||||
output_fps: snapshot.technical.output_fps,
|
||||
};
|
||||
}
|
||||
|
||||
for (const node of snapshot.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 snapshot.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 onlineCount = snapshot.nodes.filter((node) => node.connection === "online").length;
|
||||
setChip(
|
||||
elements.backendPill,
|
||||
backendModeLabel(snapshot.technical.backend_mode),
|
||||
snapshot.technical.backend_mode === "preview_only" ? "idle" : "live"
|
||||
);
|
||||
setChip(
|
||||
elements.outputPill,
|
||||
snapshot.technical.output_enabled ? "enabled" : "disabled",
|
||||
snapshot.technical.output_enabled ? "success" : "warning"
|
||||
);
|
||||
setChip(
|
||||
elements.nodesPill,
|
||||
`${onlineCount}/${snapshot.nodes.length} online`,
|
||||
onlineCount > 0 ? "success" : "warning"
|
||||
);
|
||||
}
|
||||
|
||||
function renderSummaryCards() {
|
||||
if (!appState.snapshot) {
|
||||
elements.summaryGrid.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const { technical, nodes, panels } = 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);
|
||||
elements.liveStatus.textContent = appState.snapshot.technical.live_status;
|
||||
elements.liveStatus.className = `status-banner ${bannerLevelForTechnical(appState.snapshot.technical)}`;
|
||||
elements.backendSemantics.textContent = backendSemanticsText(appState.outputDraft);
|
||||
elements.saveOutputSettingsButton.disabled = !appState.outputDirty;
|
||||
}
|
||||
|
||||
function renderNodeTable() {
|
||||
if (!appState.snapshot) {
|
||||
elements.nodeTableBody.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
elements.nodeTableBody.innerHTML = appState.snapshot.nodes
|
||||
.map((node) => {
|
||||
const draft = ensureNodeDraft(node.node_id);
|
||||
return `
|
||||
<tr data-node-id="${escapeHtml(node.node_id)}">
|
||||
<td>${escapeHtml(node.node_id)}</td>
|
||||
<td>${escapeHtml(node.display_name)}</td>
|
||||
<td>
|
||||
<input
|
||||
type="text"
|
||||
data-node-reserved-ip
|
||||
value="${escapeHtml(draft.reserved_ip)}"
|
||||
placeholder="unassigned"
|
||||
/>
|
||||
</td>
|
||||
<td>${connectionBadge(node.connection)}</td>
|
||||
<td>
|
||||
<div class="table-cell-stack">
|
||||
<span>${escapeHtml(node.error_status ?? "no active error")}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="table-action-cell">
|
||||
<button
|
||||
class="toolbar-button toolbar-button-ghost"
|
||||
type="button"
|
||||
data-save-node="${escapeHtml(node.node_id)}"
|
||||
${draft.dirty ? "" : "disabled"}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderPanelTable() {
|
||||
if (!appState.snapshot) {
|
||||
elements.panelTableBody.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
elements.panelTableBody.innerHTML = appState.snapshot.panels
|
||||
.map((panel) => {
|
||||
const key = panelKey(panel.node_id, panel.panel_position);
|
||||
const draft = ensurePanelDraft(key, panel);
|
||||
return `
|
||||
<tr data-panel-key="${escapeHtml(key)}">
|
||||
<td>${escapeHtml(panel.node_id)}</td>
|
||||
<td>${escapeHtml(panel.panel_position)}</td>
|
||||
<td>
|
||||
<input type="text" data-field="physical_output_name" value="${escapeHtml(draft.physical_output_name)}" />
|
||||
</td>
|
||||
<td>
|
||||
<select data-field="driver_kind">
|
||||
${renderOptions(DRIVER_KIND_OPTIONS, draft.driver_kind)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" data-field="driver_reference" value="${escapeHtml(draft.driver_reference)}" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="number" min="1" max="2048" step="1" data-field="led_count" value="${escapeHtml(String(draft.led_count))}" />
|
||||
</td>
|
||||
<td>
|
||||
<select data-field="direction">
|
||||
${renderOptions(DIRECTION_OPTIONS, draft.direction)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<select data-field="color_order">
|
||||
${renderOptions(COLOR_ORDER_OPTIONS, draft.color_order)}
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
<label class="table-checkbox">
|
||||
<input type="checkbox" data-field="enabled" ${draft.enabled ? "checked" : ""} />
|
||||
<span>${draft.enabled ? "on" : "off"}</span>
|
||||
</label>
|
||||
</td>
|
||||
<td>${escapeHtml(panel.validation_state)}</td>
|
||||
<td>
|
||||
<div class="table-cell-stack">
|
||||
${connectionBadge(panel.connection)}
|
||||
<span>${escapeHtml(panel.error_status ?? "no active error")}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="table-action-cell">
|
||||
<button
|
||||
class="toolbar-button toolbar-button-ghost"
|
||||
type="button"
|
||||
data-save-panel="${escapeHtml(key)}"
|
||||
${draft.dirty ? "" : "disabled"}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.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 =
|
||||
'<tr><td colspan="6" class="empty-state">Run a subnet scan to list discoverable IP targets.</td></tr>';
|
||||
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 `
|
||||
<tr data-discovery-ip="${escapeHtml(result.ip)}">
|
||||
<td>${escapeHtml(result.ip)}</td>
|
||||
<td>${result.reachable ? connectionBadge("online") : connectionBadge("offline")}</td>
|
||||
<td>${escapeHtml(discoveredTypeLabel(result.detected_type))}</td>
|
||||
<td>${escapeHtml(result.hostname || "n/a")}</td>
|
||||
<td>
|
||||
<div class="table-cell-stack">
|
||||
<select data-discovery-assignment>
|
||||
<option value="">manual assignment</option>
|
||||
${renderNodeOptions(selectedNode)}
|
||||
</select>
|
||||
<span class="table-muted">
|
||||
${assignedNode ? `currently ${escapeHtml(assignedNode)}` : "currently unassigned"}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="table-action-cell">
|
||||
<button
|
||||
class="toolbar-button toolbar-button-ghost"
|
||||
type="button"
|
||||
data-apply-discovery="${escapeHtml(result.ip)}"
|
||||
${canApply ? "" : "disabled"}
|
||||
>
|
||||
${changed ? "Assign" : "Apply"}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
if (!appState.snapshot) {
|
||||
elements.eventList.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
elements.eventList.innerHTML = appState.snapshot.recent_events
|
||||
.map(
|
||||
(event) => `
|
||||
<article class="event-entry">
|
||||
<div class="event-header">
|
||||
<span class="status-chip ${eventKindChipClass(event.kind)}">${escapeHtml(event.kind)}</span>
|
||||
<span class="event-meta">${escapeHtml(event.code ?? "event")} | ${escapeHtml(String(event.at_millis))} ms</span>
|
||||
</div>
|
||||
<div>${escapeHtml(event.message)}</div>
|
||||
</article>
|
||||
`
|
||||
)
|
||||
.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) {
|
||||
return;
|
||||
}
|
||||
ensureOutputDraft();
|
||||
const current = appState.snapshot.technical;
|
||||
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;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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 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 = appState.snapshot.nodes.find((node) => node.reserved_ip === ip);
|
||||
return match ? match.node_id : null;
|
||||
}
|
||||
|
||||
function renderNodeOptions(selectedNodeId) {
|
||||
const nodes = appState.snapshot ? appState.snapshot.nodes : [];
|
||||
return nodes
|
||||
.map((node) => {
|
||||
const selected = node.node_id === selectedNodeId ? "selected" : "";
|
||||
return `<option value="${escapeHtml(node.node_id)}" ${selected}>${escapeHtml(node.node_id)}</option>`;
|
||||
})
|
||||
.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 `<span class="status-chip ${chipClassForTone(tone)}">${escapeHtml(connection)}</span>`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<article class="summary-card summary-card-${escapeHtml(tone)}">
|
||||
<span class="toolbar-label">${escapeHtml(label)}</span>
|
||||
<strong>${escapeHtml(value)}</strong>
|
||||
</article>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderOptions(options, selectedValue) {
|
||||
return options
|
||||
.map(
|
||||
(option) => `
|
||||
<option value="${escapeHtml(option.value)}" ${option.value === selectedValue ? "selected" : ""}>
|
||||
${escapeHtml(option.label)}
|
||||
</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();
|
||||
Reference in New Issue
Block a user