feat(observatory): add laboratory setup preflight
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Button,
|
||||
GlassSurface,
|
||||
Icon,
|
||||
StatusBadge,
|
||||
} from "@nodedc/ui-react";
|
||||
|
||||
import type { ObservatoryEvidence } from "../../core/observatory/catalog";
|
||||
import type { ObservatoryLaboratorySetup } from "../../core/observatory/laboratorySetups";
|
||||
import type { ObservatoryRecordedRunBinding } from "../../core/observatory/recordedRun";
|
||||
import type { SetupPreflightState } from "../../core/observatory/useObservatoryLaboratorySetups";
|
||||
|
||||
const relationLabel: Readonly<Record<string, string>> = {
|
||||
"primary-visual": "Основной визуальный разбор",
|
||||
"compute-successor": "Связанное вычислительное доказательство",
|
||||
"canonical-projection": "Готовый записанный разбор",
|
||||
};
|
||||
|
||||
function workerLabel(value: string): string {
|
||||
const match = /^worker-(\d+)$/.exec(value);
|
||||
return match ? `Worker ${match[1]}` : value;
|
||||
}
|
||||
|
||||
export function ObservatorySetupDetail({
|
||||
setup,
|
||||
evidence,
|
||||
preflight,
|
||||
onCheck,
|
||||
onOpenExisting,
|
||||
}: {
|
||||
setup: ObservatoryLaboratorySetup;
|
||||
evidence: readonly ObservatoryEvidence[];
|
||||
preflight: SetupPreflightState;
|
||||
onCheck: () => void;
|
||||
onOpenExisting: (binding: ObservatoryRecordedRunBinding) => void;
|
||||
}) {
|
||||
const exactExisting = evidence.find(
|
||||
(candidate) => candidate.recordedRun
|
||||
&& setup.preflight.existingResultIds.includes(candidate.recordedRun.resultId),
|
||||
)?.recordedRun ?? null;
|
||||
const displayedReason = preflight.kind === "ready"
|
||||
? preflight.value.checks.find((check) => check.checkId === "executor")?.message
|
||||
?? setup.executor.reason
|
||||
: preflight.kind === "error"
|
||||
? preflight.message
|
||||
: setup.preflight.reason;
|
||||
|
||||
return (
|
||||
<GlassSurface className="observatory-setup-detail" padding="md">
|
||||
<div className="observatory-setup-detail__identity">
|
||||
<span className="section-eyebrow">СЕТАП ЛАБОРАТОРИИ</span>
|
||||
<h3>{setup.displayName}</h3>
|
||||
<p>{setup.description}</p>
|
||||
</div>
|
||||
<div className="observatory-setup-detail__badges">
|
||||
<StatusBadge tone={setup.compatibility.compatible ? "success" : "warning"}>
|
||||
{setup.compatibility.compatible ? "Совместим" : "Другая сессия"}
|
||||
</StatusBadge>
|
||||
<StatusBadge tone={setup.origin === "existing-result" ? "accent" : "neutral"}>
|
||||
{setup.origin === "existing-result" ? "Готовый результат" : "Архивный сетап"}
|
||||
</StatusBadge>
|
||||
</div>
|
||||
<dl className="observatory-setup-detail__facts">
|
||||
<div>
|
||||
<dt>Идентичность</dt>
|
||||
<dd>
|
||||
{setup.runDefinition
|
||||
? `Версия ${setup.runDefinition.version} · конфигурация зафиксирована`
|
||||
: "Готовый записанный результат без повторно запускаемой конфигурации"}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Исполнитель</dt>
|
||||
<dd>
|
||||
{setup.preflight.outcome === "existing"
|
||||
? "Не требуется для просмотра"
|
||||
: `${workerLabel(setup.executor.contourId)} · повторный запуск не подключён`}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Сохранено</dt>
|
||||
<dd>{setup.preservedResults.length} неизменяемых результатов</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<ol className="observatory-setup-results" aria-label="Сохранённые результаты сетапа">
|
||||
{setup.preservedResults.map((result) => (
|
||||
<li key={result.resultId}>
|
||||
<Icon name={result.access === "evidence-only" ? "database" : "clipboard"} size={15} />
|
||||
<span>
|
||||
<strong>{relationLabel[result.relation] ?? "Сохранённый результат"}</strong>
|
||||
<small>
|
||||
{result.access === "observatory"
|
||||
? "Доступен в Обсерватории"
|
||||
: result.access === "legacy-lab"
|
||||
? "Сохранён в legacy LAB"
|
||||
: "Сохранено как связанное доказательство"}
|
||||
</small>
|
||||
</span>
|
||||
<StatusBadge tone={result.observatoryProjectionAvailable ? "success" : "neutral"}>
|
||||
{result.access === "observatory"
|
||||
? result.observatoryProjectionAvailable ? "Готов" : "Сохранён"
|
||||
: result.access === "legacy-lab" ? "Legacy LAB" : "Evidence"}
|
||||
</StatusBadge>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
<div className="observatory-setup-detail__footer">
|
||||
<div className="observatory-setup-detail__reason" role={preflight.kind === "error" ? "alert" : "status"}>
|
||||
<Icon name={preflight.kind === "error" ? "alert" : "activity"} size={16} />
|
||||
<span>{displayedReason}</span>
|
||||
</div>
|
||||
<div className="observatory-setup-detail__actions">
|
||||
{exactExisting ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
icon={<Icon name="eye" size={15} />}
|
||||
onClick={() => onOpenExisting(exactExisting)}
|
||||
>
|
||||
Открыть готовый результат
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={preflight.kind === "checking"}
|
||||
onClick={onCheck}
|
||||
>
|
||||
{preflight.kind === "checking"
|
||||
? <ActivityIndicator size="compact" label="Проверяем совместимость" />
|
||||
: "Проверить совместимость"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
);
|
||||
}
|
||||
@@ -31,7 +31,9 @@ import {
|
||||
renameObservatoryLabProjection,
|
||||
} from "../../core/observatory/catalogMutations";
|
||||
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
|
||||
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
|
||||
import type { WorkspaceDefinition } from "../../productModel";
|
||||
import { ObservatorySetupDetail } from "./ObservatorySetupDetail";
|
||||
|
||||
const MAX_PRESENTED_EVIDENCE = 6;
|
||||
const EMPTY_OBSERVATORY_ITEMS = [] as const;
|
||||
@@ -133,6 +135,7 @@ export function ObservatoryWorkspace({
|
||||
}) {
|
||||
const controller = useObservatoryCatalog();
|
||||
const [selectedSessionId, setSelectedSessionId] = useState("");
|
||||
const setupController = useObservatoryLaboratorySetups(selectedSessionId);
|
||||
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
|
||||
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
@@ -170,6 +173,15 @@ export function ObservatoryWorkspace({
|
||||
label: source.label,
|
||||
description: `${formatTimestamp(source.startedAtUtc)} · ${formatDuration(source.durationSeconds)} · ${evidence.length} результатов`,
|
||||
})), [items]);
|
||||
const setupOptions = useMemo(() => (
|
||||
setupController.catalog?.setups.map((setup) => ({
|
||||
value: setup.setupId,
|
||||
label: setup.displayName,
|
||||
description: setup.compatibility.compatible
|
||||
? setup.origin === "existing-result" ? "Готовый результат" : "Совместимый архивный сетап"
|
||||
: "Несовместим с выбранной сессией",
|
||||
})) ?? []
|
||||
), [setupController.catalog]);
|
||||
const initialLoading = !controller.catalog
|
||||
&& ["idle", "loading"].includes(controller.state);
|
||||
const unavailable = !controller.catalog && controller.state === "error";
|
||||
@@ -375,12 +387,32 @@ export function ObservatoryWorkspace({
|
||||
menuWidth={460}
|
||||
onChange={selectSession}
|
||||
/>
|
||||
<Select
|
||||
label="Выбрать сетап лаборатории"
|
||||
value={setupController.selectedSetupId}
|
||||
options={setupOptions}
|
||||
disabled={!selectedSessionId || setupOptions.length === 0}
|
||||
searchable
|
||||
searchPlaceholder="Поиск по сетапам"
|
||||
emptyLabel={setupController.state === "error" ? "Каталог сетапов недоступен" : "Сетап не найден"}
|
||||
minMenuWidth={360}
|
||||
menuWidth={500}
|
||||
onChange={setupController.selectSetup}
|
||||
/>
|
||||
<Button
|
||||
size="compact"
|
||||
variant="secondary"
|
||||
disabled={controller.state === "loading" || controller.state === "refreshing"}
|
||||
disabled={
|
||||
controller.state === "loading"
|
||||
|| controller.state === "refreshing"
|
||||
|| setupController.state === "loading"
|
||||
|| setupController.state === "refreshing"
|
||||
}
|
||||
icon={<Icon name="refresh" />}
|
||||
onClick={controller.refresh}
|
||||
onClick={() => {
|
||||
void controller.refresh();
|
||||
setupController.refresh();
|
||||
}}
|
||||
>
|
||||
Обновить
|
||||
</Button>
|
||||
@@ -395,6 +427,14 @@ export function ObservatoryWorkspace({
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
{setupController.error && setupController.catalog ? (
|
||||
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
||||
<StatusBadge tone="warning">Показан последний каталог сетапов</StatusBadge>
|
||||
<span className="observatory-notice__copy">{setupController.error}</span>
|
||||
<Button size="compact" variant="ghost" onClick={setupController.refresh}>Повторить</Button>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
{controller.catalog
|
||||
&& (controller.catalog.window.sourceLimitReached
|
||||
|| controller.catalog.window.laboratoryLimitReached) ? (
|
||||
@@ -460,6 +500,22 @@ export function ObservatoryWorkspace({
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
{setupController.selectedSetup ? (
|
||||
<ObservatorySetupDetail
|
||||
setup={setupController.selectedSetup}
|
||||
evidence={selectedSession.evidence}
|
||||
preflight={setupController.preflight}
|
||||
onCheck={() => { void setupController.check(); }}
|
||||
onOpenExisting={openReplay}
|
||||
/>
|
||||
) : setupController.error ? (
|
||||
<GlassSurface className="observatory-notice" padding="md" tone="soft" role="alert">
|
||||
<StatusBadge tone="warning">Сетапы недоступны</StatusBadge>
|
||||
<span className="observatory-notice__copy">{setupController.error}</span>
|
||||
<Button size="compact" variant="ghost" onClick={setupController.refresh}>Повторить</Button>
|
||||
</GlassSurface>
|
||||
) : null}
|
||||
|
||||
<section className="observatory-evidence">
|
||||
<header>
|
||||
<div>
|
||||
|
||||
Reference in New Issue
Block a user