feat: add live K1 Foxglove console
This commit is contained in:
@@ -0,0 +1 @@
|
||||
VITE_API_TARGET=http://127.0.0.1:8000
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tsbuildinfo
|
||||
.env
|
||||
.env.local
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
Generated
+1891
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user