Phase 5: Web-Bedienoberfläche nach §17 (React+Vite, ADR-0006)
- ADR-0006: React 18/19 mit Vite im statischen SPA-Modus - Workspace §17.2: Statusleiste 32px, einklappbare Werkzeugleiste, Layer-Tabelle, Inspector ~30% mit Divider-Resize, umschaltbare rechte Seitenleiste, Fußleiste mit Setup/Live-Lock - Layer-Tabelle §17.3: alle 10 Pflichtspalten, Zeilen 30px, 7 unterscheidbare Zustände (Glyph+Farbe+Text, nie nur Farbe), Pfeiltasten-Navigation, Opacity-Fader mit echtem parameter.set - Inspector §17.4: 7 Tabs, Zahlenfelder mit Tippen/Ziehen/Pfeiltasten, Release-Befehl, Live-Lock sperrt Struktur - Design-Tokens §17.6: alle als CSS-Variablen (Flächen, Text, Akzent-Blau, Zustandsfarben, 4px-Raster, 2-4px Radien, Monospace tabular-nums, 100-180ms) - WebSocket: Snapshot bei Connect, Deltas danach, Reconnect 1s/2s/4s max 30s; Browser-Neustart stoppt Output nicht (§3.2) - REST: health/identity/cluster-nodes/parameters/diagnostics, Commands mit expected_revision + Revert bei 409 - Kein GO-Button, keine Standby-Anzeige, keine Cue-Carts (§17.1) - Build: tsc strict + vite, 27 Module, 262KB JS gzip 80KB - Python-Suite 549 grün unberührt
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* WebSocket zum Control Core (§17.9, §3.2).
|
||||
*
|
||||
* - Snapshot beim Connect, danach Deltas (parameter.update) plus Heartbeat.
|
||||
* - Reconnect mit Exponential-Backoff: 1 s, 2 s, 4 s … maximum 30 s.
|
||||
* - Browser-Neustart/-Reload stoppt den Output nicht (§3.2): der Client
|
||||
* sendet keinerlei Commands beim Connect und synchronisiert nur Zustand.
|
||||
* - URL: Same-Origin '/ws' (Dev über den Vite-Proxy → ws://localhost:8000/ws);
|
||||
* alternativ direkt über VITE_WS_URL konfigurierbar.
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import type { WsServerEvent } from '../types';
|
||||
|
||||
const RECONNECT_BASE_MS = 1000;
|
||||
const RECONNECT_MAX_MS = 30_000;
|
||||
|
||||
function resolveWsUrl(): string {
|
||||
const override: unknown = import.meta.env.VITE_WS_URL;
|
||||
if (typeof override === 'string' && override.length > 0) return override;
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
return `${protocol}://${window.location.host}/ws`;
|
||||
}
|
||||
|
||||
function isWsServerEvent(data: unknown): data is WsServerEvent {
|
||||
if (typeof data !== 'object' || data === null) return false;
|
||||
const type = (data as { type?: unknown }).type;
|
||||
return type === 'snapshot' || type === 'parameter.update' || type === 'heartbeat';
|
||||
}
|
||||
|
||||
export interface WsState {
|
||||
readonly connected: boolean;
|
||||
/** Engine-Revision aus Snapshot/Updates; null vor der ersten Serverantwort. */
|
||||
readonly revision: number | null;
|
||||
readonly values: Readonly<Record<string, number>>;
|
||||
readonly lastEventMs: number | null;
|
||||
}
|
||||
|
||||
export function useWebSocket(): WsState {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [revision, setRevision] = useState<number | null>(null);
|
||||
const [values, setValues] = useState<Record<string, number>>({});
|
||||
const [lastEventMs, setLastEventMs] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false;
|
||||
let socket: WebSocket | null = null;
|
||||
let timer: number | null = null;
|
||||
let attempt = 0;
|
||||
|
||||
const clearTimer = (): void => {
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = (): void => {
|
||||
const delay = Math.min(RECONNECT_BASE_MS * 2 ** attempt, RECONNECT_MAX_MS);
|
||||
attempt += 1;
|
||||
timer = window.setTimeout(connect, delay);
|
||||
};
|
||||
|
||||
const connect = (): void => {
|
||||
if (disposed) return;
|
||||
socket = new WebSocket(resolveWsUrl());
|
||||
socket.onopen = () => {
|
||||
if (disposed) return;
|
||||
attempt = 0;
|
||||
setConnected(true);
|
||||
};
|
||||
socket.onmessage = (ev: MessageEvent<string>) => {
|
||||
if (disposed) return;
|
||||
setLastEventMs(Date.now());
|
||||
const parsed: unknown = (() => {
|
||||
try {
|
||||
return JSON.parse(ev.data) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
if (!isWsServerEvent(parsed)) return;
|
||||
switch (parsed.type) {
|
||||
case 'snapshot':
|
||||
setValues(parsed.values);
|
||||
setRevision(parsed.revision);
|
||||
break;
|
||||
case 'parameter.update':
|
||||
setValues(prev => ({ ...prev, [parsed.parameter_path]: parsed.value }));
|
||||
setRevision(parsed.revision);
|
||||
break;
|
||||
case 'heartbeat':
|
||||
break;
|
||||
}
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (disposed) return;
|
||||
setConnected(false);
|
||||
scheduleReconnect();
|
||||
};
|
||||
socket.onerror = () => {
|
||||
socket?.close();
|
||||
};
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearTimer();
|
||||
socket?.close(1000, 'client-unmount');
|
||||
socket = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return { connected, revision, values, lastEventMs };
|
||||
}
|
||||
Reference in New Issue
Block a user