chore: rename repository to NODEDC MISSION CORE
This commit is contained in:
@@ -0,0 +1,556 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import {
|
||||
Button,
|
||||
Checker,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
SegmentedControl,
|
||||
StatusBadge,
|
||||
TextField,
|
||||
type StatusTone,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { BleDevice } from "../api";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { localizeRuntimeMessage } from "../messages";
|
||||
import {
|
||||
backendLabel,
|
||||
backendTone,
|
||||
eventStatusLabel,
|
||||
finiteMetric,
|
||||
formatNumber,
|
||||
phaseLabel,
|
||||
phaseTone,
|
||||
pipelineLatency,
|
||||
sourceModeLabel,
|
||||
} from "../presentation";
|
||||
import type { useK1Console } from "../useK1Console";
|
||||
|
||||
type ConsoleController = ReturnType<typeof useK1Console>;
|
||||
type SessionIntent = "live" | "replay";
|
||||
|
||||
const sessionItems = [
|
||||
{ value: "live", label: "Реальное устройство" },
|
||||
{ value: "replay", label: "Повтор записи" },
|
||||
] satisfies Array<{ value: SessionIntent; label: string }>;
|
||||
|
||||
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-compatible={device.likely_k1 ? "true" : undefined}
|
||||
data-selected={selected ? "true" : undefined}
|
||||
>
|
||||
<div className="device-row__identity">
|
||||
<span className="device-row__signal" aria-hidden="true" />
|
||||
<div>
|
||||
<span className="device-row__name">
|
||||
<strong>{device.name?.trim() || "Устройство без имени"}</strong>
|
||||
{device.likely_k1 ? <small>Совместимый профиль</small> : null}
|
||||
</span>
|
||||
<code>{device.device_id}</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="device-row__action">
|
||||
<span>{finiteMetric(device.rssi) === null ? "RSSI —" : `${device.rssi} дБм`}</span>
|
||||
<Button
|
||||
size="compact"
|
||||
variant={selected ? "primary" : "secondary"}
|
||||
disabled={device.connectable === false}
|
||||
onClick={onSelect}
|
||||
>
|
||||
{selected ? "Выбрано" : "Выбрать"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LatencyTrace({ values }: { values: number[] }) {
|
||||
const ceiling = Math.max(16, ...values);
|
||||
|
||||
return (
|
||||
<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)} мс`}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<p>Измерений пока нет. График появится после получения реальных данных.</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DeviceWorkspace({
|
||||
console,
|
||||
onOpenSpatialScene,
|
||||
}: {
|
||||
console: ConsoleController;
|
||||
onOpenSpatialScene: () => void;
|
||||
}) {
|
||||
const {
|
||||
state,
|
||||
backendStatus,
|
||||
eventStatus,
|
||||
pendingAction,
|
||||
error,
|
||||
latencyHistory,
|
||||
refresh,
|
||||
clearError,
|
||||
scan,
|
||||
connect,
|
||||
startLive,
|
||||
startReplay,
|
||||
stop,
|
||||
} = console;
|
||||
|
||||
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);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedDeviceId &&
|
||||
state?.devices &&
|
||||
!state.devices.some((device) => device.device_id === selectedDeviceId)
|
||||
) {
|
||||
setSelectedDeviceId("");
|
||||
}
|
||||
}, [selectedDeviceId, state?.devices, state?.selected_device_id]);
|
||||
|
||||
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
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 liveTargetReady = Boolean(state?.k1_ip || liveHost.trim());
|
||||
const sourceLabel = sourceModeLabel(state?.source_mode);
|
||||
|
||||
const deviceSummary = useMemo(
|
||||
() => devices.find((device) => device.device_id === selectedDeviceId),
|
||||
[devices, selectedDeviceId],
|
||||
);
|
||||
|
||||
const submitConnect = async () => {
|
||||
if (!canConnect) return;
|
||||
const succeeded = await connect({
|
||||
device_id: selectedDeviceId,
|
||||
ssid: ssid.trim(),
|
||||
password,
|
||||
});
|
||||
if (succeeded) setPassword("");
|
||||
};
|
||||
|
||||
const submitLive = async () => {
|
||||
const host = liveHost.trim();
|
||||
const started = await startLive(host ? { host } : {});
|
||||
if (started) onOpenSpatialScene();
|
||||
};
|
||||
|
||||
const submitReplay = async () => {
|
||||
const speed = Number(replaySpeed);
|
||||
const started = await startReplay({
|
||||
path: replayPath.trim(),
|
||||
speed: Number.isFinite(speed) && speed > 0 ? speed : 1,
|
||||
loop: replayLoop,
|
||||
});
|
||||
if (started) onOpenSpatialScene();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="device-workspace">
|
||||
{error ? (
|
||||
<aside className="error-banner" role="alert">
|
||||
<span className="error-banner__dot" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>Локальная операция завершилась ошибкой</strong>
|
||||
<p>{localizeRuntimeMessage(error)}</p>
|
||||
</div>
|
||||
<div className="error-banner__actions">
|
||||
<Button size="compact" variant="secondary" onClick={() => void refresh()}>
|
||||
Повторить
|
||||
</Button>
|
||||
<Button size="compact" variant="ghost" onClick={clearError}>
|
||||
Закрыть
|
||||
</Button>
|
||||
</div>
|
||||
</aside>
|
||||
) : null}
|
||||
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">РАБОЧИЙ АДАПТЕР УСТРОЙСТВА</span>
|
||||
<h2>Подключение первого устройства</h2>
|
||||
<p>
|
||||
Этот путь уже работает физически, но остаётся изолированным адаптером. Парковая и
|
||||
операторская модель от конкретного устройства не зависят.
|
||||
</p>
|
||||
</div>
|
||||
<div className="workspace-lead__status">
|
||||
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
|
||||
<span>{localizeRuntimeMessage(state?.message) || "Ожидаем состояние локального контура."}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="metrics-grid" aria-label="Метрики потока в реальном времени">
|
||||
<MetricCard
|
||||
featured
|
||||
eyebrow="ДО ПУБЛИКАЦИИ"
|
||||
value={formatNumber(latency)}
|
||||
unit="мс"
|
||||
detail="MQTT callback → Rerun SDK; без экрана"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ЧАСТОТА КАДРОВ"
|
||||
value={formatNumber(frameRate)}
|
||||
unit="кадр/с"
|
||||
detail="Последнее измерение адаптера"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ТОЧЕК В КАДРЕ"
|
||||
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||
detail="Реальное число декодированных точек"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ПРОПУЩЕНО ПРЕДПРОСМОТРОВ"
|
||||
value={droppedFrames === null ? "—" : droppedFrames.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||
detail="Исходные данные при этом сохраняются"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="device-workspace__grid">
|
||||
<GlassSurface className="connection-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОДКЛЮЧЕНИЕ · ШАГИ 01–03</span>
|
||||
<h2>Подключите устройство к сети</h2>
|
||||
</div>
|
||||
<StatusBadge tone={phaseTone(state?.phase)}>{phaseLabel(state?.phase)}</StatusBadge>
|
||||
</header>
|
||||
|
||||
<div className="wizard-list">
|
||||
<WizardStep
|
||||
number="01"
|
||||
title="Включите устройство"
|
||||
status={powerConfirmed ? "Подтверждено" : "Ожидает"}
|
||||
tone={powerConfirmed ? "success" : "warning"}
|
||||
>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">
|
||||
Для текущего адаптера дождитесь ровного зелёного индикатора. Это подтверждение оператора, а не аппаратная телеметрия.
|
||||
</span>
|
||||
<Checker
|
||||
checked={powerConfirmed}
|
||||
label="Устройство включено, индикатор стабилен"
|
||||
onChange={setPowerConfirmed}
|
||||
/>
|
||||
</div>
|
||||
</WizardStep>
|
||||
|
||||
<WizardStep
|
||||
number="02"
|
||||
title="Выберите Bluetooth-устройство"
|
||||
status={
|
||||
pendingAction === "scan"
|
||||
? "Поиск…"
|
||||
: selectedDeviceId
|
||||
? "Устройство выбрано"
|
||||
: `Найдено: ${devices.length}`
|
||||
}
|
||||
tone={
|
||||
pendingAction === "scan"
|
||||
? "accent"
|
||||
: selectedDeviceId
|
||||
? "success"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
<p className="step-copy">
|
||||
Поиск занимает 6 секунд и показывает все видимые BLE-устройства. Совместимый
|
||||
профиль — только подсказка; окончательный выбор всегда делает оператор.
|
||||
</p>
|
||||
<Button
|
||||
width="full"
|
||||
variant="secondary"
|
||||
icon={<Icon name="search" />}
|
||||
disabled={!powerConfirmed || isBusy}
|
||||
onClick={() => {
|
||||
setSelectedDeviceId("");
|
||||
void scan();
|
||||
}}
|
||||
>
|
||||
{pendingAction === "scan"
|
||||
? "Сканируем Bluetooth — 6 секунд…"
|
||||
: "Показать все BLE-устройства"}
|
||||
</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">
|
||||
Устройства пока не найдены. Проверьте питание и состояние индикатора, затем
|
||||
повторите поиск.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</WizardStep>
|
||||
|
||||
<WizardStep
|
||||
number="03"
|
||||
title="Передайте настройки Wi‑Fi"
|
||||
status={state?.k1_ip ? "Подключено" : "Не подключено"}
|
||||
tone={state?.k1_ip ? "success" : "neutral"}
|
||||
>
|
||||
<div className="field-stack">
|
||||
<TextField
|
||||
label="Название сети Wi‑Fi"
|
||||
hint="SSID"
|
||||
value={ssid}
|
||||
onChange={(event) => setSsid(event.target.value)}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
placeholder="Сеть локального контура"
|
||||
/>
|
||||
<TextField
|
||||
label="Пароль Wi‑Fi"
|
||||
hint="Только в оперативной памяти"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
autoComplete="off"
|
||||
placeholder="Введите пароль"
|
||||
/>
|
||||
</div>
|
||||
<div className="connection-summary">
|
||||
<span>Устройство</span>
|
||||
<strong>{deviceSummary?.name || selectedDeviceId || "Сначала выберите устройство"}</strong>
|
||||
</div>
|
||||
<Button
|
||||
width="full"
|
||||
variant="primary"
|
||||
icon={<Icon name="network" />}
|
||||
disabled={!canConnect}
|
||||
onClick={() => void submitConnect()}
|
||||
>
|
||||
{pendingAction === "connect" ? "Подключаем…" : "Подключить устройство к Wi‑Fi"}
|
||||
</Button>
|
||||
<p className="safety-note">
|
||||
Пароль передаётся только локальному сервису на этом компьютере, не сохраняется в браузере и
|
||||
удаляется из формы после успешного подключения.
|
||||
</p>
|
||||
</WizardStep>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<div className="device-workspace__side">
|
||||
<GlassSurface className="session-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">
|
||||
{sessionIntent === "live" ? "ШАГИ 04–05 · ПРИЁМ" : "СЛУЖЕБНЫЙ РЕЖИМ"}
|
||||
</span>
|
||||
<h2>{sessionIntent === "live" ? "Запустите поток" : "Повторите запись"}</h2>
|
||||
</div>
|
||||
<StatusBadge tone={state?.source_mode && state.source_mode !== "idle" ? "success" : "neutral"}>
|
||||
{sourceLabel}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
|
||||
<SegmentedControl
|
||||
label="Источник данных"
|
||||
value={sessionIntent}
|
||||
items={sessionItems}
|
||||
onChange={setSessionIntent}
|
||||
/>
|
||||
|
||||
{sessionIntent === "live" ? (
|
||||
<div className="session-form">
|
||||
<TextField
|
||||
label="Адрес устройства"
|
||||
hint="Обычно определяется автоматически"
|
||||
value={liveHost}
|
||||
onChange={(event) => setLiveHost(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder={state?.k1_ip || "Сначала подключите устройство к Wi‑Fi"}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="activity" />}
|
||||
disabled={isBusy || !liveTargetReady}
|
||||
onClick={() => void submitLive()}
|
||||
>
|
||||
{pendingAction === "live" ? "Запускаем приём…" : "Запустить приём данных"}
|
||||
</Button>
|
||||
<p className="live-instruction">
|
||||
{liveTargetReady
|
||||
? "После запуска включите физическое сканирование двойным нажатием кнопки текущего устройства. Поток считается активным только после появления реальных кадров."
|
||||
: "Сначала подключите устройство к Wi‑Fi или укажите его локальный адрес."}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="session-form session-form--replay">
|
||||
<TextField
|
||||
label="Путь к записи"
|
||||
hint="Локальный файл исходных данных"
|
||||
value={replayPath}
|
||||
onChange={(event) => setReplayPath(event.target.value)}
|
||||
spellCheck={false}
|
||||
placeholder="sessions/.../capture.tsv"
|
||||
/>
|
||||
<TextField
|
||||
label="Скорость повтора"
|
||||
hint="Множитель"
|
||||
type="number"
|
||||
min="0.1"
|
||||
step="0.1"
|
||||
value={replaySpeed}
|
||||
onChange={(event) => setReplaySpeed(event.target.value)}
|
||||
/>
|
||||
<div className="nodedc-field">
|
||||
<span className="nodedc-field__description">После последнего кадра начать запись заново.</span>
|
||||
<Checker
|
||||
checked={replayLoop}
|
||||
label="Повторять по кругу"
|
||||
onChange={setReplayLoop}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Icon name="video" />}
|
||||
disabled={isBusy || replayPath.trim().length === 0}
|
||||
onClick={() => void submitReplay()}
|
||||
>
|
||||
{pendingAction === "replay" ? "Запускаем повтор…" : "Запустить повтор записи"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="session-footer">
|
||||
<p>Статус изменится только после ответа локального сервиса.</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={isBusy || !state?.source_mode || state.source_mode === "idle"}
|
||||
onClick={() => void stop()}
|
||||
>
|
||||
{pendingAction === "stop" ? "Останавливаем…" : "Остановить поток"}
|
||||
</Button>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<div className="diagnostics-grid">
|
||||
<GlassSurface className="status-panel" padding="lg">
|
||||
<header className="panel-heading panel-heading--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">ТЕКУЩЕЕ СОСТОЯНИЕ</span>
|
||||
<h2>Локальный контур</h2>
|
||||
</div>
|
||||
<StatusBadge tone={backendTone(backendStatus)}>{backendLabel(backendStatus)}</StatusBadge>
|
||||
</header>
|
||||
<dl className="detail-list">
|
||||
<DetailRow label="Канал событий">
|
||||
<span className="inline-state" data-state={eventStatus}>
|
||||
{eventStatusLabel(eventStatus)}
|
||||
</span>
|
||||
</DetailRow>
|
||||
<DetailRow label="Источник">{sourceLabel}</DetailRow>
|
||||
<DetailRow label="Адрес устройства">
|
||||
<code>{state?.k1_ip || "Не получен"}</code>
|
||||
</DetailRow>
|
||||
</dl>
|
||||
</GlassSurface>
|
||||
|
||||
<GlassSurface className="latency-panel" padding="lg">
|
||||
<header className="panel-heading panel-heading--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОСЛЕДНИЕ ИЗМЕРЕНИЯ</span>
|
||||
<h2>Время до публикации</h2>
|
||||
</div>
|
||||
<strong className="latency-now">
|
||||
{formatNumber(latency)} <span>мс</span>
|
||||
</strong>
|
||||
</header>
|
||||
<LatencyTrace values={latencyHistory} />
|
||||
<div className="latency-legend">
|
||||
<span>Старые</span>
|
||||
<span>Последние</span>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { ConsoleState } from "../api";
|
||||
import { MetricCard } from "../components/MetricCard";
|
||||
import { localizeRuntimeMessage } from "../messages";
|
||||
import {
|
||||
RerunViewport,
|
||||
type RerunSelection,
|
||||
type RerunViewportStatus,
|
||||
} from "../components/RerunViewport";
|
||||
import {
|
||||
capabilityStatusLabel,
|
||||
type CapabilityStatus,
|
||||
type WorkspaceDefinition,
|
||||
} from "../productModel";
|
||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import type { BackendStatus } from "../useK1Console";
|
||||
|
||||
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
||||
if (status === "active") return "success";
|
||||
if (status === "ready") return "accent";
|
||||
if (status === "contract") return "warning";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function FeatureInventory({ definition }: { definition: WorkspaceDefinition }) {
|
||||
return (
|
||||
<div className="feature-inventory">
|
||||
{definition.groups.map((group) => (
|
||||
<section className="feature-group" key={group.title}>
|
||||
<header>
|
||||
<span className="section-eyebrow">ФУНКЦИОНАЛЬНЫЙ БЛОК</span>
|
||||
<h3>{group.title}</h3>
|
||||
<p>{group.description}</p>
|
||||
</header>
|
||||
<div className="feature-list">
|
||||
{group.capabilities.map((capability) => (
|
||||
<div className="feature-row" key={capability.label} data-status={capability.status}>
|
||||
<span className="feature-row__mark" aria-hidden="true" />
|
||||
<div>
|
||||
<strong>{capability.label}</strong>
|
||||
<p>{capability.description}</p>
|
||||
</div>
|
||||
<StatusBadge tone={statusTone(capability.status)}>
|
||||
{capabilityStatusLabel[capability.status]}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WorkspaceLead({ definition, note }: { definition: WorkspaceDefinition; note?: string }) {
|
||||
return (
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">{definition.eyebrow}</span>
|
||||
<h2>{definition.title}</h2>
|
||||
<p>{definition.description}</p>
|
||||
</div>
|
||||
{note ? <span className="workspace-lead__note">{note}</span> : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export interface WorkspaceNavigation {
|
||||
openView: (viewId: string) => void;
|
||||
openSource: () => void;
|
||||
openDisplay: () => void;
|
||||
openLayers: () => void;
|
||||
}
|
||||
|
||||
export interface WorkspaceRendererProps {
|
||||
definition: WorkspaceDefinition;
|
||||
state: ConsoleState | null;
|
||||
backendStatus: BackendStatus;
|
||||
sourceUrl: string;
|
||||
sceneSettings: SceneSettings;
|
||||
navigation: WorkspaceNavigation;
|
||||
}
|
||||
|
||||
function OverviewWorkspace({
|
||||
definition,
|
||||
state,
|
||||
backendStatus,
|
||||
navigation,
|
||||
}: WorkspaceRendererProps) {
|
||||
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
const adapterOnline = backendStatus === "online";
|
||||
const rerunReady = Boolean(state?.rerun_grpc_url);
|
||||
|
||||
return (
|
||||
<div className="standard-workspace overview-workspace">
|
||||
<WorkspaceLead
|
||||
definition={definition}
|
||||
note="Числа появляются только из реального локального контура"
|
||||
/>
|
||||
<section className="metrics-grid" aria-label="Оперативные показатели">
|
||||
<MetricCard
|
||||
featured
|
||||
eyebrow="ДО ПУБЛИКАЦИИ"
|
||||
value={formatNumber(latency)}
|
||||
unit="мс"
|
||||
detail="MQTT callback → Rerun SDK; без экрана"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ЧАСТОТА"
|
||||
value={formatNumber(frameRate)}
|
||||
unit="кадр/с"
|
||||
detail="Последнее измерение потока"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="ТОЧЕК В КАДРЕ"
|
||||
value={points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}
|
||||
detail="Без синтетического заполнения"
|
||||
/>
|
||||
<MetricCard
|
||||
eyebrow="РЕЖИМ"
|
||||
value={sourceModeLabel(state?.source_mode)}
|
||||
detail="Реальное время, повтор или ожидание"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<div className="overview-grid">
|
||||
<GlassSurface className="contour-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">КРИТИЧЕСКИЙ ПУТЬ</span>
|
||||
<h2>Контур наблюдения</h2>
|
||||
</div>
|
||||
<StatusBadge tone={adapterOnline ? "success" : "danger"}>
|
||||
{adapterOnline ? "Контур доступен" : "Нет связи"}
|
||||
</StatusBadge>
|
||||
</header>
|
||||
<div className="pipeline-strip" aria-label="Путь данных">
|
||||
<div data-state={state?.k1_ip ? "ready" : "idle"}>
|
||||
<span>01</span>
|
||||
<strong>Устройство</strong>
|
||||
<small>{state?.k1_ip || "не назначено"}</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div data-state={streamActive ? "ready" : "idle"}>
|
||||
<span>02</span>
|
||||
<strong>Адаптер</strong>
|
||||
<small>{sourceModeLabel(state?.source_mode)}</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div data-state={rerunReady ? "ready" : "idle"}>
|
||||
<span>03</span>
|
||||
<strong>Rerun</strong>
|
||||
<small>{rerunReady ? "gRPC опубликован" : "ожидает поток"}</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
<div data-state="ready">
|
||||
<span>04</span>
|
||||
<strong>Оператор</strong>
|
||||
<small>интерфейс готов</small>
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<GlassSurface className="next-actions-panel" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">БЫСТРЫЙ ПЕРЕХОД</span>
|
||||
<h2>Продолжить работу</h2>
|
||||
</div>
|
||||
</header>
|
||||
<div className="action-list">
|
||||
<button type="button" onClick={() => navigation.openView("local-device")}>
|
||||
<span><Icon name="network" /></span>
|
||||
<div>
|
||||
<strong>Подключить устройство</strong>
|
||||
<small>Bluetooth, Wi‑Fi и запуск потока</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
<button type="button" onClick={() => navigation.openView("spatial-scene")}>
|
||||
<span><Icon name="globe" /></span>
|
||||
<div>
|
||||
<strong>Открыть пространственную сцену</strong>
|
||||
<small>Облако точек, траектория и слои</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
<button type="button" onClick={() => navigation.openView("streams")}>
|
||||
<span><Icon name="activity" /></span>
|
||||
<div>
|
||||
<strong>Проверить потоки</strong>
|
||||
<small>Транспорт, схемы и готовность</small>
|
||||
</div>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptySpatialStage({ settings }: { settings: SceneSettings }) {
|
||||
return (
|
||||
<div className="empty-spatial-stage" data-grid={settings.showGrid ? "true" : undefined}>
|
||||
<div className="empty-spatial-stage__grid" aria-hidden="true" />
|
||||
<div className="spatial-axis" aria-hidden="true">
|
||||
<span data-axis="x">X</span>
|
||||
<span data-axis="z">Z</span>
|
||||
</div>
|
||||
<div className="empty-spatial-stage__message">
|
||||
<span className="empty-spatial-stage__icon"><Icon name="globe" size={20} /></span>
|
||||
<strong>Визуальный источник не назначен</strong>
|
||||
<p>
|
||||
Область сцены не подставляет демонстрационные точки. Назначьте RRD или Rerun gRPC, чтобы
|
||||
открыть реальные данные.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SpatialWorkspace({
|
||||
state,
|
||||
sourceUrl,
|
||||
sceneSettings,
|
||||
navigation,
|
||||
}: WorkspaceRendererProps) {
|
||||
const [viewerStatus, setViewerStatus] = useState<RerunViewportStatus>(sourceUrl ? "loading" : "idle");
|
||||
const [viewerMessage, setViewerMessage] = useState("");
|
||||
const [selection, setSelection] = useState<RerunSelection | null>(null);
|
||||
const streamActive = state?.source_mode === "live" || state?.source_mode === "replay";
|
||||
const metrics = streamActive ? state?.metrics : undefined;
|
||||
const latency = pipelineLatency(metrics);
|
||||
const frameRate = finiteMetric(metrics?.frame_rate ?? metrics?.frame_rate_hz);
|
||||
const points = finiteMetric(metrics?.point_count);
|
||||
|
||||
const onStatusChange = useCallback((status: RerunViewportStatus, message?: string) => {
|
||||
setViewerStatus(status);
|
||||
setViewerMessage(message ?? "");
|
||||
}, []);
|
||||
const onSelectionChange = useCallback((next: RerunSelection | null) => setSelection(next), []);
|
||||
|
||||
const viewerStatusLabel = {
|
||||
idle: "Источник не назначен",
|
||||
loading: "Подключение",
|
||||
ready: "Визуализатор готов",
|
||||
error: "Ошибка источника",
|
||||
}[viewerStatus];
|
||||
|
||||
const viewerStatusTone = viewerStatus === "ready" ? "success" : viewerStatus === "error" ? "danger" : "neutral";
|
||||
|
||||
return (
|
||||
<div className="spatial-workspace">
|
||||
<div className="spatial-toolbar">
|
||||
<div className="spatial-toolbar__mode">
|
||||
<span className="section-eyebrow">СЦЕНА 3D · RERUN</span>
|
||||
</div>
|
||||
<div className="spatial-toolbar__actions">
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="network" />} onClick={navigation.openSource}>
|
||||
Источник
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="list" />} onClick={navigation.openLayers}>
|
||||
Слои
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" icon={<Icon name="sliders" />} onClick={navigation.openDisplay}>
|
||||
Отображение
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="spatial-viewport-shell">
|
||||
{sourceUrl.trim() ? (
|
||||
<RerunViewport
|
||||
sourceUrl={sourceUrl}
|
||||
onStatusChange={onStatusChange}
|
||||
onSelectionChange={onSelectionChange}
|
||||
/>
|
||||
) : (
|
||||
<EmptySpatialStage settings={sceneSettings} />
|
||||
)}
|
||||
|
||||
<div className="scene-status scene-status--top-left">
|
||||
<span className="section-eyebrow">ВИЗУАЛЬНЫЙ ДВИЖОК</span>
|
||||
<StatusBadge tone={viewerStatusTone}>{viewerStatusLabel}</StatusBadge>
|
||||
{viewerMessage ? <small>{viewerMessage}</small> : null}
|
||||
</div>
|
||||
|
||||
<div className="scene-metrics" aria-label="Метрики пространственной сцены">
|
||||
<div>
|
||||
<span>КАДР/С</span>
|
||||
<strong>{formatNumber(frameRate)}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>Точек</span>
|
||||
<strong>{points === null ? "—" : points.toLocaleString("ru-RU", { maximumFractionDigits: 0 })}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>До публикации</span>
|
||||
<strong>{formatNumber(latency)}<small> мс</small></strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state?.source_mode && state.source_mode !== "idle" && !sourceUrl.trim() ? (
|
||||
<div className="scene-adapter-note">
|
||||
<Icon name="alert" />
|
||||
<span>
|
||||
Локальный поток <strong>{sourceModeLabel(state.source_mode).toLocaleLowerCase("ru-RU")}</strong> активен,
|
||||
Rerun-мост запускается и опубликует адрес автоматически.
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selection ? (
|
||||
<div className="scene-selection">
|
||||
<span>Выбрано</span>
|
||||
<strong>{selection.entityPath}</strong>
|
||||
{selection.viewName ? <small>{selection.viewName}</small> : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="scene-timeline">
|
||||
<Button size="compact" variant="ghost" disabled={viewerStatus !== "ready"} aria-label="Перейти к началу">
|
||||
<Icon name="chevron-left" />
|
||||
</Button>
|
||||
<Button size="compact" variant="secondary" disabled={viewerStatus !== "ready"}>
|
||||
Воспроизвести
|
||||
</Button>
|
||||
<div className="scene-timeline__track" data-disabled={viewerStatus !== "ready" ? "true" : undefined}>
|
||||
<span style={{ width: viewerStatus === "ready" ? "8%" : "0%" }} />
|
||||
</div>
|
||||
<code>{viewerStatus === "ready" ? "00:00:00.000" : "—:—:—.———"}</code>
|
||||
<span className="scene-timeline__follow" data-active={viewerStatus === "ready" ? "true" : undefined}>
|
||||
ЭФИР
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="spatial-contract-strip">
|
||||
<span><i data-state="ready" />Облако точек</span>
|
||||
<span><i data-state="ready" />Траектория</span>
|
||||
<span><i data-state="ready" />Преобразования</span>
|
||||
<span><i data-state="contract" />Камеры в 3D</span>
|
||||
<span><i data-state="contract" />Объекты / маски</span>
|
||||
<span><i data-state="contract" />Компоновка</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CamerasWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const slots = ["Панорама", "Камера 01", "Камера 02", "Глубина"];
|
||||
return (
|
||||
<div className="standard-workspace cameras-workspace">
|
||||
<WorkspaceLead definition={definition} note="Кадры не подменяются демонстрационным видео" />
|
||||
<div className="camera-grid">
|
||||
{slots.map((slot, index) => (
|
||||
<div className="camera-slot" key={slot} data-primary={index === 0 ? "true" : undefined}>
|
||||
<div className="camera-slot__grid" aria-hidden="true" />
|
||||
<header>
|
||||
<span>{slot}</span>
|
||||
<StatusBadge tone="neutral">Канал не назначен</StatusBadge>
|
||||
</header>
|
||||
<div className="camera-slot__empty">
|
||||
<Icon name={index === 3 ? "image" : "video"} />
|
||||
<span>{index === 3 ? "Ожидается карта глубины" : "Ожидается поток изображения"}</span>
|
||||
</div>
|
||||
<footer>
|
||||
<span>{index === 0 ? "360° / эквидистантная" : index === 3 ? "глубина / метры" : "перспективная / калибровка"}</span>
|
||||
<button type="button" disabled aria-label={`Настроить ${slot}`}><Icon name="sliders" /></button>
|
||||
</footer>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MapWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const isMissionRoute = definition.id === "routes";
|
||||
return (
|
||||
<div className="standard-workspace map-workspace">
|
||||
<WorkspaceLead
|
||||
definition={definition}
|
||||
note={isMissionRoute ? "Схема интерфейса — координаты и командный канал не подключены" : "Картографический модуль не подключён"}
|
||||
/>
|
||||
<div className="map-layout">
|
||||
<div className="map-canvas" data-route={isMissionRoute ? "true" : undefined}>
|
||||
<div className="map-canvas__grid" aria-hidden="true" />
|
||||
<div className="map-canvas__contours" aria-hidden="true">
|
||||
<span /><span /><span /><span />
|
||||
</div>
|
||||
<svg viewBox="0 0 1000 560" role="img" aria-label="Схематический черновик маршрута без географических координат">
|
||||
<path d="M165 420 C 235 350, 272 375, 340 292 S 480 235, 545 290 S 680 340, 765 210 S 845 125, 892 155" />
|
||||
<circle cx="165" cy="420" r="8" />
|
||||
<circle cx="340" cy="292" r="8" />
|
||||
<circle cx="545" cy="290" r="8" />
|
||||
<circle cx="765" cy="210" r="8" />
|
||||
<circle cx="892" cy="155" r="8" />
|
||||
</svg>
|
||||
<div className="map-point-label" style={{ left: "15%", top: "72%" }}>A · старт</div>
|
||||
<div className="map-point-label" style={{ left: "52%", top: "49%" }}>C · наблюдение</div>
|
||||
<div className="map-point-label" style={{ left: "84%", top: "22%" }}>E · финиш</div>
|
||||
<div className="map-canvas__notice">
|
||||
<StatusBadge tone="warning">Схема интерфейса</StatusBadge>
|
||||
<span>Не является картой местности и не содержит реальных координат.</span>
|
||||
</div>
|
||||
</div>
|
||||
<GlassSurface className="map-inspector" padding="lg">
|
||||
<span className="section-eyebrow">СЛОИ КАРТЫ</span>
|
||||
<h3>Рабочая композиция</h3>
|
||||
<div className="layer-summary-list">
|
||||
<div><span data-tone="cyan" /><strong>Маршрут</strong><small>5 точек · схема</small></div>
|
||||
<div><span data-tone="violet" /><strong>Геозоны</strong><small>не назначены</small></div>
|
||||
<div><span data-tone="green" /><strong>Высота</strong><small>контракт GridMap</small></div>
|
||||
<div><span data-tone="orange" /><strong>Проходимость</strong><small>обработчик не подключён</small></div>
|
||||
</div>
|
||||
<Button variant="secondary" width="full" disabled>Редактировать точки</Button>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineWorkspace({ definition, state }: WorkspaceRendererProps) {
|
||||
const rows = useMemo(() => [
|
||||
{ label: "Устройство", value: state?.phase ? sourceModeLabel(state.source_mode) : "нет событий", active: Boolean(state?.phase) },
|
||||
{ label: "Поток точек", value: state?.source_mode && state.source_mode !== "idle" ? "активен" : "ожидание", active: state?.source_mode !== "idle" && Boolean(state?.source_mode) },
|
||||
{ label: "Поза", value: state?.metrics?.point_count ? "синхронно" : "нет данных", active: Boolean(state?.metrics?.point_count) },
|
||||
{ label: "Камеры", value: "каналы не назначены", active: false },
|
||||
{ label: "События", value: localizeRuntimeMessage(state?.message) || "нет событий", active: Boolean(state?.message) },
|
||||
], [state]);
|
||||
|
||||
return (
|
||||
<div className="standard-workspace timeline-workspace">
|
||||
<WorkspaceLead definition={definition} note="Отображаются только доступные состояния" />
|
||||
<GlassSurface className="timeline-panel" padding="lg">
|
||||
<header className="timeline-panel__head">
|
||||
<div>
|
||||
<span className="section-eyebrow">СИНХРОННЫЕ КАНАЛЫ</span>
|
||||
<h3>Текущий интервал</h3>
|
||||
</div>
|
||||
<div className="timeline-panel__controls">
|
||||
<Button size="compact" variant="secondary" disabled>Воспроизвести</Button>
|
||||
<code>—:—:—.———</code>
|
||||
</div>
|
||||
</header>
|
||||
<div className="timeline-ruler" aria-hidden="true">
|
||||
{Array.from({ length: 11 }, (_, index) => <span key={index}>{index * 10}</span>)}
|
||||
</div>
|
||||
<div className="timeline-lanes">
|
||||
{rows.map((row, index) => (
|
||||
<div className="timeline-lane" key={row.label} data-active={row.active ? "true" : undefined}>
|
||||
<strong>{row.label}</strong>
|
||||
<div><span style={{ left: row.active ? `${18 + index * 9}%` : "0" }} /></div>
|
||||
<small>{row.value}</small>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MissionWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const steps = [
|
||||
{ id: "01", label: "Аппарат", value: "Не назначен" },
|
||||
{ id: "02", label: "Зона", value: "Не задана" },
|
||||
{ id: "03", label: "Маршрут", value: "Черновик · 0 точек" },
|
||||
{ id: "04", label: "Наблюдение", value: "Облако точек" },
|
||||
{ id: "05", label: "Завершение", value: "Безопасная остановка" },
|
||||
];
|
||||
return (
|
||||
<div className="standard-workspace mission-workspace">
|
||||
<WorkspaceLead definition={definition} note="Команды на физический аппарат отключены" />
|
||||
<div className="mission-layout">
|
||||
<GlassSurface className="mission-sequence" padding="lg">
|
||||
<header className="panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">МИССИЯ / ЧЕРНОВИК</span>
|
||||
<h2>Новая миссия</h2>
|
||||
</div>
|
||||
<StatusBadge tone="warning">Интерфейс готов</StatusBadge>
|
||||
</header>
|
||||
<div className="mission-steps">
|
||||
{steps.map((step) => (
|
||||
<button key={step.id} type="button" disabled>
|
||||
<span>{step.id}</span>
|
||||
<div><strong>{step.label}</strong><small>{step.value}</small></div>
|
||||
<Icon name="chevron-right" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
<GlassSurface className="mission-summary" padding="lg">
|
||||
<span className="section-eyebrow">ГОТОВНОСТЬ</span>
|
||||
<div className="mission-readiness"><strong>0</strong><span>/ 5 блоков</span></div>
|
||||
<p>Сохранение и отправка станут доступны после подключения исполнителя миссий и проверки безопасности.</p>
|
||||
<div className="mission-summary__checks">
|
||||
<span><i />Аппарат</span>
|
||||
<span><i />Геометрия</span>
|
||||
<span><i />Связь</span>
|
||||
<span><i />Безопасность</span>
|
||||
</div>
|
||||
<Button variant="primary" width="full" disabled>Сохранить миссию</Button>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CatalogWorkspace({ definition }: WorkspaceRendererProps) {
|
||||
const total = definition.groups.reduce((count, group) => count + group.capabilities.length, 0);
|
||||
const active = definition.groups.reduce(
|
||||
(count, group) => count + group.capabilities.filter((capability) => capability.status === "active").length,
|
||||
0,
|
||||
);
|
||||
const ready = definition.groups.reduce(
|
||||
(count, group) => count + group.capabilities.filter((capability) => capability.status === "ready").length,
|
||||
0,
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="standard-workspace catalog-workspace">
|
||||
<WorkspaceLead definition={definition} note="Архитектурный и интерфейсный контракт" />
|
||||
<section className="capability-summary" aria-label="Готовность функционала">
|
||||
<div><span>Всего функций</span><strong>{total}</strong></div>
|
||||
<div><span>Работает сейчас</span><strong>{active}</strong></div>
|
||||
<div><span>Готово к данным</span><strong>{ready}</strong></div>
|
||||
<div><span>Без серверной логики</span><strong>{Math.max(0, total - active - ready)}</strong></div>
|
||||
</section>
|
||||
<FeatureInventory definition={definition} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
switch (props.definition.kind) {
|
||||
case "overview":
|
||||
return <OverviewWorkspace {...props} />;
|
||||
case "spatial":
|
||||
return <SpatialWorkspace {...props} />;
|
||||
case "cameras":
|
||||
return <CamerasWorkspace {...props} />;
|
||||
case "map":
|
||||
return <MapWorkspace {...props} />;
|
||||
case "timeline":
|
||||
return <TimelineWorkspace {...props} />;
|
||||
case "missions":
|
||||
return <MissionWorkspace {...props} />;
|
||||
case "catalog":
|
||||
return <CatalogWorkspace {...props} />;
|
||||
case "device":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user