feat(observatory): add durable recorded compute queue
This commit is contained in:
@@ -1,139 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -32,8 +32,9 @@ import {
|
||||
} from "../../core/observatory/catalogMutations";
|
||||
import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog";
|
||||
import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups";
|
||||
import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs";
|
||||
import type { ObservatoryRecordedJobState } from "../../core/observatory/recordedJobs";
|
||||
import type { WorkspaceDefinition } from "../../productModel";
|
||||
import { ObservatorySetupDetail } from "./ObservatorySetupDetail";
|
||||
|
||||
const MAX_PRESENTED_EVIDENCE = 6;
|
||||
const EMPTY_OBSERVATORY_ITEMS = [] as const;
|
||||
@@ -87,6 +88,27 @@ const modalityLabel: Record<string, string> = {
|
||||
telemetry: "Телеметрия",
|
||||
};
|
||||
|
||||
const recordedJobStatus: Record<
|
||||
ObservatoryRecordedJobState,
|
||||
{
|
||||
readonly label: string;
|
||||
readonly tone: "success" | "accent" | "warning" | "danger" | "neutral";
|
||||
}
|
||||
> = {
|
||||
accepted: { label: "Принят", tone: "neutral" },
|
||||
queued: { label: "Ждёт Worker", tone: "neutral" },
|
||||
claimed: { label: "Назначен Worker", tone: "accent" },
|
||||
running: { label: "Выполняется", tone: "accent" },
|
||||
paused: { label: "Пауза: live-поток", tone: "warning" },
|
||||
"preemption-pending": {
|
||||
label: "Ждём подтверждения остановки",
|
||||
tone: "warning",
|
||||
},
|
||||
succeeded: { label: "Готово", tone: "success" },
|
||||
failed: { label: "Ошибка расчёта", tone: "danger" },
|
||||
"reconciliation-required": { label: "Нужна сверка", tone: "warning" },
|
||||
};
|
||||
|
||||
function statusTone(
|
||||
status: ObservationSessionStatus,
|
||||
): "success" | "accent" | "warning" | "danger" | "neutral" {
|
||||
@@ -136,6 +158,10 @@ export function ObservatoryWorkspace({
|
||||
const controller = useObservatoryCatalog();
|
||||
const [selectedSessionId, setSelectedSessionId] = useState("");
|
||||
const setupController = useObservatoryLaboratorySetups(selectedSessionId);
|
||||
const recordedJobsController = useObservatoryRecordedJobs(
|
||||
selectedSessionId,
|
||||
setupController.selectedSetupId,
|
||||
);
|
||||
const [replay, setReplay] = useState<ObservatoryReplayState>({ kind: "closed" });
|
||||
const [renameTarget, setRenameTarget] = useState<ObservatoryEvidence | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
@@ -188,6 +214,29 @@ export function ObservatoryWorkspace({
|
||||
: "Несовместим с выбранной сессией",
|
||||
})) ?? []
|
||||
), [setupController.catalog]);
|
||||
const runPreflight = setupController.preflight.kind === "ready"
|
||||
? setupController.preflight.value
|
||||
: null;
|
||||
const queueSubmissionAllowed = Boolean(
|
||||
setupController.selectedSetup?.runDefinition
|
||||
&& setupController.selectedSetup.compatibility.compatible
|
||||
&& runPreflight?.outcome === "queueable"
|
||||
&& runPreflight.submissionAllowed,
|
||||
);
|
||||
const presentedJob = recordedJobsController.activeJob
|
||||
?? recordedJobsController.latestJob;
|
||||
const presentedJobStatus = presentedJob
|
||||
? recordedJobStatus[presentedJob.state]
|
||||
: null;
|
||||
const queueStatusError = setupController.preflight.kind === "error"
|
||||
? setupController.preflight.message
|
||||
: recordedJobsController.error;
|
||||
const queueStateBusy = recordedJobsController.state === "loading"
|
||||
|| recordedJobsController.state === "refreshing"
|
||||
|| recordedJobsController.state === "submitting";
|
||||
const canSubmitRecordedJob = queueSubmissionAllowed
|
||||
&& recordedJobsController.state === "ready"
|
||||
&& recordedJobsController.activeJob === null;
|
||||
const initialLoading = !controller.catalog
|
||||
&& ["idle", "loading"].includes(controller.state);
|
||||
const unavailable = !controller.catalog && controller.state === "error";
|
||||
@@ -374,11 +423,10 @@ export function ObservatoryWorkspace({
|
||||
</StatusBadge>
|
||||
</section>
|
||||
|
||||
<GlassSurface className="observatory-catalog-bar" padding="md">
|
||||
<GlassSurface className="observatory-catalog-bar" padding="sm">
|
||||
<div className="observatory-catalog-bar__copy">
|
||||
<span className="section-eyebrow">ИСТОЧНИК ДОКАЗАТЕЛЬСТВ</span>
|
||||
<h3>Сохранённая сессия</h3>
|
||||
<p>Выбор меняет только читаемую карточку и не готовит визуальный разбор в фоне.</p>
|
||||
</div>
|
||||
<div className="observatory-catalog-bar__controls">
|
||||
<Select
|
||||
@@ -418,10 +466,40 @@ export function ObservatoryWorkspace({
|
||||
onClick={() => {
|
||||
void controller.refresh();
|
||||
setupController.refresh();
|
||||
recordedJobsController.refresh();
|
||||
}}
|
||||
>
|
||||
Обновить
|
||||
</Button>
|
||||
<div className="observatory-catalog-bar__run" aria-live="polite">
|
||||
{queueStatusError ? (
|
||||
<StatusBadge tone="danger" title={queueStatusError}>
|
||||
Очередь недоступна
|
||||
</StatusBadge>
|
||||
) : recordedJobsController.state === "submitting" ? (
|
||||
<StatusBadge tone="neutral">Ставим в очередь</StatusBadge>
|
||||
) : presentedJobStatus ? (
|
||||
<StatusBadge
|
||||
tone={presentedJobStatus.tone}
|
||||
title={presentedJob?.terminalMessage ?? undefined}
|
||||
>
|
||||
{presentedJobStatus.label}
|
||||
</StatusBadge>
|
||||
) : setupController.preflight.kind === "checking" ? (
|
||||
<StatusBadge tone="neutral">Проверяем сетап</StatusBadge>
|
||||
) : queueStateBusy ? (
|
||||
<StatusBadge tone="neutral">Читаем очередь</StatusBadge>
|
||||
) : null}
|
||||
{canSubmitRecordedJob ? (
|
||||
<Button
|
||||
size="compact"
|
||||
variant="primary"
|
||||
onClick={() => { void recordedJobsController.submit(); }}
|
||||
>
|
||||
Рассчитать
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</GlassSurface>
|
||||
|
||||
@@ -476,7 +554,7 @@ export function ObservatoryWorkspace({
|
||||
</GlassSurface>
|
||||
) : selectedSession ? (
|
||||
<section className="observatory-session-stack" aria-label="Выбранная сессия и связанные результаты">
|
||||
<GlassSurface className="observatory-session-summary" padding="md">
|
||||
<GlassSurface className="observatory-session-summary" padding="sm">
|
||||
<div className="observatory-session-summary__identity">
|
||||
<span className="section-eyebrow">ИСХОДНАЯ СЕССИЯ</span>
|
||||
<h3>{selectedSession.source.label}</h3>
|
||||
@@ -506,22 +584,6 @@ 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