feat(observatory): add laboratory setup preflight
This commit is contained in:
@@ -0,0 +1,385 @@
|
||||
const CATALOG_SCHEMA = "missioncore.observatory-laboratory-setup-catalog/v1";
|
||||
const PREFLIGHT_REQUEST_SCHEMA = "missioncore.observatory-run-preflight-request/v1";
|
||||
const PREFLIGHT_SCHEMA = "missioncore.observatory-run-preflight/v1";
|
||||
const SHA256 = /^[a-f0-9]{64}$/;
|
||||
|
||||
export type ObservatoryLaboratorySetupOrigin = "archived-definition" | "existing-result";
|
||||
export type ObservatoryLaboratorySetupAction = "open-existing" | "open-legacy" | "blocked";
|
||||
|
||||
export interface ObservatoryLaboratoryRunDefinition {
|
||||
readonly definitionId: string;
|
||||
readonly version: number;
|
||||
readonly workId: string;
|
||||
readonly definitionSha256: string;
|
||||
readonly configuration: readonly {
|
||||
readonly role: string;
|
||||
readonly sha256: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ObservatoryLaboratoryPreservedResult {
|
||||
readonly resultId: string;
|
||||
readonly resultKind: string;
|
||||
readonly relation: string;
|
||||
readonly access: "legacy-lab" | "observatory" | "evidence-only";
|
||||
readonly createdAtUtc: string;
|
||||
readonly observatoryProjectionAvailable: boolean;
|
||||
}
|
||||
|
||||
export interface ObservatoryLaboratorySetup {
|
||||
readonly setupId: string;
|
||||
readonly displayName: string;
|
||||
readonly description: string;
|
||||
readonly origin: ObservatoryLaboratorySetupOrigin;
|
||||
readonly runDefinition: ObservatoryLaboratoryRunDefinition | null;
|
||||
readonly compatibility: {
|
||||
readonly compatible: boolean;
|
||||
readonly reasons: readonly { readonly code: string; readonly message: string }[];
|
||||
};
|
||||
readonly executor: {
|
||||
readonly contourId: string;
|
||||
readonly state: "not-installed";
|
||||
readonly reasonCode: string;
|
||||
readonly reason: string;
|
||||
};
|
||||
readonly preservedResults: readonly ObservatoryLaboratoryPreservedResult[];
|
||||
readonly preflight: {
|
||||
readonly outcome: "existing" | "blocked";
|
||||
readonly action: ObservatoryLaboratorySetupAction;
|
||||
readonly reason: string;
|
||||
readonly submissionAllowed: false;
|
||||
readonly existingResultIds: readonly string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface ObservatoryLaboratorySetupCatalog {
|
||||
readonly sourceSessionId: string;
|
||||
readonly setups: readonly ObservatoryLaboratorySetup[];
|
||||
}
|
||||
|
||||
export interface ObservatoryLaboratoryRunPreflight {
|
||||
readonly sourceSessionId: string;
|
||||
readonly setupId: string;
|
||||
readonly definitionSha256: string | null;
|
||||
readonly outcome: "existing" | "blocked";
|
||||
readonly submissionAllowed: false;
|
||||
readonly checks: readonly {
|
||||
readonly checkId: string;
|
||||
readonly outcome: "pass" | "fail" | "not-applicable";
|
||||
readonly reasonCode: string;
|
||||
readonly message: string;
|
||||
}[];
|
||||
readonly existingResultIds: readonly string[];
|
||||
}
|
||||
|
||||
export type ObservatoryLaboratorySetupFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export class ObservatoryLaboratorySetupContractError extends Error {
|
||||
readonly status: number | null;
|
||||
|
||||
constructor(message: string, status: number | null = null) {
|
||||
super(message);
|
||||
this.name = "ObservatoryLaboratorySetupContractError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchObservatoryLaboratorySetups(
|
||||
sourceSessionId: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryLaboratorySetupFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryLaboratorySetupCatalog> {
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/laboratory-setups?source_session_id=${encodeURIComponent(sourceSessionId)}`,
|
||||
{ method: "GET", headers: { Accept: "application/json" }, signal },
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const catalog = decodeCatalog(body);
|
||||
if (catalog.sourceSessionId !== sourceSessionId) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Каталог сетапов относится к другой исходной сессии.",
|
||||
);
|
||||
}
|
||||
return catalog;
|
||||
}
|
||||
|
||||
export async function preflightObservatoryLaboratorySetup(
|
||||
sourceSessionId: string,
|
||||
setup: ObservatoryLaboratorySetup,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryLaboratorySetupFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryLaboratoryRunPreflight> {
|
||||
const response = await request(
|
||||
fetcher,
|
||||
"/api/v1/observatory/run-preflights",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json", "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
schema_version: PREFLIGHT_REQUEST_SCHEMA,
|
||||
source_session_id: sourceSessionId,
|
||||
setup_id: setup.setupId,
|
||||
definition_sha256: setup.runDefinition?.definitionSha256 ?? null,
|
||||
}),
|
||||
signal,
|
||||
},
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok) throw apiError(body, response.status);
|
||||
const preflight = decodePreflight(body);
|
||||
if (preflight.sourceSessionId !== sourceSessionId || preflight.setupId !== setup.setupId) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Preflight относится к другому источнику или сетапу.",
|
||||
);
|
||||
}
|
||||
if (preflight.definitionSha256 !== (setup.runDefinition?.definitionSha256 ?? null)) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Preflight относится к другой версии сетапа.",
|
||||
);
|
||||
}
|
||||
return preflight;
|
||||
}
|
||||
|
||||
function decodeCatalog(value: unknown): ObservatoryLaboratorySetupCatalog {
|
||||
const row = record(value, "каталог сетапов");
|
||||
exactKeys(row, ["authority", "schema_version", "setups", "source_session_id"], "каталог сетапов");
|
||||
exact(row.schema_version, CATALOG_SCHEMA, "schema_version каталога сетапов");
|
||||
observationAuthority(row.authority);
|
||||
return {
|
||||
sourceSessionId: text(row.source_session_id, "source_session_id"),
|
||||
setups: array(row.setups, "setups").map(decodeSetup),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeSetup(value: unknown): ObservatoryLaboratorySetup {
|
||||
const row = record(value, "сетап");
|
||||
exactKeys(row, [
|
||||
"authority", "compatibility", "description", "display_name", "executor", "origin",
|
||||
"preflight", "preserved_results", "run_definition", "setup_id", "source",
|
||||
], "сетап");
|
||||
observationAuthority(row.authority);
|
||||
const origin = oneOf(row.origin, ["archived-definition", "existing-result"] as const, "origin");
|
||||
const compatibility = record(row.compatibility, "compatibility");
|
||||
const executor = record(row.executor, "executor");
|
||||
const preflight = record(row.preflight, "preflight");
|
||||
const source = record(row.source, "source");
|
||||
const runDefinition = row.run_definition === null
|
||||
? null
|
||||
: decodeRunDefinition(row.run_definition);
|
||||
if ((origin === "archived-definition") !== (runDefinition !== null)) {
|
||||
throw new ObservatoryLaboratorySetupContractError(
|
||||
"Происхождение сетапа не совпадает с RunDefinition.",
|
||||
);
|
||||
}
|
||||
exactKeys(source, ["label", "required_modalities", "session_id"], "source");
|
||||
text(source.session_id, "source.session_id");
|
||||
text(source.label, "source.label");
|
||||
array(source.required_modalities, "source.required_modalities").forEach((item) => text(item, "source modality"));
|
||||
exactKeys(compatibility, ["compatible", "reasons"], "compatibility");
|
||||
exactKeys(executor, ["contour_id", "reason", "reason_code", "state"], "executor");
|
||||
exactKeys(preflight, ["action", "existing_result_ids", "outcome", "reason", "submission_allowed"], "preflight");
|
||||
return {
|
||||
setupId: text(row.setup_id, "setup_id"),
|
||||
displayName: text(row.display_name, "display_name"),
|
||||
description: text(row.description, "description"),
|
||||
origin,
|
||||
runDefinition,
|
||||
compatibility: {
|
||||
compatible: boolean(row.compatibility && compatibility.compatible, "compatible"),
|
||||
reasons: array(compatibility.reasons, "compatibility.reasons").map((item) => {
|
||||
const reason = record(item, "compatibility reason");
|
||||
exactKeys(reason, ["code", "message"], "compatibility reason");
|
||||
return { code: text(reason.code, "reason.code"), message: text(reason.message, "reason.message") };
|
||||
}),
|
||||
},
|
||||
executor: {
|
||||
contourId: text(executor.contour_id, "executor.contour_id"),
|
||||
state: exact(executor.state, "not-installed", "executor.state"),
|
||||
reasonCode: text(executor.reason_code, "executor.reason_code"),
|
||||
reason: text(executor.reason, "executor.reason"),
|
||||
},
|
||||
preservedResults: array(row.preserved_results, "preserved_results").map(decodePreservedResult),
|
||||
preflight: {
|
||||
outcome: oneOf(preflight.outcome, ["existing", "blocked"] as const, "preflight.outcome"),
|
||||
action: oneOf(preflight.action, ["open-existing", "open-legacy", "blocked"] as const, "preflight.action"),
|
||||
reason: text(preflight.reason, "preflight.reason"),
|
||||
submissionAllowed: exact(preflight.submission_allowed, false, "preflight.submission_allowed"),
|
||||
existingResultIds: array(preflight.existing_result_ids, "preflight.existing_result_ids").map((item) => text(item, "existing result id")),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function decodeRunDefinition(value: unknown): ObservatoryLaboratoryRunDefinition {
|
||||
const row = record(value, "RunDefinition");
|
||||
exactKeys(row, [
|
||||
"authority", "configuration", "definition_id", "definition_sha256", "schema_version",
|
||||
"source", "version", "work_id",
|
||||
], "RunDefinition");
|
||||
exact(row.schema_version, "missioncore.observatory-run-definition/v1", "RunDefinition schema");
|
||||
observationAuthority(row.authority);
|
||||
const source = record(row.source, "RunDefinition source");
|
||||
exactKeys(source, ["label", "required_modalities", "session_id"], "RunDefinition source");
|
||||
text(source.session_id, "RunDefinition source.session_id");
|
||||
text(source.label, "RunDefinition source.label");
|
||||
array(source.required_modalities, "RunDefinition source.required_modalities")
|
||||
.forEach((item) => text(item, "RunDefinition source modality"));
|
||||
const digest = text(row.definition_sha256, "definition_sha256");
|
||||
if (!SHA256.test(digest)) throw new ObservatoryLaboratorySetupContractError("Некорректный digest RunDefinition.");
|
||||
return {
|
||||
definitionId: text(row.definition_id, "definition_id"),
|
||||
version: positiveInteger(row.version, "definition version"),
|
||||
workId: text(row.work_id, "work_id"),
|
||||
definitionSha256: digest,
|
||||
configuration: array(row.configuration, "configuration").map((item) => {
|
||||
const reference = record(item, "configuration reference");
|
||||
exactKeys(reference, ["role", "sha256"], "configuration reference");
|
||||
const sha256 = text(reference.sha256, "configuration sha256");
|
||||
if (!SHA256.test(sha256)) throw new ObservatoryLaboratorySetupContractError("Некорректный digest конфигурации.");
|
||||
return { role: text(reference.role, "configuration role"), sha256 };
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function decodePreservedResult(value: unknown): ObservatoryLaboratoryPreservedResult {
|
||||
const row = record(value, "preserved result");
|
||||
exactKeys(row, [
|
||||
"access", "created_at_utc", "observatory_projection_available", "relation",
|
||||
"result_id", "result_kind",
|
||||
], "preserved result");
|
||||
return {
|
||||
resultId: text(row.result_id, "result_id"),
|
||||
resultKind: text(row.result_kind, "result_kind"),
|
||||
relation: text(row.relation, "relation"),
|
||||
access: oneOf(row.access, ["legacy-lab", "observatory", "evidence-only"] as const, "result access"),
|
||||
createdAtUtc: text(row.created_at_utc, "created_at_utc"),
|
||||
observatoryProjectionAvailable: boolean(row.observatory_projection_available, "observatory projection availability"),
|
||||
};
|
||||
}
|
||||
|
||||
function decodePreflight(value: unknown): ObservatoryLaboratoryRunPreflight {
|
||||
const row = record(value, "preflight");
|
||||
exactKeys(row, [
|
||||
"authority", "checks", "definition_sha256", "executor", "existing_result_ids",
|
||||
"outcome", "schema_version", "setup_id", "source_session_id", "submission_allowed",
|
||||
], "preflight");
|
||||
exact(row.schema_version, PREFLIGHT_SCHEMA, "preflight schema");
|
||||
observationAuthority(row.authority);
|
||||
const digest = row.definition_sha256;
|
||||
if (digest !== null && (typeof digest !== "string" || !SHA256.test(digest))) {
|
||||
throw new ObservatoryLaboratorySetupContractError("Некорректный digest preflight.");
|
||||
}
|
||||
return {
|
||||
sourceSessionId: text(row.source_session_id, "preflight source_session_id"),
|
||||
setupId: text(row.setup_id, "preflight setup_id"),
|
||||
definitionSha256: digest,
|
||||
outcome: oneOf(row.outcome, ["existing", "blocked"] as const, "preflight outcome"),
|
||||
submissionAllowed: exact(row.submission_allowed, false, "preflight submission_allowed"),
|
||||
checks: array(row.checks, "preflight checks").map((item) => {
|
||||
const check = record(item, "preflight check");
|
||||
exactKeys(check, ["check_id", "message", "outcome", "reason_code"], "preflight check");
|
||||
return {
|
||||
checkId: text(check.check_id, "check_id"),
|
||||
outcome: oneOf(check.outcome, ["pass", "fail", "not-applicable"] as const, "check outcome"),
|
||||
reasonCode: text(check.reason_code, "check reason_code"),
|
||||
message: text(check.message, "check message"),
|
||||
};
|
||||
}),
|
||||
existingResultIds: array(row.existing_result_ids, "existing_result_ids").map((item) => text(item, "existing result id")),
|
||||
};
|
||||
}
|
||||
|
||||
function observationAuthority(value: unknown): void {
|
||||
const row = record(value, "authority");
|
||||
const keys = ["commands_enabled", "actuation_allowed", "navigation_or_safety_accepted", "production_accepted"];
|
||||
exactKeys(row, keys, "authority");
|
||||
for (const key of keys) {
|
||||
exact(row[key], false, `authority.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function request(fetcher: ObservatoryLaboratorySetupFetch, input: string, init: RequestInit): Promise<Response> {
|
||||
try {
|
||||
return await fetcher(input, init);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservatoryLaboratorySetupContractError("Каталог сетапов недоступен.");
|
||||
}
|
||||
}
|
||||
|
||||
async function responseBody(response: Response): Promise<unknown> {
|
||||
const textBody = await response.text();
|
||||
if (!textBody) return undefined;
|
||||
try { return JSON.parse(textBody) as unknown; } catch { return textBody; }
|
||||
}
|
||||
|
||||
function apiError(body: unknown, status: number): ObservatoryLaboratorySetupContractError {
|
||||
const detail = body && typeof body === "object" && !Array.isArray(body)
|
||||
? (body as Record<string, unknown>).detail
|
||||
: null;
|
||||
return new ObservatoryLaboratorySetupContractError(
|
||||
typeof detail === "string" && detail.trim() ? detail : `Observatory API вернул HTTP ${status}.`,
|
||||
status,
|
||||
);
|
||||
}
|
||||
|
||||
function record(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался объект.`);
|
||||
}
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function array(value: unknown, label: string): unknown[] {
|
||||
if (!Array.isArray(value)) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался массив.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function text(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || !value.trim()) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался текст.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function boolean(value: unknown, label: string): boolean {
|
||||
if (typeof value !== "boolean") throw new ObservatoryLaboratorySetupContractError(`${label}: ожидался boolean.`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown, label: string): number {
|
||||
if (!Number.isInteger(value) || Number(value) < 1) throw new ObservatoryLaboratorySetupContractError(`${label}: ожидалось положительное целое.`);
|
||||
return Number(value);
|
||||
}
|
||||
|
||||
function exact<T>(value: unknown, expected: T, label: string): T {
|
||||
if (value !== expected) throw new ObservatoryLaboratorySetupContractError(`${label}: значение изменилось.`);
|
||||
return expected;
|
||||
}
|
||||
|
||||
function oneOf<const T extends readonly string[]>(value: unknown, allowed: T, label: string): T[number] {
|
||||
if (typeof value !== "string" || !allowed.includes(value)) {
|
||||
throw new ObservatoryLaboratorySetupContractError(`${label}: значение не поддерживается.`);
|
||||
}
|
||||
return value as T[number];
|
||||
}
|
||||
|
||||
function exactKeys(value: Record<string, unknown>, expected: readonly string[], label: string): void {
|
||||
const actual = Object.keys(value).sort();
|
||||
const sortedExpected = [...expected].sort();
|
||||
if (actual.length !== sortedExpected.length || actual.some((key, index) => key !== sortedExpected[index])) {
|
||||
throw new ObservatoryLaboratorySetupContractError(`${label}: обнаружены неизвестные поля.`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchObservatoryLaboratorySetups,
|
||||
preflightObservatoryLaboratorySetup,
|
||||
type ObservatoryLaboratoryRunPreflight,
|
||||
type ObservatoryLaboratorySetupCatalog,
|
||||
} from "./laboratorySetups";
|
||||
|
||||
type SetupCatalogState = "idle" | "loading" | "ready" | "refreshing" | "error";
|
||||
export type SetupPreflightState =
|
||||
| { readonly kind: "idle" }
|
||||
| { readonly kind: "checking" }
|
||||
| { readonly kind: "ready"; readonly value: ObservatoryLaboratoryRunPreflight }
|
||||
| { readonly kind: "error"; readonly message: string };
|
||||
|
||||
export function useObservatoryLaboratorySetups(sourceSessionId: string) {
|
||||
const [catalog, setCatalog] = useState<ObservatoryLaboratorySetupCatalog | null>(null);
|
||||
const [state, setState] = useState<SetupCatalogState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [selectedSetupId, setSelectedSetupId] = useState("");
|
||||
const [revision, setRevision] = useState(0);
|
||||
const [preflight, setPreflight] = useState<SetupPreflightState>({ kind: "idle" });
|
||||
const requestSequence = useRef(0);
|
||||
const preflightRequest = useRef<AbortController | null>(null);
|
||||
const activeCatalog = catalog?.sourceSessionId === sourceSessionId ? catalog : null;
|
||||
|
||||
useEffect(() => {
|
||||
preflightRequest.current?.abort();
|
||||
preflightRequest.current = null;
|
||||
if (!sourceSessionId) {
|
||||
setCatalog(null);
|
||||
setState("idle");
|
||||
setError(null);
|
||||
setSelectedSetupId("");
|
||||
setPreflight({ kind: "idle" });
|
||||
return;
|
||||
}
|
||||
const sequence = ++requestSequence.current;
|
||||
const request = new AbortController();
|
||||
setState((current) => activeCatalog && current !== "idle" ? "refreshing" : "loading");
|
||||
setError(null);
|
||||
setPreflight({ kind: "idle" });
|
||||
void fetchObservatoryLaboratorySetups(sourceSessionId, { signal: request.signal })
|
||||
.then((next) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
setCatalog(next);
|
||||
setState("ready");
|
||||
setSelectedSetupId((current) => {
|
||||
if (next.setups.some(
|
||||
(setup) => setup.setupId === current && setup.compatibility.compatible,
|
||||
)) return current;
|
||||
return next.setups.find(
|
||||
(setup) => setup.compatibility.compatible && setup.preflight.outcome === "existing",
|
||||
)?.setupId
|
||||
?? next.setups.find((setup) => setup.compatibility.compatible)?.setupId
|
||||
?? next.setups[0]?.setupId
|
||||
?? "";
|
||||
});
|
||||
})
|
||||
.catch((caught: unknown) => {
|
||||
if (request.signal.aborted || requestSequence.current !== sequence) return;
|
||||
setState("error");
|
||||
setError(caught instanceof Error && caught.message.trim()
|
||||
? caught.message
|
||||
: "Каталог сетапов недоступен.");
|
||||
});
|
||||
return () => request.abort();
|
||||
}, [revision, sourceSessionId]);
|
||||
|
||||
useEffect(() => () => preflightRequest.current?.abort(), []);
|
||||
|
||||
const selectedSetup = useMemo(
|
||||
() => activeCatalog?.setups.find((setup) => setup.setupId === selectedSetupId) ?? null,
|
||||
[activeCatalog, selectedSetupId],
|
||||
);
|
||||
|
||||
const selectSetup = useCallback((setupId: string) => {
|
||||
preflightRequest.current?.abort();
|
||||
preflightRequest.current = null;
|
||||
setSelectedSetupId(setupId);
|
||||
setPreflight({ kind: "idle" });
|
||||
}, []);
|
||||
|
||||
const refresh = useCallback(() => setRevision((value) => value + 1), []);
|
||||
|
||||
const check = useCallback(async () => {
|
||||
if (!sourceSessionId || !selectedSetup || preflight.kind === "checking") return;
|
||||
preflightRequest.current?.abort();
|
||||
const request = new AbortController();
|
||||
preflightRequest.current = request;
|
||||
setPreflight({ kind: "checking" });
|
||||
try {
|
||||
const value = await preflightObservatoryLaboratorySetup(
|
||||
sourceSessionId,
|
||||
selectedSetup,
|
||||
{ signal: request.signal },
|
||||
);
|
||||
if (request.signal.aborted || preflightRequest.current !== request) return;
|
||||
setPreflight({ kind: "ready", value });
|
||||
} catch (caught) {
|
||||
if (request.signal.aborted || preflightRequest.current !== request) return;
|
||||
setPreflight({
|
||||
kind: "error",
|
||||
message: caught instanceof Error && caught.message.trim()
|
||||
? caught.message
|
||||
: "Не удалось проверить возможность запуска.",
|
||||
});
|
||||
} finally {
|
||||
if (preflightRequest.current === request) preflightRequest.current = null;
|
||||
}
|
||||
}, [preflight.kind, selectedSetup, sourceSessionId]);
|
||||
|
||||
return {
|
||||
catalog: activeCatalog,
|
||||
state,
|
||||
error,
|
||||
selectedSetupId,
|
||||
selectedSetup,
|
||||
selectSetup,
|
||||
refresh,
|
||||
preflight,
|
||||
check,
|
||||
};
|
||||
}
|
||||
|
||||
export type ObservatoryLaboratorySetupsController = ReturnType<
|
||||
typeof useObservatoryLaboratorySetups
|
||||
>;
|
||||
Reference in New Issue
Block a user