feat: localize K1 console to Russian

This commit is contained in:
DCCONSTRUCTIONS
2026-07-15 22:08:18 +03:00
parent 6be96f0b85
commit ea834e09e8
9 changed files with 431 additions and 194 deletions
+3 -3
View File
@@ -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
View File
@@ -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: "Передача настроек WiFi",
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>Передать WiFi</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">ПОДКЛЮЧЕНИЕ · ШАГИ 0103</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 настройки WiFi"
status={state?.k1_ip ? "Подключён" : "Не подключён"}
tone={state?.k1_ip ? "success" : "neutral"}
>
<div className="field-stack">
<TextField
label="Network name"
label="Название сети WiFi"
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="Пароль WiFi"
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 к WiFi"}
</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 к WiFi слева.
</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>
+7 -11
View File
@@ -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 закрыта");
}
+67 -8
View File
@@ -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;
}
+1 -1
View File
@@ -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 {