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
+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,
};
}