feat: add live K1 Foxglove console

This commit is contained in:
DCCONSTRUCTIONS
2026-07-15 21:53:43 +03:00
parent 6b22e5a1d2
commit 6be96f0b85
34 changed files with 6037 additions and 15 deletions
+1
View File
@@ -0,0 +1 @@
VITE_API_TARGET=http://127.0.0.1:8000
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
*.tsbuildinfo
.env
.env.local
+51
View File
@@ -0,0 +1,51 @@
# K1 Live Console
React 19 + TypeScript + Vite frontend for the local K1 bridge. The console handles
connection setup, source selection, transport status, latency metrics and handoff to
the Foxglove 3D viewer. It does not emulate scanner operations or generate placeholder
telemetry.
## Run locally
```bash
cd apps/k1-viewer
npm install
npm run dev
```
The development server listens on `http://127.0.0.1:5173` and proxies `/api` to
`http://127.0.0.1:8000`. Set `VITE_API_TARGET` in a local `.env` file to use a
different backend address. Production artifacts are built with `npm run build`.
The NODE.DC UI packages are consumed from the sibling `NODEDC_DESIGN_GUIDELINE`
checkout through `file:` dependencies. Their source is not copied into this app.
## Backend contract
| Method | Route | Body / purpose |
| --- | --- | --- |
| `GET` | `/api/health` | Local service health |
| `GET` | `/api/state` | Authoritative console state |
| `POST` | `/api/ble/scan` | `{ "duration_seconds": 6 }` |
| `POST` | `/api/connect` | `{ "device_id", "ssid", "password" }` |
| `POST` | `/api/session/live` | Optional `{ "host", "duration_seconds" }` |
| `POST` | `/api/session/replay` | `{ "path", "speed", "loop" }` |
| `POST` | `/api/session/stop` | Stop the active source |
| `WS` | `/api/events` | State snapshots for live UI updates |
`/api/state` and state-changing responses may return the snapshot directly or as
`{ "state": { ... } }`. A snapshot exposes `phase`, `message`, `devices`,
`selected_device_id`, `k1_ip`, `foxglove_ws_url`, `foxglove_viewer_url`,
`source_mode` and `metrics`.
The Wi-Fi password remains only in React memory, is sent in the JSON POST body, and is
cleared after the backend acknowledges a successful connection. It is never written to
local storage or included in a URL.
## Explicit unavailable states
- Missing metrics render as an em dash; no synthetic values are used.
- BLE scanning remains a real API action and reports HTTP/network errors visibly.
- The Foxglove button is disabled until the backend supplies
`foxglove_viewer_url`.
- Replay cannot start without a backend-local capture path.
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta
name="description"
content="Local control and live telemetry console for an owner-controlled XGRIDS Lixel K1 scanner."
/>
<meta name="theme-color" content="#08090b" />
<title>K1 Live Console · NODE.DC</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+1891
View File
File diff suppressed because it is too large Load Diff
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@nodedc/k1-viewer",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@nodedc/tokens": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/tokens",
"@nodedc/ui-core": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-core",
"@nodedc/ui-react": "file:../../../NODEDC_DESIGN_GUIDELINE/packages/ui-react",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.3",
"vite": "^7.0.0"
},
"engines": {
"node": ">=20"
}
}
+699
View File
@@ -0,0 +1,699 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import {
AppHeader,
ApplicationShell,
Button,
Checker,
GlassSurface,
HeaderAvatar,
HeaderNavigation,
HeaderProfile,
HeaderProfileButton,
Icon,
SegmentedControl,
StatusBadge,
TextField,
type StatusTone,
} from "@nodedc/ui-react";
import type { BleDevice, ConsoleState, K1Metrics } from "./api";
import { useK1Console, type BackendStatus } from "./useK1Console";
type ConsoleSection = "monitor" | "connect" | "session";
type SessionIntent = "live" | "replay";
const sectionItems = [
{ value: "monitor", label: "Monitor" },
{ value: "connect", label: "Connect" },
{ value: "session", label: "Session" },
] as const;
const sessionItems = [
{ value: "live", label: "Live scanner" },
{ value: "replay", label: "Replay capture" },
] satisfies Array<{ value: SessionIntent; label: string }>;
const phaseLabels: Record<string, string> = {
idle: "Idle",
scanning: "BLE scan",
device_selected: "Device selected",
provisioning: "Provisioning Wi-Fi",
connecting: "Connecting",
connected: "K1 connected",
starting_live: "Starting live",
live: "Live stream",
replay: "Replay",
stopping: "Stopping",
error: "Error",
};
function phaseLabel(phase: string | null | undefined): string {
if (!phase) return "No state";
return phaseLabels[phase] ?? phase.replaceAll("_", " ");
}
function phaseTone(phase: string | null | undefined): StatusTone {
if (!phase) return "neutral";
if (phase === "error") return "danger";
if (["connected", "live", "replay"].includes(phase)) return "success";
if (["scanning", "provisioning", "connecting", "starting_live", "stopping"].includes(phase)) {
return "accent";
}
return "neutral";
}
function backendLabel(status: BackendStatus): string {
return {
checking: "Checking API",
online: "API online",
degraded: "API degraded",
offline: "API offline",
}[status];
}
function backendTone(status: BackendStatus): StatusTone {
if (status === "online") return "success";
if (status === "degraded" || status === "checking") return "warning";
return "danger";
}
function finiteMetric(value: number | null | undefined): number | null {
return typeof value === "number" && Number.isFinite(value) ? value : null;
}
function pipelineLatency(metrics: K1Metrics | undefined): number | null {
if (!metrics) return null;
const direct = finiteMetric(metrics.pipeline_ms ?? metrics.end_to_end_ms);
if (direct !== null) return direct;
const segments = [
finiteMetric(metrics.mqtt_to_decode_ms),
finiteMetric(metrics.decode_ms),
finiteMetric(metrics.publish_ms),
].filter((value): value is number => value !== null);
return segments.length ? segments.reduce((total, value) => total + value, 0) : null;
}
function formatNumber(value: number | null, digits = 1): string {
if (value === null) return "—";
return value.toLocaleString("en-US", {
maximumFractionDigits: digits,
minimumFractionDigits: digits,
});
}
function MetricCard({
eyebrow,
value,
unit,
detail,
featured = false,
}: {
eyebrow: string;
value: string;
unit?: string;
detail: string;
featured?: boolean;
}) {
return (
<GlassSurface className="metric-card" padding="md" data-featured={featured ? "true" : undefined}>
<span className="metric-card__eyebrow">{eyebrow}</span>
<div className="metric-card__reading">
<strong>{value}</strong>
{unit ? <span>{unit}</span> : null}
</div>
<p>{detail}</p>
</GlassSurface>
);
}
function DetailRow({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="detail-row">
<dt>{label}</dt>
<dd>{children}</dd>
</div>
);
}
function WizardStep({
number,
title,
status,
tone = "neutral",
children,
}: {
number: string;
title: string;
status: string;
tone?: StatusTone;
children: ReactNode;
}) {
return (
<section className="wizard-step">
<div className="wizard-step__rail" aria-hidden="true">
<span>{number}</span>
</div>
<div className="wizard-step__content">
<header>
<h3>{title}</h3>
<StatusBadge tone={tone}>{status}</StatusBadge>
</header>
{children}
</div>
</section>
);
}
function DeviceRow({
device,
selected,
onSelect,
}: {
device: BleDevice;
selected: boolean;
onSelect: () => void;
}) {
return (
<div className="device-row" data-selected={selected ? "true" : undefined}>
<div className="device-row__identity">
<span className="device-row__signal" aria-hidden="true" />
<div>
<strong>{device.name?.trim() || "K1 candidate"}</strong>
<code>{device.device_id}</code>
</div>
</div>
<div className="device-row__action">
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} dBm`}</span>
<Button
size="compact"
variant={selected ? "accent" : "secondary"}
disabled={device.connectable === false}
onClick={onSelect}
>
{selected ? "Selected" : "Select"}
</Button>
</div>
</div>
);
}
function LatencyTrace({ values }: { values: number[] }) {
const ceiling = Math.max(16, ...values);
return (
<div className="latency-trace" aria-label="Recent measured pipeline latency samples">
{values.length ? (
values.map((value, index) => (
<span
key={`${index}-${value}`}
style={{ height: `${Math.max(8, Math.min(100, (value / ceiling) * 100))}%` }}
title={`${value.toFixed(1)} ms`}
/>
))
) : (
<p>No latency samples yet. The chart remains empty until the backend reports metrics.</p>
)}
</div>
);
}
function FoxglovePanel({ state }: { state: ConsoleState | null }) {
const viewerUrl = state?.foxglove_viewer_url?.trim();
return (
<GlassSurface className="foxglove-panel" padding="lg">
<div className="foxglove-panel__grid" aria-hidden="true" />
<div className="foxglove-panel__content">
<span className="section-eyebrow">3D WORKSPACE</span>
<h2>Point cloud + trajectory</h2>
<p>
Foxglove renders the live topics. This console only reports transport state and never
substitutes a simulated cloud when the scanner is silent.
</p>
<div className="topic-list" aria-label="Foxglove topics">
<code>/k1/points</code>
<code>/k1/pose</code>
<code>/k1/trajectory</code>
</div>
</div>
<div className="foxglove-panel__action">
<StatusBadge tone={viewerUrl ? "accent" : "neutral"}>
{viewerUrl ? "Viewer ready" : "Viewer URL unavailable"}
</StatusBadge>
<Button
variant="accent"
icon={<Icon name="external" />}
disabled={!viewerUrl}
onClick={() => viewerUrl && window.open(viewerUrl, "_blank", "noopener,noreferrer")}
>
Open Foxglove 3D
</Button>
</div>
</GlassSurface>
);
}
export default function App() {
const {
state,
backendStatus,
eventStatus,
pendingAction,
error,
latencyHistory,
refresh,
clearError,
scan,
connect,
startLive,
startReplay,
stop,
} = useK1Console();
const [activeSection, setActiveSection] = useState<ConsoleSection>("monitor");
const [powerConfirmed, setPowerConfirmed] = useState(false);
const [selectedDeviceId, setSelectedDeviceId] = useState("");
const [ssid, setSsid] = useState("");
const [password, setPassword] = useState("");
const [sessionIntent, setSessionIntent] = useState<SessionIntent>("live");
const [liveHost, setLiveHost] = useState("");
const [replayPath, setReplayPath] = useState("");
const [replaySpeed, setReplaySpeed] = useState("1");
const [replayLoop, setReplayLoop] = useState(false);
useEffect(() => {
if (state?.selected_device_id) setSelectedDeviceId(state.selected_device_id);
}, [state?.selected_device_id]);
const metrics = state?.metrics;
const latency = pipelineLatency(metrics);
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
const points = finiteMetric(metrics?.point_count);
const droppedFrames = finiteMetric(metrics?.dropped_preview_frames);
const devices = state?.devices ?? [];
const isBusy = pendingAction !== null;
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
const sourceLabel = state?.source_mode
? state.source_mode === "live"
? "Live"
: state.source_mode === "replay"
? "Replay"
: "Idle"
: "Unknown";
const deviceSummary = useMemo(
() => devices.find((device) => device.device_id === selectedDeviceId),
[devices, selectedDeviceId],
);
const goToSection = (section: ConsoleSection) => {
setActiveSection(section);
document.getElementById(section)?.scrollIntoView({
block: "start",
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
});
};
const submitConnect = async () => {
if (!canConnect) return;
const succeeded = await connect({
device_id: selectedDeviceId,
ssid: ssid.trim(),
password,
});
if (succeeded) setPassword("");
};
const submitLive = () => {
const host = liveHost.trim();
void startLive(host ? { host } : {});
};
const submitReplay = () => {
const speed = Number(replaySpeed);
void startReplay({
path: replayPath.trim(),
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
loop: replayLoop,
});
};
const header = (
<AppHeader
brand={
<span className="brand-lockup">
NODE<span>.DC</span>
</span>
}
brandLabel="NODE.DC"
left={<span className="product-label">K1 LIVE CONSOLE</span>}
center={
<HeaderNavigation
label="Console sections"
value={activeSection}
items={sectionItems}
onChange={goToSection}
/>
}
right={
<HeaderProfile>
<HeaderProfileButton onClick={() => void refresh()} title="Refresh API state">
<span className="api-dot" data-status={backendStatus} aria-hidden="true" />
{backendLabel(backendStatus)}
</HeaderProfileButton>
<HeaderAvatar label="K1" />
</HeaderProfile>
}
/>
);
return (
<ApplicationShell
data-nodedc-ui
className="k1-console"
header={header}
stage={
<div className="console-stage">
<div className="console-canvas">
{error ? (
<aside className="error-banner" role="alert">
<Icon name="alert" size={18} />
<div>
<strong>Local API operation failed</strong>
<p>{error}</p>
</div>
<div className="error-banner__actions">
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
Retry state
</Button>
<Button size="compact" variant="ghost" onClick={clearError}>
Dismiss
</Button>
</div>
</aside>
) : null}
<section className="console-hero" id="monitor">
<div className="console-hero__copy">
<span className="section-eyebrow">LOCAL TELEMETRY BRIDGE</span>
<h1>See the scan arrive, frame by frame.</h1>
<p>
Owner-controlled K1 transport, latency and Foxglove handoff on the local network.
Blank values mean no measurement has arrived yet.
</p>
</div>
<div className="console-hero__status">
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
<span>{state?.message || "Waiting for an authoritative backend state snapshot."}</span>
</div>
</section>
<section className="metrics-grid" aria-label="Live stream metrics">
<MetricCard
featured
eyebrow="PIPELINE LATENCY"
value={formatNumber(latency)}
unit="ms"
detail="MQTT receive → Foxglove publish"
/>
<MetricCard
eyebrow="FRAME RATE"
value={formatNumber(frameRate)}
unit="fps"
detail="Latest backend measurement"
/>
<MetricCard
eyebrow="POINTS / FRAME"
value={points === null ? "—" : points.toLocaleString("en-US", { maximumFractionDigits: 0 })}
detail="Published point count"
/>
<MetricCard
eyebrow="DROPPED PREVIEW"
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("en-US", { maximumFractionDigits: 0 })}
detail="Frames omitted before display"
/>
</section>
<div className="console-layout">
<GlassSurface className="connection-panel" padding="lg" id="connect">
<header className="panel-heading">
<div>
<span className="section-eyebrow">CONNECTION WIZARD</span>
<h2>Bring K1 online</h2>
</div>
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
</header>
<div className="wizard-list">
<WizardStep
number="01"
title="Scanner power"
status={powerConfirmed ? "Confirmed" : "Manual check"}
tone={powerConfirmed ? "success" : "warning"}
>
<Checker
checked={powerConfirmed}
label="K1 is powered and nearby"
description="Local checklist only — this does not switch or write to the scanner."
onChange={setPowerConfirmed}
/>
</WizardStep>
<WizardStep
number="02"
title="Discover over BLE"
status={pendingAction === "scan" ? "Scanning" : `${devices.length} found`}
tone={pendingAction === "scan" ? "accent" : devices.length ? "success" : "neutral"}
>
<p className="step-copy">
Scanning is active discovery. It does not send provisioning writes.
</p>
<Button
width="full"
variant="secondary"
icon={<Icon name="search" />}
disabled={!powerConfirmed || isBusy}
onClick={() => void scan()}
>
{pendingAction === "scan" ? "Scanning for 6 seconds…" : "Scan for K1 devices"}
</Button>
<div className="device-list">
{devices.length ? (
devices.map((device) => (
<DeviceRow
key={device.device_id}
device={device}
selected={device.device_id === selectedDeviceId}
onSelect={() => setSelectedDeviceId(device.device_id)}
/>
))
) : (
<div className="empty-device-list">
No devices reported. Confirm scanner power, then run the real BLE scan.
</div>
)}
</div>
</WizardStep>
<WizardStep
number="03"
title="Local Wi-Fi"
status={credentialsReady ? "Ready" : "Required"}
tone={credentialsReady ? "success" : "neutral"}
>
<div className="field-stack">
<TextField
label="Network name"
hint="SSID"
value={ssid}
onChange={(event) => setSsid(event.target.value)}
autoComplete="off"
spellCheck={false}
placeholder="Workshop Wi-Fi"
/>
<TextField
label="Network password"
hint="Kept in memory only"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
autoComplete="off"
placeholder="Enter password"
/>
</div>
</WizardStep>
<WizardStep
number="04"
title="Provision & connect"
status={state?.k1_ip ? "Connected" : "Not connected"}
tone={state?.k1_ip ? "success" : "neutral"}
>
<div className="connection-summary">
<span>Device</span>
<strong>{deviceSummary?.name || selectedDeviceId || "Select a BLE device"}</strong>
</div>
<Button
width="full"
variant="accent"
icon={<Icon name="network" />}
disabled={!canConnect}
onClick={() => void submitConnect()}
>
{pendingAction === "connect" ? "Provisioning…" : "Provision Wi-Fi & connect"}
</Button>
<p className="safety-note">
The password is sent only in the POST body to the local API and is cleared from
this form after a successful request.
</p>
</WizardStep>
</div>
</GlassSurface>
<div className="monitor-column">
<FoxglovePanel state={state} />
<div className="monitor-grid">
<GlassSurface className="status-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">LIVE STATUS</span>
<h2>Transport</h2>
</div>
<StatusBadge tone={backendTone(backendStatus)}>
{backendLabel(backendStatus)}
</StatusBadge>
</header>
<dl className="detail-list">
<DetailRow label="Events socket">
<span className="inline-state" data-state={eventStatus}>
{eventStatus}
</span>
</DetailRow>
<DetailRow label="Source">{sourceLabel}</DetailRow>
<DetailRow label="K1 address">
<code>{state?.k1_ip || "Not reported"}</code>
</DetailRow>
<DetailRow label="Foxglove bridge">
<code>{state?.foxglove_ws_url || "Not reported"}</code>
</DetailRow>
</dl>
</GlassSurface>
<GlassSurface className="latency-panel" padding="lg">
<header className="panel-heading panel-heading--compact">
<div>
<span className="section-eyebrow">RECENT SAMPLES</span>
<h2>Pipeline latency</h2>
</div>
<strong className="latency-now">
{formatNumber(latency)} <span>ms</span>
</strong>
</header>
<LatencyTrace values={latencyHistory} />
<div className="latency-legend">
<span>Oldest</span>
<span>Latest</span>
</div>
</GlassSurface>
</div>
<GlassSurface className="session-panel" padding="lg" id="session">
<header className="panel-heading">
<div>
<span className="section-eyebrow">SOURCE CONTROL</span>
<h2>Live or replay</h2>
</div>
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
{sourceLabel}
</StatusBadge>
</header>
<SegmentedControl
label="Data source"
value={sessionIntent}
items={sessionItems}
onChange={setSessionIntent}
/>
{sessionIntent === "live" ? (
<div className="session-form">
<TextField
label="K1 host override"
hint="Optional"
value={liveHost}
onChange={(event) => setLiveHost(event.target.value)}
spellCheck={false}
placeholder={state?.k1_ip || "Use connected K1 address"}
/>
<Button
variant="accent"
icon={<Icon name="activity" />}
disabled={isBusy}
onClick={submitLive}
>
{pendingAction === "live" ? "Starting live…" : "Start live stream"}
</Button>
</div>
) : (
<div className="session-form session-form--replay">
<TextField
label="Capture path"
hint="Backend-local path"
value={replayPath}
onChange={(event) => setReplayPath(event.target.value)}
spellCheck={false}
placeholder="sessions/.../mqtt.raw.k1mqtt or capture.tsv"
/>
<TextField
label="Playback speed"
hint="Multiplier"
type="number"
min="0.1"
step="0.1"
value={replaySpeed}
onChange={(event) => setReplaySpeed(event.target.value)}
/>
<Checker
checked={replayLoop}
label="Loop replay"
description="Restart the same capture after its last frame."
onChange={setReplayLoop}
/>
<Button
variant="accent"
icon={<Icon name="video" />}
disabled={isBusy || replayPath.trim().length === 0}
onClick={submitReplay}
>
{pendingAction === "replay" ? "Starting replay…" : "Start replay"}
</Button>
</div>
)}
<div className="session-footer">
<p>
Source changes call the local API. The console never toggles its status before
the backend acknowledges the request.
</p>
<Button
variant="secondary"
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
onClick={() => void stop()}
>
{pendingAction === "stop" ? "Stopping…" : "Stop session"}
</Button>
</div>
</GlassSurface>
</div>
</div>
</div>
</div>
}
/>
);
}
+210
View File
@@ -0,0 +1,210 @@
export interface BleDevice {
device_id: string;
name?: string | null;
rssi?: number | null;
address?: string | null;
connectable?: boolean | null;
}
export type SourceMode = "idle" | "live" | "replay";
export interface K1Metrics {
mqtt_to_decode_ms?: number | null;
decode_ms?: number | null;
publish_ms?: number | null;
pipeline_ms?: number | null;
end_to_end_ms?: number | null;
frame_rate?: number | null;
frame_rate_hz?: number | null;
point_count?: number | null;
dropped_preview_frames?: number | null;
[key: string]: number | null | undefined;
}
export interface ConsoleState {
phase?: string | null;
message?: string | null;
devices?: BleDevice[];
selected_device_id?: string | null;
k1_ip?: string | null;
foxglove_ws_url?: string | null;
foxglove_viewer_url?: string | null;
source_mode?: SourceMode | null;
metrics?: K1Metrics;
}
export interface HealthResponse {
ok?: boolean;
status?: string;
service?: string;
version?: string;
}
export interface ScanRequest {
duration_seconds?: number;
}
export interface ConnectRequest {
device_id: string;
ssid: string;
password: string;
}
export interface LiveRequest {
host?: string;
duration_seconds?: number;
}
export interface ReplayRequest {
path: string;
speed?: number;
loop?: boolean;
}
export class ApiError extends Error {
readonly status: number;
constructor(message: string, status = 0) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function unwrapState(payload: unknown): ConsoleState {
const value = isRecord(payload) && isRecord(payload.state) ? payload.state : payload;
if (!isRecord(value)) {
throw new ApiError("Backend returned an invalid state snapshot.");
}
return value as ConsoleState;
}
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
let response: Response;
try {
response = await fetch(path, {
...init,
headers: {
Accept: "application/json",
...(init?.body ? { "Content-Type": "application/json" } : {}),
...init?.headers,
},
});
} catch (error) {
throw new ApiError(
error instanceof Error
? `Cannot reach the local K1 API: ${error.message}`
: "Cannot reach the local K1 API.",
);
}
const bodyText = await response.text();
let body: unknown;
if (bodyText) {
try {
body = JSON.parse(bodyText) as unknown;
} catch {
body = bodyText;
}
}
if (!response.ok) {
const detail =
isRecord(body) && typeof body.detail === "string"
? body.detail
: typeof body === "string" && body.trim()
? body.trim()
: response.statusText;
throw new ApiError(
detail || `K1 API request failed with HTTP ${response.status}.`,
response.status,
);
}
return body;
}
async function postState(path: string, body?: object): Promise<ConsoleState> {
const payload = await requestJson(path, {
method: "POST",
body: body ? JSON.stringify(body) : undefined,
});
if (payload === undefined) {
return api.getState();
}
return unwrapState(payload);
}
export const api = {
async getHealth(): Promise<HealthResponse> {
const payload = await requestJson("/api/health");
if (!isRecord(payload)) {
throw new ApiError("Backend returned an invalid health response.");
}
return payload as HealthResponse;
},
async getState(): Promise<ConsoleState> {
return unwrapState(await requestJson("/api/state"));
},
scanBle(body: ScanRequest = {}): Promise<ConsoleState> {
return postState("/api/ble/scan", body);
},
connect(body: ConnectRequest): Promise<ConsoleState> {
return postState("/api/connect", body);
},
startLive(body: LiveRequest = {}): Promise<ConsoleState> {
return postState("/api/session/live", body);
},
startReplay(body: ReplayRequest): Promise<ConsoleState> {
return postState("/api/session/replay", body);
},
stopSession(): Promise<ConsoleState> {
return postState("/api/session/stop");
},
};
export type EventSocketStatus = "connecting" | "open" | "closed" | "error";
function eventSocketUrl(): string {
const url = new URL("/api/events", window.location.href);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
return url.toString();
}
export function openEventSocket(
onState: (state: ConsoleState) => void,
onStatus: (status: EventSocketStatus) => void,
): () => void {
onStatus("connecting");
const socket = new WebSocket(eventSocketUrl());
socket.addEventListener("open", () => onStatus("open"));
socket.addEventListener("message", (event) => {
try {
const payload = JSON.parse(String(event.data)) as unknown;
onState(unwrapState(payload));
} catch {
// The REST poll remains authoritative if an unrelated event is received.
}
});
socket.addEventListener("error", () => onStatus("error"));
socket.addEventListener("close", () => onStatus("closed"));
return () => socket.close(1000, "K1 console unmounted");
}
+25
View File
@@ -0,0 +1,25 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { applyNodedcTheme } from "@nodedc/ui-core";
import "@nodedc/tokens/tokens.css";
import "@nodedc/tokens/themes.css";
import "@nodedc/ui-core/styles.css";
import App from "./App";
import "./styles.css";
const rootElement = document.getElementById("root");
if (!rootElement) {
throw new Error("K1 console root element is missing.");
}
rootElement.classList.add("nodedc-ui-root");
rootElement.dataset.nodedcUi = "";
applyNodedcTheme(rootElement, { theme: "dark" });
createRoot(rootElement).render(
<StrictMode>
<App />
</StrictMode>,
);
+884
View File
@@ -0,0 +1,884 @@
:root {
background: #050506;
}
html {
min-width: 320px;
min-height: 100%;
background: #050506;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background: #050506;
}
button,
input {
font: inherit;
}
#root {
min-height: 100vh;
}
.k1-console {
--k1-panel: #151517;
--k1-panel-soft: #0d0d0f;
--k1-hairline: rgba(255, 255, 255, 0.08);
--k1-accent-soft: rgb(var(--nodedc-accent-rgb) / 0.11);
--k1-success-soft: rgb(var(--nodedc-success-rgb) / 0.1);
}
.brand-lockup {
display: inline-flex;
height: 100%;
align-items: center;
color: var(--nodedc-text-primary);
font-size: 1.32rem;
font-weight: 850;
letter-spacing: -0.075em;
line-height: 1;
}
.brand-lockup span {
color: rgb(var(--nodedc-accent-rgb));
}
.product-label {
overflow: hidden;
color: var(--nodedc-text-muted);
font-size: 0.64rem;
font-weight: 800;
letter-spacing: 0.13em;
text-overflow: ellipsis;
white-space: nowrap;
}
.api-dot {
width: 0.48rem;
height: 0.48rem;
flex: 0 0 0.48rem;
border-radius: 999px;
background: var(--nodedc-text-muted);
}
.api-dot[data-status="online"] {
background: rgb(var(--nodedc-success-rgb));
box-shadow: 0 0 0 0.28rem rgb(var(--nodedc-success-rgb) / 0.09);
}
.api-dot[data-status="checking"],
.api-dot[data-status="degraded"] {
background: rgb(var(--nodedc-warning-rgb));
}
.api-dot[data-status="offline"] {
background: rgb(var(--nodedc-danger-rgb));
}
.console-stage {
height: 100%;
overflow: auto;
overscroll-behavior: contain;
scrollbar-color: var(--nodedc-scrollbar-thumb) transparent;
}
.console-canvas {
position: relative;
width: min(100%, 112rem);
min-height: 100%;
margin: 0 auto;
padding: 0 0 2.75rem;
}
.console-canvas::before {
position: absolute;
z-index: 0;
top: -8rem;
right: -8rem;
width: 34rem;
height: 34rem;
border-radius: 50%;
background: radial-gradient(circle, rgb(var(--nodedc-accent-rgb) / 0.08), transparent 66%);
content: "";
pointer-events: none;
}
.console-canvas > * {
position: relative;
z-index: 1;
}
.error-banner {
display: grid;
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: 0.9rem;
margin-bottom: 1.25rem;
border-radius: 1.1rem;
background: rgb(var(--nodedc-danger-rgb) / 0.1);
color: color-mix(in srgb, rgb(var(--nodedc-danger-rgb)) 82%, white);
padding: 0.85rem 1rem;
}
.error-banner > svg {
align-self: start;
margin-top: 0.12rem;
}
.error-banner strong,
.error-banner p {
margin: 0;
}
.error-banner strong {
color: var(--nodedc-text-primary);
font-size: 0.8rem;
}
.error-banner p {
margin-top: 0.2rem;
color: var(--nodedc-text-secondary);
font-size: 0.74rem;
line-height: 1.45;
}
.error-banner__actions {
display: flex;
align-items: center;
gap: 0.35rem;
}
.console-hero {
display: flex;
min-height: 15.5rem;
align-items: flex-end;
justify-content: space-between;
gap: 3rem;
overflow: hidden;
scroll-margin-top: 1rem;
border-radius: var(--nodedc-radius-card);
background:
linear-gradient(112deg, rgba(255, 255, 255, 0.048), transparent 44%),
radial-gradient(circle at 79% 16%, rgb(var(--nodedc-accent-rgb) / 0.12), transparent 34%),
#0b0b0d;
padding: clamp(1.6rem, 3vw, 3rem);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.045);
}
.console-hero__copy {
max-width: 46rem;
}
.section-eyebrow,
.metric-card__eyebrow {
display: block;
color: var(--nodedc-text-muted);
font-size: 0.64rem;
font-weight: 820;
letter-spacing: 0.12em;
line-height: 1.2;
}
.console-hero h1 {
max-width: 42rem;
margin: 0.65rem 0 0.9rem;
font-size: clamp(2rem, 4vw, 4.2rem);
font-weight: 710;
letter-spacing: -0.055em;
line-height: 0.98;
}
.console-hero__copy p {
max-width: 39rem;
margin: 0;
color: var(--nodedc-text-secondary);
font-size: 0.86rem;
line-height: 1.55;
}
.console-hero__status {
display: grid;
max-width: 21rem;
justify-items: end;
gap: 0.75rem;
color: var(--nodedc-text-muted);
font-size: 0.74rem;
line-height: 1.45;
text-align: right;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.metric-card {
min-height: 9.3rem;
}
.metric-card[data-featured="true"] {
background: linear-gradient(145deg, var(--k1-accent-soft), transparent 72%), var(--k1-panel);
}
.metric-card__reading {
display: flex;
align-items: baseline;
gap: 0.45rem;
margin-top: 1.15rem;
}
.metric-card__reading strong {
font-size: clamp(1.75rem, 2.6vw, 2.7rem);
font-weight: 690;
letter-spacing: -0.055em;
line-height: 1;
}
.metric-card__reading span {
color: var(--nodedc-text-muted);
font-size: 0.76rem;
font-weight: 700;
}
.metric-card p {
margin: 0.9rem 0 0;
color: var(--nodedc-text-muted);
font-size: 0.7rem;
line-height: 1.35;
}
.console-layout {
display: grid;
grid-template-columns: minmax(21rem, 28.5rem) minmax(0, 1fr);
align-items: start;
gap: 1.25rem;
margin-top: 1.25rem;
}
.connection-panel,
.session-panel {
scroll-margin-top: 1rem;
}
.connection-panel {
background: var(--k1-panel);
}
.panel-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.panel-heading h2 {
margin: 0.42rem 0 0;
font-size: 1.3rem;
font-weight: 730;
letter-spacing: -0.035em;
line-height: 1.1;
}
.panel-heading--compact {
align-items: center;
}
.wizard-list {
display: grid;
margin-top: 1.5rem;
}
.wizard-step {
display: grid;
grid-template-columns: 2.25rem minmax(0, 1fr);
gap: 0.8rem;
}
.wizard-step__rail {
position: relative;
display: flex;
justify-content: center;
}
.wizard-step__rail::after {
position: absolute;
top: 2rem;
bottom: 0;
left: 50%;
width: 1px;
background: var(--k1-hairline);
content: "";
}
.wizard-step:last-child .wizard-step__rail::after {
display: none;
}
.wizard-step__rail span {
position: relative;
z-index: 1;
display: grid;
width: 2rem;
height: 2rem;
place-items: center;
border-radius: 999px;
background: #27272a;
color: var(--nodedc-text-secondary);
font-size: 0.62rem;
font-weight: 800;
}
.wizard-step__content {
min-width: 0;
padding: 0.1rem 0 1.6rem;
}
.wizard-step:last-child .wizard-step__content {
padding-bottom: 0;
}
.wizard-step__content > header {
display: flex;
min-height: 2rem;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
margin-bottom: 0.8rem;
}
.wizard-step__content h3 {
margin: 0;
font-size: 0.86rem;
font-weight: 720;
}
.step-copy,
.safety-note {
margin: 0 0 0.75rem;
color: var(--nodedc-text-muted);
font-size: 0.69rem;
line-height: 1.45;
}
.safety-note {
margin: 0.7rem 0 0;
}
.device-list {
display: grid;
gap: 0.5rem;
margin-top: 0.75rem;
}
.device-row {
display: grid;
gap: 0.65rem;
border-radius: 1rem;
background: rgba(255, 255, 255, 0.035);
padding: 0.75rem;
}
.device-row[data-selected="true"] {
background: var(--k1-accent-soft);
box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.18);
}
.device-row__identity,
.device-row__action {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 0.7rem;
}
.device-row__identity {
justify-content: flex-start;
}
.device-row__identity > div {
display: grid;
min-width: 0;
gap: 0.2rem;
}
.device-row__identity strong {
overflow: hidden;
font-size: 0.74rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-row code,
.detail-row code {
overflow: hidden;
color: var(--nodedc-text-muted);
font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
font-size: 0.62rem;
text-overflow: ellipsis;
white-space: nowrap;
}
.device-row__signal {
width: 0.6rem;
height: 0.6rem;
flex: 0 0 0.6rem;
border-radius: 50%;
background: rgb(var(--nodedc-success-rgb));
box-shadow: 0 0 0 0.25rem var(--k1-success-soft);
}
.device-row__action > span {
color: var(--nodedc-text-muted);
font-size: 0.66rem;
}
.empty-device-list {
border-radius: 1rem;
background: rgba(255, 255, 255, 0.025);
color: var(--nodedc-text-muted);
padding: 0.95rem;
font-size: 0.69rem;
line-height: 1.45;
}
.field-stack {
display: grid;
gap: 0.9rem;
}
.connection-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 0.75rem;
border-radius: 0.9rem;
background: rgba(255, 255, 255, 0.03);
padding: 0.65rem 0.8rem;
font-size: 0.69rem;
}
.connection-summary span {
color: var(--nodedc-text-muted);
}
.connection-summary strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.monitor-column {
display: grid;
min-width: 0;
gap: 1.25rem;
}
.foxglove-panel {
position: relative;
display: grid;
min-height: 20rem;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
overflow: hidden;
background: #0b0b0e;
}
.foxglove-panel__grid {
position: absolute;
inset: 0;
opacity: 0.36;
background-image:
linear-gradient(rgb(255 255 255 / 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 0.05) 1px, transparent 1px);
background-position: center;
background-size: 3rem 3rem;
mask-image: radial-gradient(circle at 58% 48%, #000 0%, transparent 66%);
transform: perspective(32rem) rotateX(56deg) scale(1.42) translateY(18%);
}
.foxglove-panel::after {
position: absolute;
top: 16%;
right: 14%;
width: 13rem;
height: 13rem;
border-radius: 50%;
background: radial-gradient(circle, rgb(var(--nodedc-accent-rgb) / 0.13), transparent 68%);
content: "";
pointer-events: none;
}
.foxglove-panel__content,
.foxglove-panel__action {
position: relative;
z-index: 1;
}
.foxglove-panel__content {
max-width: 37rem;
}
.foxglove-panel h2 {
margin: 0.5rem 0 0.7rem;
font-size: clamp(1.6rem, 3vw, 3rem);
font-weight: 690;
letter-spacing: -0.05em;
line-height: 1;
}
.foxglove-panel p {
max-width: 33rem;
margin: 0;
color: var(--nodedc-text-secondary);
font-size: 0.78rem;
line-height: 1.55;
}
.topic-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 1.2rem;
}
.topic-list code {
border-radius: 999px;
background: rgba(255, 255, 255, 0.055);
color: var(--nodedc-text-secondary);
padding: 0.42rem 0.7rem;
font-size: 0.64rem;
}
.foxglove-panel__action {
display: grid;
justify-items: end;
gap: 0.75rem;
}
.monitor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1.25rem;
}
.status-panel,
.latency-panel,
.session-panel {
background: var(--k1-panel);
}
.detail-list {
display: grid;
gap: 0;
margin: 1rem 0 0;
}
.detail-row {
display: grid;
min-width: 0;
grid-template-columns: minmax(7rem, 0.72fr) minmax(0, 1.28fr);
gap: 1rem;
border-top: 1px solid var(--k1-hairline);
padding: 0.75rem 0;
}
.detail-row dt,
.detail-row dd {
min-width: 0;
margin: 0;
font-size: 0.7rem;
}
.detail-row dt {
color: var(--nodedc-text-muted);
}
.detail-row dd {
overflow: hidden;
color: var(--nodedc-text-secondary);
text-align: right;
text-overflow: ellipsis;
text-transform: capitalize;
white-space: nowrap;
}
.inline-state {
display: inline-flex;
align-items: center;
gap: 0.35rem;
}
.inline-state::before {
width: 0.42rem;
height: 0.42rem;
border-radius: 50%;
background: var(--nodedc-text-muted);
content: "";
}
.inline-state[data-state="open"]::before {
background: rgb(var(--nodedc-success-rgb));
}
.inline-state[data-state="error"]::before,
.inline-state[data-state="closed"]::before {
background: rgb(var(--nodedc-danger-rgb));
}
.latency-now {
color: var(--nodedc-text-primary);
font-size: 1.35rem;
font-weight: 680;
letter-spacing: -0.04em;
}
.latency-now span {
color: var(--nodedc-text-muted);
font-size: 0.66rem;
letter-spacing: 0;
}
.latency-trace {
display: flex;
height: 8.4rem;
align-items: end;
gap: 0.22rem;
margin-top: 1.25rem;
border-radius: 0.9rem;
background:
linear-gradient(to top, rgba(255, 255, 255, 0.035) 1px, transparent 1px),
rgba(255, 255, 255, 0.018);
background-size: 100% 25%;
padding: 0.75rem;
}
.latency-trace span {
min-width: 0.18rem;
flex: 1 1 0;
border-radius: 999px 999px 0.14rem 0.14rem;
background: linear-gradient(to top, rgb(var(--nodedc-accent-rgb) / 0.35), rgb(var(--nodedc-accent-rgb)));
}
.latency-trace p {
align-self: center;
margin: auto;
color: var(--nodedc-text-muted);
font-size: 0.68rem;
line-height: 1.45;
text-align: center;
}
.latency-legend {
display: flex;
justify-content: space-between;
margin-top: 0.4rem;
color: var(--nodedc-text-muted);
font-size: 0.58rem;
}
.session-panel > .nodedc-segmented {
margin-top: 1.4rem;
}
.session-form {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
gap: 0.85rem;
margin-top: 1rem;
}
.session-form--replay {
grid-template-columns: minmax(14rem, 1fr) minmax(8rem, 0.3fr) minmax(12rem, 0.5fr) auto;
}
.session-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-top: 1.25rem;
border-top: 1px solid var(--k1-hairline);
padding-top: 1rem;
}
.session-footer p {
max-width: 42rem;
margin: 0;
color: var(--nodedc-text-muted);
font-size: 0.68rem;
line-height: 1.5;
}
@media (max-width: 1320px) {
.metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.console-layout {
grid-template-columns: minmax(20rem, 24rem) minmax(0, 1fr);
}
.monitor-grid {
grid-template-columns: 1fr;
}
.session-form--replay {
grid-template-columns: minmax(0, 1fr) minmax(7rem, 0.3fr);
}
.session-form--replay > :nth-child(3),
.session-form--replay > :nth-child(4) {
grid-column: span 1;
}
}
@media (max-width: 980px) {
.console-layout {
grid-template-columns: 1fr;
}
.connection-panel {
order: 2;
}
.monitor-column {
order: 1;
}
.foxglove-panel {
min-height: 18rem;
}
}
@media (max-width: 760px) {
.product-label {
display: none;
}
.console-canvas {
padding-bottom: 1.5rem;
}
.error-banner {
grid-template-columns: auto minmax(0, 1fr);
}
.error-banner__actions {
grid-column: 1 / -1;
justify-content: flex-end;
}
.console-hero {
min-height: 20rem;
flex-direction: column;
align-items: flex-start;
justify-content: flex-end;
gap: 1.4rem;
}
.console-hero h1 {
font-size: 2.5rem;
}
.console-hero__status {
max-width: none;
justify-items: start;
text-align: left;
}
.metrics-grid {
grid-template-columns: 1fr 1fr;
gap: 0.7rem;
margin-top: 0.7rem;
}
.metric-card {
min-height: 8.2rem;
}
.console-layout,
.monitor-column,
.monitor-grid {
gap: 0.7rem;
margin-top: 0.7rem;
}
.foxglove-panel {
grid-template-columns: 1fr;
gap: 2rem;
}
.foxglove-panel__action {
justify-items: start;
}
.session-form,
.session-form--replay {
grid-template-columns: 1fr;
}
.session-form--replay > :nth-child(3),
.session-form--replay > :nth-child(4) {
grid-column: auto;
}
.session-footer {
align-items: stretch;
flex-direction: column;
}
}
@media (max-width: 480px) {
.metrics-grid {
grid-template-columns: 1fr;
}
.panel-heading {
align-items: flex-start;
flex-direction: column;
}
.wizard-step {
grid-template-columns: 1.8rem minmax(0, 1fr);
gap: 0.55rem;
}
.wizard-step__rail span {
width: 1.65rem;
height: 1.65rem;
}
.wizard-step__rail::after {
top: 1.65rem;
}
.wizard-step__content > header {
align-items: flex-start;
flex-direction: column;
}
.session-panel > .nodedc-segmented {
display: grid;
width: 100%;
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
+191
View File
@@ -0,0 +1,191 @@
import { useCallback, useEffect, useRef, useState } from "react";
import {
ApiError,
api,
openEventSocket,
type ConnectRequest,
type ConsoleState,
type EventSocketStatus,
type LiveRequest,
type ReplayRequest,
} from "./api";
export type BackendStatus = "checking" | "online" | "degraded" | "offline";
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop";
function messageFor(error: unknown): string {
if (error instanceof ApiError) {
return error.status
? `${error.message} (HTTP ${error.status})`
: error.message;
}
return error instanceof Error ? error.message : "The local K1 API request failed.";
}
function measuredLatency(state: ConsoleState | null): number | null {
const metrics = state?.metrics;
if (!metrics) return null;
const reported = metrics.pipeline_ms ?? metrics.end_to_end_ms;
if (typeof reported === "number" && Number.isFinite(reported)) return reported;
const segments = [
metrics.mqtt_to_decode_ms,
metrics.decode_ms,
metrics.publish_ms,
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
return segments.length ? segments.reduce((total, value) => total + value, 0) : null;
}
export function useK1Console() {
const [state, setState] = useState<ConsoleState | null>(null);
const [backendStatus, setBackendStatus] = useState<BackendStatus>("checking");
const [eventStatus, setEventStatus] = useState<EventSocketStatus>("connecting");
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
const [error, setError] = useState<string | null>(null);
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
const mounted = useRef(true);
const acceptState = useCallback((nextState: ConsoleState) => {
setState(nextState);
setBackendStatus("online");
}, []);
const refresh = useCallback(async (reportErrors = true) => {
const [healthResult, stateResult] = await Promise.allSettled([
api.getHealth(),
api.getState(),
]);
if (!mounted.current) return;
if (stateResult.status === "fulfilled") {
acceptState(stateResult.value);
if (reportErrors) setError(null);
}
if (healthResult.status === "fulfilled") {
const health = healthResult.value;
const healthy = health.ok !== false && health.status !== "error";
setBackendStatus(healthy && stateResult.status === "fulfilled" ? "online" : "degraded");
} else if (stateResult.status === "rejected") {
setBackendStatus("offline");
}
if (stateResult.status === "rejected" && reportErrors) {
setError(messageFor(stateResult.reason));
}
}, [acceptState]);
const run = useCallback(
async (action: PendingAction, operation: () => Promise<ConsoleState>) => {
setPendingAction(action);
setError(null);
try {
const nextState = await operation();
if (mounted.current) acceptState(nextState);
return true;
} catch (operationError) {
if (mounted.current) {
setError(messageFor(operationError));
if (operationError instanceof ApiError && operationError.status === 0) {
setBackendStatus("offline");
}
}
return false;
} finally {
if (mounted.current) setPendingAction(null);
}
},
[acceptState],
);
const scan = useCallback(
() => run("scan", () => api.scanBle({ duration_seconds: 6 })),
[run],
);
const connect = useCallback(
(request: ConnectRequest) => run("connect", () => api.connect(request)),
[run],
);
const startLive = useCallback(
(request: LiveRequest = {}) => run("live", () => api.startLive(request)),
[run],
);
const startReplay = useCallback(
(request: ReplayRequest) => run("replay", () => api.startReplay(request)),
[run],
);
const stop = useCallback(
() => run("stop", () => api.stopSession()),
[run],
);
useEffect(() => {
mounted.current = true;
void refresh(true);
const poll = window.setInterval(() => void refresh(false), 4_000);
return () => {
mounted.current = false;
window.clearInterval(poll);
};
}, [refresh]);
useEffect(() => {
let dispose: (() => void) | undefined;
let retry: number | undefined;
let cancelled = false;
const connectEvents = () => {
if (cancelled) return;
dispose = openEventSocket(acceptState, (status) => {
if (cancelled) return;
setEventStatus(status);
if ((status === "closed" || status === "error") && retry === undefined) {
retry = window.setTimeout(() => {
retry = undefined;
connectEvents();
}, 3_000);
}
});
};
connectEvents();
return () => {
cancelled = true;
if (retry !== undefined) window.clearTimeout(retry);
dispose?.();
};
}, [acceptState]);
useEffect(() => {
const latency = measuredLatency(state);
if (latency === null) return;
setLatencyHistory((values) => [...values.slice(-23), latency]);
}, [state]);
return {
state,
backendStatus,
eventStatus,
pendingAction,
error,
latencyHistory,
refresh: () => refresh(true),
clearError: () => setError(null),
scan,
connect,
startLive,
startReplay,
stop,
};
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+16
View File
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true
},
"include": ["vite.config.ts"]
}
+28
View File
@@ -0,0 +1,28 @@
import { defineConfig, loadEnv } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const apiTarget = env.VITE_API_TARGET || "http://127.0.0.1:8000";
return {
plugins: [react()],
server: {
host: "127.0.0.1",
port: 5173,
strictPort: true,
proxy: {
"/api": {
target: apiTarget,
changeOrigin: false,
ws: true,
},
},
},
preview: {
host: "127.0.0.1",
port: 4173,
strictPort: true,
},
};
});