diff --git a/apps/control-station/src/core/observatory/recordedJobs.ts b/apps/control-station/src/core/observatory/recordedJobs.ts index 17031f2..cc50658 100644 --- a/apps/control-station/src/core/observatory/recordedJobs.ts +++ b/apps/control-station/src/core/observatory/recordedJobs.ts @@ -26,6 +26,7 @@ export interface ObservatoryRecordedJob { readonly sourceSessionId: string; readonly setupId: string; readonly definitionSha256: string; + readonly claimGeneration: number; readonly state: ObservatoryRecordedJobState; readonly restartFromZero: boolean; readonly resultId: string | null; @@ -229,6 +230,7 @@ function decodeJob(value: unknown): ObservatoryRecordedJob { sourceSessionId: text(source.session_id, "source.session_id"), setupId: text(setup.setup_id, "setup.setup_id"), definitionSha256: String(setup.definition_sha256), + claimGeneration: nonNegativeInteger(row.claim_generation, "claim_generation"), state, restartFromZero: boolean(row.restart_from_zero, "restart_from_zero"), resultId: result === null ? null : text(result.result_id, "result.result_id"), diff --git a/apps/control-station/src/core/observatory/recordedProgress.ts b/apps/control-station/src/core/observatory/recordedProgress.ts new file mode 100644 index 0000000..adbb46a --- /dev/null +++ b/apps/control-station/src/core/observatory/recordedProgress.ts @@ -0,0 +1,118 @@ +import type { ObservatoryRecordedJob } from "./recordedJobs"; + +const PHASES = { + "source-transfer": "Передаём входные данные", + "source-preparation": "Подготавливаем вход", + computing: "Обрабатываем запись", + "result-assembly": "Собираем результат", + "result-transfer": "Передаём результат", +} as const; +const UNITS = { frames: "кадров", members: "файлов", steps: "операций" } as const; +type Phase = keyof typeof PHASES; +type Unit = keyof typeof UNITS; + +export interface RecordedProgressView { + readonly jobId: string; + readonly claimGeneration: number; + readonly sequence: number; + readonly phase: Phase; + readonly unit: Unit; + readonly completed: number; + readonly total: number | null; + readonly ageSeconds: number; +} + +export async function fetchRecordedProgress( + job: ObservatoryRecordedJob, + { signal, fetcher = globalThis.fetch }: { + signal?: AbortSignal; + fetcher?: typeof globalThis.fetch; + } = {}, +): Promise { + const request = new AbortController(); + const abort = () => request.abort(); + signal?.addEventListener("abort", abort, { once: true }); + if (signal?.aborted) request.abort(); + const timer = globalThis.setTimeout(abort, 2_500); + try { + const response = await fetcher( + `/api/v1/observatory/runs/${encodeURIComponent(job.jobId)}/progress`, + { signal: request.signal, headers: { Accept: "application/json" } }, + ); + if (!response.ok) throw new Error("Прогресс расчёта недоступен."); + return decodeRecordedProgress(await response.json(), job); + } finally { + globalThis.clearTimeout(timer); + signal?.removeEventListener("abort", abort); + } +} + +export function decodeRecordedProgress( + value: unknown, job: ObservatoryRecordedJob, +): RecordedProgressView | null { + const row = object(value); + keys(row, ["schema_version", "job_id", "source_session_id", "setup_id", + "definition_sha256", "claim_generation", "state", "received_at_utc", "age_seconds", "progress"]); + if (row.schema_version !== "missioncore.observatory-recorded-progress-view/v1" + || row.job_id !== job.jobId || row.source_session_id !== job.sourceSessionId + || row.setup_id !== job.setupId || row.definition_sha256 !== job.definitionSha256 + || row.claim_generation !== job.claimGeneration) throw new Error("Прогресс другой попытки."); + if (row.progress === null || row.state !== job.state) return null; + const progress = object(row.progress); + keys(progress, ["schema_version", "claim_generation", "sequence", "phase_index", + "phase", "unit", "completed", "total", "elapsed_seconds", "phase_elapsed_seconds"]); + if (progress.schema_version !== "missioncore.observatory-recorded-progress/v1" + || progress.claim_generation !== row.claim_generation + || !(typeof progress.phase === "string" && Object.hasOwn(PHASES, progress.phase)) + || !(typeof progress.unit === "string" && Object.hasOwn(UNITS, progress.unit)) + || typeof row.received_at_utc !== "string" || !Number.isFinite(Date.parse(row.received_at_utc))) { + throw new Error("Некорректный прогресс."); + } + const completed = integer(progress.completed); + const total = progress.total === null ? null : integer(progress.total, 1); + const elapsed = finite(progress.elapsed_seconds); + if ((total !== null && completed > total) || finite(progress.phase_elapsed_seconds) > elapsed) { + throw new Error("Некорректные счётчики прогресса."); + } + integer(progress.phase_index); + return { + jobId: job.jobId, claimGeneration: integer(progress.claim_generation, 1), + sequence: integer(progress.sequence, 1), phase: progress.phase as Phase, + unit: progress.unit as Unit, completed, total, ageSeconds: finite(row.age_seconds), + }; +} + +export function recordedProgressLabel( + job: ObservatoryRecordedJob | null, progress: RecordedProgressView | null, +): string { + if (job?.publication.state === "pending") return "Сохраняем результат"; + if (!job || job.state === "accepted" || job.state === "queued") return "Ожидаем расчёт"; + if (job.state === "paused" || job.state === "preemption-pending") return "Расчёт приостановлен"; + if (job.state === "reconciliation-required") return "Проверяем состояние расчёта"; + if (!progress || progress.jobId !== job.jobId + || progress.claimGeneration !== job.claimGeneration) return "Ожидаем данные расчёта"; + if (progress.ageSeconds > 15) return "Ожидаем обновление прогресса"; + const count = progress.total === null + ? (progress.completed > 0 ? ` · ${progress.completed.toLocaleString("ru-RU")} ${UNITS[progress.unit]}` : "") + : ` · ${progress.completed.toLocaleString("ru-RU")} / ${progress.total.toLocaleString("ru-RU")} ${UNITS[progress.unit]}`; + return PHASES[progress.phase] + count; +} + +function object(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Прогресс: ожидался объект."); + return value as Record; +} +function keys(row: Record, expected: string[]): void { + if (Object.keys(row).length !== expected.length || expected.some((key) => !Object.hasOwn(row, key))) { + throw new Error("Прогресс: неизвестные поля."); + } +} +function finite(value: unknown): number { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) throw new Error("Прогресс: некорректное число."); + return value; +} +function integer(value: unknown, minimum = 0): number { + const number = finite(value); + if (!Number.isSafeInteger(number) || number < minimum) throw new Error("Прогресс: некорректный счётчик."); + return number; +} diff --git a/apps/control-station/src/core/observatory/useObservatoryRecordedJobs.ts b/apps/control-station/src/core/observatory/useObservatoryRecordedJobs.ts index df5b325..9f0a775 100644 --- a/apps/control-station/src/core/observatory/useObservatoryRecordedJobs.ts +++ b/apps/control-station/src/core/observatory/useObservatoryRecordedJobs.ts @@ -1,4 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; +import { fetchRecordedProgress, type RecordedProgressView } from "./recordedProgress"; import { fetchObservatoryRecordedJobs, @@ -21,6 +22,7 @@ interface JobSnapshot { readonly jobs: readonly ObservatoryRecordedJob[]; readonly state: RecordedJobsState; readonly error: string | null; + readonly progress?: RecordedProgressView | null; } const EMPTY_JOBS = [] as const; const OPEN_STATES = new Set([ @@ -63,14 +65,19 @@ export function useObservatoryRecordedJobs( setSnapshot({ selectionKey, jobs, state: jobs.length > 0 ? "refreshing" : "loading", error: null, + progress: current?.progress ?? null, }); void fetchObservatoryRecordedJobs(sourceSessionId, setupId, { definitionSha256, signal: request.signal, - }).then((next) => { + }).then(async (next) => { if (request.signal.aborted || requestSequence.current !== sequence) return; const pending = next.find((job) => OPEN_STATES.has(job.state)); + const progress = pending + ? await fetchRecordedProgress(pending, { signal: request.signal }).catch(() => null) + : null; + if (request.signal.aborted || requestSequence.current !== sequence) return; if (pending) observedJobId.current = pending.jobId; - setSnapshot({ selectionKey, jobs: next, state: "ready", error: null }); + setSnapshot({ selectionKey, jobs: next, state: "ready", error: null, progress }); }).catch((caught: unknown) => { if (request.signal.aborted || requestSequence.current !== sequence) return; setSnapshot({ @@ -95,7 +102,7 @@ export function useObservatoryRecordedJobs( && latestJob.jobId === observedJobId.current; useEffect(() => { - if (state !== "ready" || (!activeJob && !publicationPending)) return; + if ((state !== "ready" && state !== "error") || (!activeJob && !publicationPending)) return; const timer = globalThis.setTimeout( () => setRevision((value) => value + 1), POLL_INTERVAL_MS, @@ -185,6 +192,7 @@ export function useObservatoryRecordedJobs( return { jobs, latestJob, activeJob, publicationPending, computationFailed, + progress: current?.progress ?? null, state, error, refresh, submit, retryPublication, }; } diff --git a/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx b/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx index fb81b24..531b034 100644 --- a/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx +++ b/apps/control-station/src/workspaces/observatory/ObservatoryWorkspace.tsx @@ -33,6 +33,7 @@ import { import { useObservatoryCatalog } from "../../core/observatory/useObservatoryCatalog"; import { useObservatoryLaboratorySetups } from "../../core/observatory/useObservatoryLaboratorySetups"; import { useObservatoryRecordedJobs } from "../../core/observatory/useObservatoryRecordedJobs"; +import { recordedProgressLabel } from "../../core/observatory/recordedProgress"; import type { WorkspaceDefinition } from "../../productModel"; const EMPTY_OBSERVATORY_ITEMS = [] as const; @@ -466,7 +467,12 @@ export function ObservatoryWorkspace({
{calculationPending ? ( - + <> + + + {recordedProgressLabel(presentedJob, recordedJobsController.progress)} + + ) : null} {showCalculate ? (