feat: localize K1 console to Russian
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="ru">
|
||||
<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."
|
||||
content="Локальная консоль подключения XGRIDS Lixel K1 и просмотра облака точек в реальном времени."
|
||||
/>
|
||||
<meta name="theme-color" content="#08090b" />
|
||||
<title>K1 Live Console · NODE.DC</title>
|
||||
<title>K1 · Облако точек в реальном времени · NODE.DC</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
+191
-130
@@ -23,33 +23,33 @@ type ConsoleSection = "monitor" | "connect" | "session";
|
||||
type SessionIntent = "live" | "replay";
|
||||
|
||||
const sectionItems = [
|
||||
{ value: "monitor", label: "Monitor" },
|
||||
{ value: "connect", label: "Connect" },
|
||||
{ value: "session", label: "Session" },
|
||||
{ value: "monitor", label: "Монитор" },
|
||||
{ value: "connect", label: "Подключение" },
|
||||
{ value: "session", label: "Поток" },
|
||||
] as const;
|
||||
|
||||
const sessionItems = [
|
||||
{ value: "live", label: "Live scanner" },
|
||||
{ value: "replay", label: "Replay capture" },
|
||||
{ value: "live", label: "Сканер в реальном времени" },
|
||||
{ value: "replay", label: "Повтор записи" },
|
||||
] 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",
|
||||
idle: "Ожидание",
|
||||
scanning: "Поиск по BLE",
|
||||
device_selected: "K1 выбран",
|
||||
provisioning: "Передача настроек Wi‑Fi",
|
||||
connecting: "Подключение",
|
||||
connected: "K1 подключён",
|
||||
starting_live: "Запуск потока",
|
||||
live: "Поток в реальном времени",
|
||||
replay: "Повтор записи",
|
||||
stopping: "Остановка",
|
||||
error: "Ошибка",
|
||||
};
|
||||
|
||||
function phaseLabel(phase: string | null | undefined): string {
|
||||
if (!phase) return "No state";
|
||||
return phaseLabels[phase] ?? phase.replaceAll("_", " ");
|
||||
if (!phase) return "Нет состояния";
|
||||
return phaseLabels[phase] ?? "Неизвестное состояние";
|
||||
}
|
||||
|
||||
function phaseTone(phase: string | null | undefined): StatusTone {
|
||||
@@ -64,13 +64,22 @@ function phaseTone(phase: string | null | undefined): StatusTone {
|
||||
|
||||
function backendLabel(status: BackendStatus): string {
|
||||
return {
|
||||
checking: "Checking API",
|
||||
online: "API online",
|
||||
degraded: "API degraded",
|
||||
offline: "API offline",
|
||||
checking: "Проверка API",
|
||||
online: "API работает",
|
||||
degraded: "API работает частично",
|
||||
offline: "API недоступен",
|
||||
}[status];
|
||||
}
|
||||
|
||||
function eventStatusLabel(status: string): string {
|
||||
return {
|
||||
connecting: "подключение",
|
||||
open: "подключён",
|
||||
closed: "закрыт",
|
||||
error: "ошибка",
|
||||
}[status] ?? "неизвестно";
|
||||
}
|
||||
|
||||
function backendTone(status: BackendStatus): StatusTone {
|
||||
if (status === "online") return "success";
|
||||
if (status === "degraded" || status === "checking") return "warning";
|
||||
@@ -97,7 +106,7 @@ function pipelineLatency(metrics: K1Metrics | undefined): number | null {
|
||||
|
||||
function formatNumber(value: number | null, digits = 1): string {
|
||||
if (value === null) return "—";
|
||||
return value.toLocaleString("en-US", {
|
||||
return value.toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: digits,
|
||||
minimumFractionDigits: digits,
|
||||
});
|
||||
@@ -180,19 +189,19 @@ function DeviceRow({
|
||||
<div className="device-row__identity">
|
||||
<span className="device-row__signal" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{device.name?.trim() || "K1 candidate"}</strong>
|
||||
<strong>{device.name?.trim() || "Возможный K1"}</strong>
|
||||
<code>{device.device_id}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="device-row__action">
|
||||
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} dBm`}</span>
|
||||
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={selected ? "accent" : "secondary"}
|
||||
disabled={device.connectable === false}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{selected ? "Selected" : "Select"}
|
||||
{selected ? "Выбран" : "Выбрать"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -203,17 +212,17 @@ function LatencyTrace({ values }: { values: number[] }) {
|
||||
const ceiling = Math.max(16, ...values);
|
||||
|
||||
return (
|
||||
<div className="latency-trace" aria-label="Recent measured pipeline latency samples">
|
||||
<div className="latency-trace" aria-label="Последние измерения задержки потока">
|
||||
{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`}
|
||||
title={`${value.toFixed(1)} мс`}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p>No latency samples yet. The chart remains empty until the backend reports metrics.</p>
|
||||
<p>Измерений пока нет. График появится после получения первых данных от K1.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
@@ -226,13 +235,13 @@ function FoxglovePanel({ state }: { state: ConsoleState | null }) {
|
||||
<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>
|
||||
<span className="section-eyebrow">ШАГ 06 · 3D-ПРОСМОТР</span>
|
||||
<h2>Облако точек и траектория</h2>
|
||||
<p>
|
||||
Foxglove renders the live topics. This console only reports transport state and never
|
||||
substitutes a simulated cloud when the scanner is silent.
|
||||
Foxglove показывает реальные данные сканера. Если K1 ничего не передаёт, здесь не будет
|
||||
подставного или сгенерированного облака.
|
||||
</p>
|
||||
<div className="topic-list" aria-label="Foxglove topics">
|
||||
<div className="topic-list" aria-label="Темы Foxglove">
|
||||
<code>/k1/points</code>
|
||||
<code>/k1/pose</code>
|
||||
<code>/k1/trajectory</code>
|
||||
@@ -240,7 +249,7 @@ function FoxglovePanel({ state }: { state: ConsoleState | null }) {
|
||||
</div>
|
||||
<div className="foxglove-panel__action">
|
||||
<StatusBadge tone={viewerUrl ? "accent" : "neutral"}>
|
||||
{viewerUrl ? "Viewer ready" : "Viewer URL unavailable"}
|
||||
{viewerUrl ? "Просмотр готов" : "Сначала запустите поток"}
|
||||
</StatusBadge>
|
||||
<Button
|
||||
variant="accent"
|
||||
@@ -248,7 +257,7 @@ function FoxglovePanel({ state }: { state: ConsoleState | null }) {
|
||||
disabled={!viewerUrl}
|
||||
onClick={() => viewerUrl && window.open(viewerUrl, "_blank", "noopener,noreferrer")}
|
||||
>
|
||||
Open Foxglove 3D
|
||||
Открыть облако точек в Foxglove
|
||||
</Button>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
@@ -296,14 +305,15 @@ export default function App() {
|
||||
const isBusy = pendingAction !== null;
|
||||
const credentialsReady = ssid.trim().length > 0 && password.length > 0;
|
||||
const canConnect = powerConfirmed && selectedDeviceId.length > 0 && credentialsReady && !isBusy;
|
||||
const liveTargetReady = Boolean(state?.k1_ip || liveHost.trim());
|
||||
|
||||
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),
|
||||
@@ -350,10 +360,10 @@ export default function App() {
|
||||
</span>
|
||||
}
|
||||
brandLabel="NODE.DC"
|
||||
left={<span className="product-label">K1 LIVE CONSOLE</span>}
|
||||
left={<span className="product-label">K1 · ОБЛАКО ТОЧЕК В РЕАЛЬНОМ ВРЕМЕНИ</span>}
|
||||
center={
|
||||
<HeaderNavigation
|
||||
label="Console sections"
|
||||
label="Разделы консоли"
|
||||
value={activeSection}
|
||||
items={sectionItems}
|
||||
onChange={goToSection}
|
||||
@@ -361,7 +371,7 @@ export default function App() {
|
||||
}
|
||||
right={
|
||||
<HeaderProfile>
|
||||
<HeaderProfileButton onClick={() => void refresh()} title="Refresh API state">
|
||||
<HeaderProfileButton onClick={() => void refresh()} title="Обновить состояние API">
|
||||
<span className="api-dot" data-status={backendStatus} aria-hidden="true" />
|
||||
{backendLabel(backendStatus)}
|
||||
</HeaderProfileButton>
|
||||
@@ -383,15 +393,15 @@ export default function App() {
|
||||
<aside className="error-banner" role="alert">
|
||||
<Icon name="alert" size={18} />
|
||||
<div>
|
||||
<strong>Local API operation failed</strong>
|
||||
<strong>Локальная операция завершилась ошибкой</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>
|
||||
@@ -399,42 +409,75 @@ export default function App() {
|
||||
|
||||
<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>
|
||||
<span className="section-eyebrow">ЛОКАЛЬНЫЙ МОСТ K1 → FOXGLOVE</span>
|
||||
<h1>K1 выключен? Начните с первого шага.</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>
|
||||
<span>{state?.message || "Ожидаем состояние локального сервера."}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="metrics-grid" aria-label="Live stream metrics">
|
||||
<section className="quick-path" aria-label="Путь от выключенного K1 до облака точек">
|
||||
<div>
|
||||
<span>01</span>
|
||||
<strong>Включить K1</strong>
|
||||
<small>Дождаться ровного зелёного индикатора</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>02</span>
|
||||
<strong>Найти по BLE</strong>
|
||||
<small>Нажать «Найти K1» и выбрать сканер</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>03</span>
|
||||
<strong>Передать Wi‑Fi</strong>
|
||||
<small>Ввести сеть MacBook и подключить K1</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>04</span>
|
||||
<strong>Запустить приём</strong>
|
||||
<small>Поднять MQTT → Foxglove поток</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>05</span>
|
||||
<strong>Начать сканирование</strong>
|
||||
<small>Дважды нажать физическую кнопку K1</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>06</span>
|
||||
<strong>Открыть 3D</strong>
|
||||
<small>Увидеть облако точек и траекторию</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
|
||||
<MetricCard
|
||||
featured
|
||||
eyebrow="PIPELINE LATENCY"
|
||||
eyebrow="ЗАДЕРЖКА ПОТОКА"
|
||||
value={formatNumber(latency)}
|
||||
unit="ms"
|
||||
detail="MQTT receive → Foxglove publish"
|
||||
unit="мс"
|
||||
detail="Приём MQTT → публикация в Foxglove"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="FRAME RATE"
|
||||
eyebrow="ЧАСТОТА КАДРОВ"
|
||||
value={formatNumber(frameRate)}
|
||||
unit="fps"
|
||||
detail="Latest backend measurement"
|
||||
unit="кадр/с"
|
||||
detail="Последнее измерение сервера"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="POINTS / FRAME"
|
||||
value={points === null ? "—" : points.toLocaleString("en-US", { maximumFractionDigits: 0 })}
|
||||
detail="Published point count"
|
||||
eyebrow="ТОЧЕК В КАДРЕ"
|
||||
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||
detail="Количество опубликованных точек"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="DROPPED PREVIEW"
|
||||
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("en-US", { maximumFractionDigits: 0 })}
|
||||
detail="Frames omitted before display"
|
||||
eyebrow="ПРОПУЩЕНО В ПРОСМОТРЕ"
|
||||
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||
detail="Кадры пропущены только в визуализации"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -442,8 +485,8 @@ export default function App() {
|
||||
<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>
|
||||
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span>
|
||||
<h2>Подключите K1 к сети</h2>
|
||||
</div>
|
||||
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
|
||||
</header>
|
||||
@@ -451,26 +494,38 @@ export default function App() {
|
||||
<div className="wizard-list">
|
||||
<WizardStep
|
||||
number="01"
|
||||
title="Scanner power"
|
||||
status={powerConfirmed ? "Confirmed" : "Manual check"}
|
||||
title="Включите сканер"
|
||||
status={powerConfirmed ? "Подтверждено" : "Ожидает"}
|
||||
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."
|
||||
label="K1 включён, индикатор горит ровно зелёным"
|
||||
description="Если K1 выключен: нажмите кнопку питания и дождитесь ровного зелёного света. Затем поставьте галочку."
|
||||
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"}
|
||||
title="Найдите K1 по Bluetooth (BLE)"
|
||||
status={
|
||||
pendingAction === "scan"
|
||||
? "Поиск…"
|
||||
: selectedDeviceId
|
||||
? "K1 выбран"
|
||||
: `Найдено: ${devices.length}`
|
||||
}
|
||||
tone={
|
||||
pendingAction === "scan"
|
||||
? "accent"
|
||||
: selectedDeviceId
|
||||
? "success"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
<p className="step-copy">
|
||||
Scanning is active discovery. It does not send provisioning writes.
|
||||
Поиск занимает 6 секунд и ничего не записывает в настройки сканера.
|
||||
</p>
|
||||
<Button
|
||||
width="full"
|
||||
@@ -479,7 +534,7 @@ export default function App() {
|
||||
disabled={!powerConfirmed || isBusy}
|
||||
onClick={() => void scan()}
|
||||
>
|
||||
{pendingAction === "scan" ? "Scanning for 6 seconds…" : "Scan for K1 devices"}
|
||||
{pendingAction === "scan" ? "Ищем K1 — 6 секунд…" : "Найти K1"}
|
||||
</Button>
|
||||
<div className="device-list">
|
||||
{devices.length ? (
|
||||
@@ -493,7 +548,8 @@ export default function App() {
|
||||
))
|
||||
) : (
|
||||
<div className="empty-device-list">
|
||||
No devices reported. Confirm scanner power, then run the real BLE scan.
|
||||
K1 пока не найден. Убедитесь, что он включён и горит ровно зелёным, затем
|
||||
нажмите «Найти K1».
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -501,41 +557,33 @@ export default function App() {
|
||||
|
||||
<WizardStep
|
||||
number="03"
|
||||
title="Local Wi-Fi"
|
||||
status={credentialsReady ? "Ready" : "Required"}
|
||||
tone={credentialsReady ? "success" : "neutral"}
|
||||
title="Передайте K1 настройки Wi‑Fi"
|
||||
status={state?.k1_ip ? "Подключён" : "Не подключён"}
|
||||
tone={state?.k1_ip ? "success" : "neutral"}
|
||||
>
|
||||
<div className="field-stack">
|
||||
<TextField
|
||||
label="Network name"
|
||||
label="Название сети Wi‑Fi"
|
||||
hint="SSID"
|
||||
value={ssid}
|
||||
onChange={(event) => setSsid(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="Workshop Wi-Fi"
|
||||
placeholder="Сеть, к которой подключён MacBook"
|
||||
/>
|
||||
<TextField
|
||||
label="Network password"
|
||||
hint="Kept in memory only"
|
||||
label="Пароль Wi‑Fi"
|
||||
hint="Только в оперативной памяти"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder="Enter password"
|
||||
placeholder="Введите пароль"
|
||||
/>
|
||||
</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>
|
||||
<span>Сканер</span>
|
||||
<strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите K1"}</strong>
|
||||
</div>
|
||||
<Button
|
||||
width="full"
|
||||
@@ -544,11 +592,11 @@ export default function App() {
|
||||
disabled={!canConnect}
|
||||
onClick={() => void submitConnect()}
|
||||
>
|
||||
{pendingAction === "connect" ? "Provisioning…" : "Provision Wi-Fi & connect"}
|
||||
{pendingAction === "connect" ? "Подключаем K1…" : "Подключить K1 к Wi‑Fi"}
|
||||
</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.
|
||||
Пароль передаётся только локальному серверу на этом MacBook, не записывается в
|
||||
репозиторий и удаляется из формы после успешного подключения.
|
||||
</p>
|
||||
</WizardStep>
|
||||
</div>
|
||||
@@ -561,25 +609,25 @@ export default function App() {
|
||||
<GlassSurface className="status-panel" padding="lg">
|
||||
<header className="panel-heading panel-heading--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">LIVE STATUS</span>
|
||||
<h2>Transport</h2>
|
||||
<span className="section-eyebrow">ТЕКУЩЕЕ СОСТОЯНИЕ</span>
|
||||
<h2>Подключение</h2>
|
||||
</div>
|
||||
<StatusBadge tone={backendTone(backendStatus)}>
|
||||
{backendLabel(backendStatus)}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<dl className="detail-list">
|
||||
<DetailRow label="Events socket">
|
||||
<DetailRow label="Канал событий">
|
||||
<span className="inline-state" data-state={eventStatus}>
|
||||
{eventStatus}
|
||||
{eventStatusLabel(eventStatus)}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Source">{sourceLabel}</DetailRow>
|
||||
<DetailRow label="K1 address">
|
||||
<code>{state?.k1_ip || "Not reported"}</code>
|
||||
<DetailRow label="Источник">{sourceLabel}</DetailRow>
|
||||
<DetailRow label="Адрес K1">
|
||||
<code>{state?.k1_ip || "Не получен"}</code>
|
||||
</DetailRow>
|
||||
<DetailRow label="Foxglove bridge">
|
||||
<code>{state?.foxglove_ws_url || "Not reported"}</code>
|
||||
<DetailRow label="Мост Foxglove">
|
||||
<code>{state?.foxglove_ws_url || "Не запущен"}</code>
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</GlassSurface>
|
||||
@@ -587,17 +635,17 @@ export default function App() {
|
||||
<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>
|
||||
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
|
||||
<h2>Задержка потока</h2>
|
||||
</div>
|
||||
<strong className="latency-now">
|
||||
{formatNumber(latency)} <span>ms</span>
|
||||
{formatNumber(latency)} <span>мс</span>
|
||||
</strong>
|
||||
</header>
|
||||
<LatencyTrace values={latencyHistory} />
|
||||
<div className="latency-legend">
|
||||
<span>Oldest</span>
|
||||
<span>Latest</span>
|
||||
<span>Старые</span>
|
||||
<span>Последние</span>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
@@ -605,8 +653,16 @@ export default function App() {
|
||||
<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>
|
||||
<span className="section-eyebrow">
|
||||
{sessionIntent === "live"
|
||||
? "ШАГИ 04–05 · ЗАПУСК ДАННЫХ"
|
||||
: "СЛУЖЕБНЫЙ РЕЖИМ · ПОВТОР ЗАПИСИ"}
|
||||
</span>
|
||||
<h2>
|
||||
{sessionIntent === "live"
|
||||
? "Запустите поток K1"
|
||||
: "Повторите сохранённую запись"}
|
||||
</h2>
|
||||
</div>
|
||||
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
|
||||
{sourceLabel}
|
||||
@@ -614,7 +670,7 @@ export default function App() {
|
||||
</header>
|
||||
|
||||
<SegmentedControl
|
||||
label="Data source"
|
||||
label="Источник данных"
|
||||
value={sessionIntent}
|
||||
items={sessionItems}
|
||||
onChange={setSessionIntent}
|
||||
@@ -623,35 +679,40 @@ export default function App() {
|
||||
{sessionIntent === "live" ? (
|
||||
<div className="session-form">
|
||||
<TextField
|
||||
label="K1 host override"
|
||||
hint="Optional"
|
||||
label="Адрес K1"
|
||||
hint="Обычно заполняется автоматически"
|
||||
value={liveHost}
|
||||
onChange={(event) => setLiveHost(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={state?.k1_ip || "Use connected K1 address"}
|
||||
placeholder={state?.k1_ip || "Сначала подключите K1 к Wi‑Fi"}
|
||||
/>
|
||||
<Button
|
||||
variant="accent"
|
||||
icon={<Icon name="activity" />}
|
||||
disabled={isBusy}
|
||||
disabled={isBusy || !liveTargetReady}
|
||||
onClick={submitLive}
|
||||
>
|
||||
{pendingAction === "live" ? "Starting live…" : "Start live stream"}
|
||||
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
|
||||
</Button>
|
||||
<p className="live-instruction">
|
||||
{liveTargetReady
|
||||
? "Шаг 05: после запуска дважды нажмите физическую кнопку K1. Когда лидар зашумит и зелёный индикатор начнёт мигать, облако точек пойдёт в Foxglove."
|
||||
: "Сначала выполните шаг 03: подключите K1 к Wi-Fi слева или укажите его локальный адрес."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="session-form session-form--replay">
|
||||
<TextField
|
||||
label="Capture path"
|
||||
hint="Backend-local path"
|
||||
label="Путь к записи"
|
||||
hint="Файл внутри репозитория"
|
||||
value={replayPath}
|
||||
onChange={(event) => setReplayPath(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="sessions/.../mqtt.raw.k1mqtt or capture.tsv"
|
||||
placeholder="sessions/.../mqtt.raw.k1mqtt или capture.tsv"
|
||||
/>
|
||||
<TextField
|
||||
label="Playback speed"
|
||||
hint="Multiplier"
|
||||
label="Скорость повтора"
|
||||
hint="Множитель"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
@@ -660,8 +721,8 @@ export default function App() {
|
||||
/>
|
||||
<Checker
|
||||
checked={replayLoop}
|
||||
label="Loop replay"
|
||||
description="Restart the same capture after its last frame."
|
||||
label="Повторять по кругу"
|
||||
description="После последнего кадра начать запись заново."
|
||||
onChange={setReplayLoop}
|
||||
/>
|
||||
<Button
|
||||
@@ -670,22 +731,22 @@ export default function App() {
|
||||
disabled={isBusy || replayPath.trim().length === 0}
|
||||
onClick={submitReplay}
|
||||
>
|
||||
{pendingAction === "replay" ? "Starting replay…" : "Start replay"}
|
||||
{pendingAction === "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.
|
||||
Статус меняется только после ответа локального сервера. Для реального потока
|
||||
сначала подключите K1 к Wi‑Fi слева.
|
||||
</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
|
||||
onClick={() => void stop()}
|
||||
>
|
||||
{pendingAction === "stop" ? "Stopping…" : "Stop session"}
|
||||
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
|
||||
</Button>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
@@ -79,7 +79,7 @@ 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.");
|
||||
throw new ApiError("Локальный сервер вернул некорректное состояние.");
|
||||
}
|
||||
|
||||
return value as ConsoleState;
|
||||
@@ -97,12 +97,8 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ApiError(
|
||||
error instanceof Error
|
||||
? `Cannot reach the local K1 API: ${error.message}`
|
||||
: "Cannot reach the local K1 API.",
|
||||
);
|
||||
} catch {
|
||||
throw new ApiError("Не удалось подключиться к локальному API K1.");
|
||||
}
|
||||
|
||||
const bodyText = await response.text();
|
||||
@@ -122,9 +118,9 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||
? body.detail
|
||||
: typeof body === "string" && body.trim()
|
||||
? body.trim()
|
||||
: response.statusText;
|
||||
: `Запрос к API K1 завершился ошибкой HTTP ${response.status}.`;
|
||||
throw new ApiError(
|
||||
detail || `K1 API request failed with HTTP ${response.status}.`,
|
||||
detail || `Запрос к API K1 завершился ошибкой HTTP ${response.status}.`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
@@ -149,7 +145,7 @@ 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.");
|
||||
throw new ApiError("Локальный сервер вернул некорректный ответ проверки.");
|
||||
}
|
||||
return payload as HealthResponse;
|
||||
},
|
||||
@@ -206,5 +202,5 @@ export function openEventSocket(
|
||||
socket.addEventListener("error", () => onStatus("error"));
|
||||
socket.addEventListener("close", () => onStatus("closed"));
|
||||
|
||||
return () => socket.close(1000, "K1 console unmounted");
|
||||
return () => socket.close(1000, "Консоль K1 закрыта");
|
||||
}
|
||||
|
||||
@@ -211,6 +211,43 @@ input {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.quick-path {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.quick-path > div {
|
||||
display: grid;
|
||||
min-height: 6.8rem;
|
||||
align-content: start;
|
||||
gap: 0.42rem;
|
||||
border: 1px solid var(--k1-hairline);
|
||||
border-radius: 1rem;
|
||||
background: var(--k1-panel-soft);
|
||||
padding: 0.85rem;
|
||||
}
|
||||
|
||||
.quick-path span {
|
||||
color: rgb(var(--nodedc-accent-rgb));
|
||||
font-size: 0.58rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
|
||||
.quick-path strong {
|
||||
color: var(--nodedc-text-primary);
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.quick-path small {
|
||||
color: var(--nodedc-text-muted);
|
||||
font-size: 0.63rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
@@ -483,6 +520,7 @@ input {
|
||||
|
||||
.foxglove-panel {
|
||||
position: relative;
|
||||
order: 2;
|
||||
display: grid;
|
||||
min-height: 20rem;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
@@ -565,6 +603,7 @@ input {
|
||||
|
||||
.monitor-grid {
|
||||
display: grid;
|
||||
order: 3;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
@@ -575,6 +614,10 @@ input {
|
||||
background: var(--k1-panel);
|
||||
}
|
||||
|
||||
.session-panel {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.detail-list {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
@@ -700,6 +743,16 @@ input {
|
||||
grid-template-columns: minmax(14rem, 1fr) minmax(8rem, 0.3fr) minmax(12rem, 0.5fr) auto;
|
||||
}
|
||||
|
||||
.live-instruction {
|
||||
grid-column: 1 / -1;
|
||||
margin: 0;
|
||||
border-left: 2px solid rgb(var(--nodedc-accent-rgb));
|
||||
color: var(--nodedc-text-secondary);
|
||||
padding: 0.15rem 0 0.15rem 0.75rem;
|
||||
font-size: 0.7rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.session-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -719,6 +772,10 @@ input {
|
||||
}
|
||||
|
||||
@media (max-width: 1320px) {
|
||||
.quick-path {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
@@ -746,14 +803,6 @@ input {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.connection-panel {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.monitor-column {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.foxglove-panel {
|
||||
min-height: 18rem;
|
||||
}
|
||||
@@ -801,6 +850,12 @@ input {
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.quick-path {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
margin-top: 0.7rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
min-height: 8.2rem;
|
||||
}
|
||||
@@ -838,6 +893,10 @@ input {
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.quick-path {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.metrics-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ function messageFor(error: unknown): string {
|
||||
? `${error.message} (HTTP ${error.status})`
|
||||
: error.message;
|
||||
}
|
||||
return error instanceof Error ? error.message : "The local K1 API request failed.";
|
||||
return "Запрос к локальному API K1 завершился ошибкой.";
|
||||
}
|
||||
|
||||
function measuredLatency(state: ConsoleState | null): number | null {
|
||||
|
||||
Reference in New Issue
Block a user