feat: add Polygon UI-0 run view
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
GlassSurface,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
fetchPolygonRunCatalog,
|
||||
fetchPolygonRunDetail,
|
||||
type PolygonRunCatalog,
|
||||
type PolygonRunDetail,
|
||||
type PolygonRunRoute,
|
||||
type PolygonRunState,
|
||||
} from "../core/polygon/runArchive";
|
||||
|
||||
interface PolygonRunWorkspaceProps {
|
||||
route: PolygonRunRoute;
|
||||
}
|
||||
|
||||
const stateLabels: Record<PolygonRunState, string> = {
|
||||
admitted: "Допущен",
|
||||
starting: "Запускается",
|
||||
running: "Выполняется",
|
||||
paused: "На паузе",
|
||||
stopping: "Останавливается",
|
||||
completed: "Завершён",
|
||||
failed: "Ошибка",
|
||||
aborted: "Прерван",
|
||||
};
|
||||
|
||||
function stateTone(
|
||||
state: PolygonRunState,
|
||||
): "success" | "accent" | "warning" | "danger" | "neutral" {
|
||||
if (state === "completed") return "success";
|
||||
if (state === "running") return "accent";
|
||||
if (state === "failed" || state === "aborted") return "danger";
|
||||
if (state === "starting" || state === "stopping" || state === "paused") return "warning";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function formatTimestamp(value: string | null): string {
|
||||
if (!value) return "—";
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium",
|
||||
}).format(new Date(value));
|
||||
}
|
||||
|
||||
function formatBytes(value: number): string {
|
||||
if (value === 0) return "0 Б";
|
||||
const units = ["Б", "КБ", "МБ", "ГБ"];
|
||||
const exponent = Math.min(Math.floor(Math.log(value) / Math.log(1024)), units.length - 1);
|
||||
return `${(value / 1024 ** exponent).toLocaleString("ru-RU", {
|
||||
maximumFractionDigits: 1,
|
||||
})} ${units[exponent]}`;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
if (error instanceof Error && error.message.trim()) return error.message;
|
||||
return "Не удалось прочитать доказательства прогона.";
|
||||
}
|
||||
|
||||
export function PolygonRunWorkspace({ route }: PolygonRunWorkspaceProps) {
|
||||
const [catalog, setCatalog] = useState<PolygonRunCatalog | null>(null);
|
||||
const [detail, setDetail] = useState<PolygonRunDetail | null>(null);
|
||||
const [selectedRunId, setSelectedRunId] = useState<string | null>(route.runId);
|
||||
const [loading, setLoading] = useState(route.error === null);
|
||||
const [error, setError] = useState<string | null>(route.error);
|
||||
const [reloadGeneration, setReloadGeneration] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (route.error) {
|
||||
setLoading(false);
|
||||
setError(route.error);
|
||||
return;
|
||||
}
|
||||
const controller = new AbortController();
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
void (async () => {
|
||||
try {
|
||||
const nextCatalog = await fetchPolygonRunCatalog({ signal: controller.signal });
|
||||
if (controller.signal.aborted) return;
|
||||
setCatalog(nextCatalog);
|
||||
const targetRunId = selectedRunId ?? route.runId ?? nextCatalog.items[0]?.runId ?? null;
|
||||
if (!targetRunId) {
|
||||
setDetail(null);
|
||||
return;
|
||||
}
|
||||
const nextDetail = await fetchPolygonRunDetail(targetRunId, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (controller.signal.aborted) return;
|
||||
setSelectedRunId(targetRunId);
|
||||
setDetail(nextDetail);
|
||||
} catch (loadError) {
|
||||
if (controller.signal.aborted) return;
|
||||
setDetail(null);
|
||||
setError(errorMessage(loadError));
|
||||
} finally {
|
||||
if (!controller.signal.aborted) setLoading(false);
|
||||
}
|
||||
})();
|
||||
return () => controller.abort();
|
||||
}, [reloadGeneration, route.error, route.runId, selectedRunId]);
|
||||
|
||||
const visibleEvents = useMemo(
|
||||
() => detail ? [...detail.events].reverse() : [],
|
||||
[detail],
|
||||
);
|
||||
|
||||
if (loading && !detail) {
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<GlassSurface className="polygon-run-message" padding="lg">
|
||||
<StatusBadge tone="accent">Только чтение</StatusBadge>
|
||||
<h2>Проверяем журнал прогона</h2>
|
||||
<p>Mission Core читает манифест, события и индекс артефактов без запуска провайдеров.</p>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<GlassSurface className="polygon-run-message" padding="lg">
|
||||
<StatusBadge tone="danger">Данные недоступны</StatusBadge>
|
||||
<h2>UI-0 не может открыть прогон</h2>
|
||||
<p>{error}</p>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
onClick={() => setReloadGeneration((value) => value + 1)}
|
||||
>
|
||||
Повторить чтение
|
||||
</Button>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!detail) {
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<GlassSurface className="polygon-run-message" padding="lg">
|
||||
<StatusBadge tone="neutral">Журнал пуст</StatusBadge>
|
||||
<h2>Квалификационных прогонов пока нет</h2>
|
||||
<p>Экран появится автоматически после публикации первого журнала в read-only источник.</p>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const { run } = detail;
|
||||
return (
|
||||
<div className="standard-workspace polygon-run-workspace">
|
||||
<section className="workspace-lead workspace-lead--compact">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОЛИГОН / КВАЛИФИКАЦИОННЫЙ ПРОГОН</span>
|
||||
<h2>{run.runId}</h2>
|
||||
<p>
|
||||
Канонический журнал Mission Core. Экран не содержит lifecycle-операций,
|
||||
команд управления или доступа к физическим актуаторам.
|
||||
</p>
|
||||
</div>
|
||||
<div className="polygon-run-lead-status">
|
||||
<StatusBadge tone={stateTone(run.state)}>{stateLabels[run.state]}</StatusBadge>
|
||||
<span>read-only · {run.reproducibilityTier}</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="polygon-run-metrics" aria-label="Сводка прогона">
|
||||
<div>
|
||||
<span>Состояние</span>
|
||||
<strong>{stateLabels[run.state]}</strong>
|
||||
<small>{run.terminalReason ?? "терминальная причина отсутствует"}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Провайдеры</span>
|
||||
<strong>{run.providers.length}</strong>
|
||||
<small>{run.providerIds.join(" · ")}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>События</span>
|
||||
<strong>{detail.eventsTotal}</strong>
|
||||
<small>{detail.eventsTruncated ? "показан последний фрагмент" : "журнал целиком"}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>Команды</span>
|
||||
<strong>{detail.commandCount}</strong>
|
||||
<small>содержимое не публикуется UI-0</small>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="polygon-run-layout">
|
||||
<GlassSurface className="polygon-run-catalog" padding="lg">
|
||||
<header className="polygon-run-panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ПОСЛЕДНИЕ ПРОГОНЫ</span>
|
||||
<h3>{catalog?.total ?? 0} в источнике</h3>
|
||||
</div>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
onClick={() => setReloadGeneration((value) => value + 1)}
|
||||
>
|
||||
Обновить
|
||||
</Button>
|
||||
</header>
|
||||
<div className="polygon-run-list">
|
||||
{catalog?.items.map((item) => (
|
||||
<button
|
||||
type="button"
|
||||
key={item.runId}
|
||||
data-active={item.runId === run.runId ? "true" : undefined}
|
||||
onClick={() => setSelectedRunId(item.runId)}
|
||||
>
|
||||
<i data-state={item.state} aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{item.runId}</strong>
|
||||
<small>{formatTimestamp(item.createdAtUtc)}</small>
|
||||
</span>
|
||||
<em>{stateLabels[item.state]}</em>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<GlassSurface className="polygon-run-identity" padding="lg">
|
||||
<span className="section-eyebrow">ИДЕНТИЧНОСТЬ И ГРАНИЦА</span>
|
||||
<dl>
|
||||
<div><dt>Сценарий</dt><dd>{run.scenarioGeneration}</dd></div>
|
||||
<div><dt>Профиль</dt><dd>{run.profileGeneration}</dd></div>
|
||||
<div><dt>Host profile</dt><dd>{run.hostProfileId}</dd></div>
|
||||
<div><dt>Mission Core</dt><dd><code>{run.missionCoreCommit.slice(0, 12)}</code></dd></div>
|
||||
<div><dt>Clock</dt><dd><code>{run.clockDomain}</code></dd></div>
|
||||
<div><dt>Seed</dt><dd>{run.seed}</dd></div>
|
||||
<div><dt>Начало</dt><dd>{formatTimestamp(run.startedAtUtc)}</dd></div>
|
||||
<div><dt>Завершение</dt><dd>{formatTimestamp(run.endedAtUtc)}</dd></div>
|
||||
</dl>
|
||||
<div className="polygon-run-safety-boundary">
|
||||
<StatusBadge tone="success">Virtual only</StatusBadge>
|
||||
<p>
|
||||
Actuator authority: {run.authority.actuatorAuthority ? "да" : "нет"} ·
|
||||
direct setpoints: {run.authority.directActuatorSetpointsAllowed ? "да" : "нет"} ·
|
||||
navigation/safety accepted: {run.authority.navigationOrSafetyAccepted ? "да" : "нет"}
|
||||
</p>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
|
||||
<section className="polygon-run-providers" aria-label="Провайдеры прогона">
|
||||
{run.providers.map((provider) => (
|
||||
<div key={provider.identifier}>
|
||||
<i aria-hidden="true" />
|
||||
<span>
|
||||
<strong>{provider.identifier}</strong>
|
||||
<small>{provider.version}</small>
|
||||
</span>
|
||||
<code>{provider.revision.slice(0, 16)}</code>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<GlassSurface className="polygon-run-events" padding="lg">
|
||||
<header className="polygon-run-panel-heading">
|
||||
<div>
|
||||
<span className="section-eyebrow">ЖУРНАЛ СОБЫТИЙ</span>
|
||||
<h3>Последние переходы и факты</h3>
|
||||
</div>
|
||||
<span>revision {run.revision}</span>
|
||||
</header>
|
||||
<div className="polygon-run-event-list">
|
||||
{visibleEvents.map((event) => (
|
||||
<article key={event.sequence}>
|
||||
<span className="polygon-run-event-sequence">
|
||||
{String(event.sequence).padStart(3, "0")}
|
||||
</span>
|
||||
<div>
|
||||
<strong>{event.eventType}</strong>
|
||||
<small>{formatTimestamp(event.observedAtUtc)}</small>
|
||||
<code>{JSON.stringify(event.payload)}</code>
|
||||
</div>
|
||||
<span>{event.simTimeNs === null ? "host" : `${event.simTimeNs} ns`}</span>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
<div className="polygon-run-evidence-grid">
|
||||
<GlassSurface className="polygon-run-artifacts" padding="lg">
|
||||
<span className="section-eyebrow">АРТЕФАКТЫ</span>
|
||||
<h3>{detail.artifacts.length || run.artifactCount} ссылок в индексе</h3>
|
||||
{detail.artifacts.length ? (
|
||||
<div>
|
||||
{detail.artifacts.map((artifact) => (
|
||||
<article key={artifact.artifactId}>
|
||||
<span>
|
||||
<strong>{artifact.kind}</strong>
|
||||
<small>{artifact.relativePath}</small>
|
||||
</span>
|
||||
<code>{artifact.sha256.slice(0, 12)} · {formatBytes(artifact.byteLength)}</code>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>В журнале прогона нет зарегистрированных artifact-index записей.</p>
|
||||
)}
|
||||
</GlassSurface>
|
||||
<GlassSurface className="polygon-run-limitations" padding="lg">
|
||||
<span className="section-eyebrow">ЧЕСТНАЯ ГРАНИЦА UI-0</span>
|
||||
<h3>Что этот результат ещё не доказывает</h3>
|
||||
<ul>
|
||||
{detail.limitations.map((limitation) => <li key={limitation}>{limitation}</li>)}
|
||||
</ul>
|
||||
</GlassSurface>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
import { ObservationTimeline } from "../components/ObservationTimeline";
|
||||
import { FloatingObservationWindow } from "../components/FloatingObservationWindow";
|
||||
import type { ObservationSessionReplayLaunch } from "../core/observation/sessionArchive";
|
||||
import type { PolygonRunRoute } from "../core/polygon/runArchive";
|
||||
import type { RecordedSessionAdmissionController } from "../core/observation/useRecordedSessionAdmission";
|
||||
import type {
|
||||
RecordedAdmissionPhase,
|
||||
@@ -55,6 +56,7 @@ import {
|
||||
} from "../productModel";
|
||||
import { finiteMetric, formatNumber, pipelineLatency, sourceModeLabel } from "../presentation";
|
||||
import type { SceneSettings } from "../sceneSettings";
|
||||
import { PolygonRunWorkspace } from "./PolygonRunWorkspace";
|
||||
|
||||
function statusTone(status: CapabilityStatus): "success" | "accent" | "warning" | "neutral" {
|
||||
if (status === "active") return "success";
|
||||
@@ -136,6 +138,7 @@ export interface WorkspaceRendererProps {
|
||||
cuboids3d: boolean;
|
||||
}) => void;
|
||||
observationLayout: ObservationLayoutController;
|
||||
polygonRunRoute: PolygonRunRoute;
|
||||
navigation: WorkspaceNavigation;
|
||||
spatialControls: {
|
||||
View: ComponentType<DevicePluginConnectionProps>;
|
||||
@@ -1303,6 +1306,8 @@ export function WorkspaceRenderer(props: WorkspaceRendererProps) {
|
||||
return <MissionWorkspace {...props} />;
|
||||
case "catalog":
|
||||
return <CatalogWorkspace {...props} />;
|
||||
case "polygon-run":
|
||||
return <PolygonRunWorkspace route={props.polygonRunRoute} />;
|
||||
case "device":
|
||||
return null;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user