fix(observatory): refine catalog and replay UX
This commit is contained in:
@@ -35,6 +35,27 @@ export interface ObservatoryCatalog {
|
||||
};
|
||||
}
|
||||
|
||||
export type ObservatoryCatalogMutation =
|
||||
| {
|
||||
readonly kind: "rename";
|
||||
readonly displayName: string;
|
||||
readonly revision: number;
|
||||
}
|
||||
| {
|
||||
readonly kind: "delete";
|
||||
readonly revision: number;
|
||||
};
|
||||
|
||||
export type ObservatoryCatalogMutationOverlay = ReadonlyMap<
|
||||
string,
|
||||
ObservatoryCatalogMutation
|
||||
>;
|
||||
|
||||
export interface ObservatoryCatalogReconciliation {
|
||||
readonly catalog: ObservatoryCatalog;
|
||||
readonly overlay: ObservatoryCatalogMutationOverlay;
|
||||
}
|
||||
|
||||
export class ObservatoryCatalogContractError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
@@ -42,6 +63,95 @@ export class ObservatoryCatalogContractError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function findObservatoryEvidence(
|
||||
catalog: ObservatoryCatalog,
|
||||
sessionId: string,
|
||||
): ObservatoryEvidence | null {
|
||||
for (const item of catalog.items) {
|
||||
const evidence = item.evidence.find(
|
||||
(candidate) => candidate.sessionId === sessionId,
|
||||
);
|
||||
if (evidence) return evidence;
|
||||
}
|
||||
return catalog.unresolvedEvidence.find(
|
||||
(candidate) => candidate.sessionId === sessionId,
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export function observatoryCatalogConfirmsEvidenceDeletion(
|
||||
catalog: ObservatoryCatalog,
|
||||
sessionId: string,
|
||||
): boolean {
|
||||
return !catalog.window.laboratoryLimitReached
|
||||
&& findObservatoryEvidence(catalog, sessionId) === null;
|
||||
}
|
||||
|
||||
export function applyObservatoryCatalogMutationOverlay(
|
||||
catalog: ObservatoryCatalog,
|
||||
overlay: ObservatoryCatalogMutationOverlay,
|
||||
): ObservatoryCatalog {
|
||||
if (overlay.size === 0) return catalog;
|
||||
|
||||
const removedSessionIds = new Set<string>();
|
||||
const projectEvidence = (
|
||||
evidence: ObservatoryEvidence,
|
||||
): ObservatoryEvidence | null => {
|
||||
const mutation = overlay.get(evidence.sessionId);
|
||||
if (!mutation) return evidence;
|
||||
if (mutation.kind === "delete") {
|
||||
removedSessionIds.add(evidence.sessionId);
|
||||
return null;
|
||||
}
|
||||
return evidence.label === mutation.displayName
|
||||
? evidence
|
||||
: { ...evidence, label: mutation.displayName };
|
||||
};
|
||||
const projectEvidenceList = (
|
||||
evidence: readonly ObservatoryEvidence[],
|
||||
): ObservatoryEvidence[] => evidence.flatMap((candidate) => {
|
||||
const projected = projectEvidence(candidate);
|
||||
return projected ? [projected] : [];
|
||||
});
|
||||
|
||||
const items = catalog.items.map((item) => ({
|
||||
...item,
|
||||
evidence: projectEvidenceList(item.evidence),
|
||||
}));
|
||||
const unresolvedEvidence = projectEvidenceList(catalog.unresolvedEvidence);
|
||||
return {
|
||||
...catalog,
|
||||
items,
|
||||
unresolvedEvidence,
|
||||
window: {
|
||||
...catalog.window,
|
||||
laboratoryCount: Math.max(
|
||||
0,
|
||||
catalog.window.laboratoryCount - removedSessionIds.size,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function reconcileObservatoryCatalogMutationOverlay(
|
||||
serverCatalog: ObservatoryCatalog,
|
||||
overlay: ObservatoryCatalogMutationOverlay,
|
||||
requestMutationRevision: number,
|
||||
): ObservatoryCatalogReconciliation {
|
||||
const remaining = new Map(overlay);
|
||||
for (const [sessionId, mutation] of overlay) {
|
||||
if (mutation.revision > requestMutationRevision) continue;
|
||||
const evidence = findObservatoryEvidence(serverCatalog, sessionId);
|
||||
const confirmed = mutation.kind === "rename"
|
||||
? evidence?.label === mutation.displayName
|
||||
: observatoryCatalogConfirmsEvidenceDeletion(serverCatalog, sessionId);
|
||||
if (confirmed) remaining.delete(sessionId);
|
||||
}
|
||||
return {
|
||||
catalog: applyObservatoryCatalogMutationOverlay(serverCatalog, remaining),
|
||||
overlay: remaining,
|
||||
};
|
||||
}
|
||||
|
||||
function newestFirst(left: string, right: string): number {
|
||||
return Date.parse(right) - Date.parse(left);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
import type { ObservatoryRecordedRunBinding } from "./recordedRun";
|
||||
|
||||
const SAFE_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const RENAME_SCHEMA = "missioncore.observatory-lab-projection-rename/v1";
|
||||
const PROJECTION_SCHEMA = "missioncore.observatory-lab-projection/v1";
|
||||
|
||||
export type ObservatoryCatalogMutationFetch = (
|
||||
input: RequestInfo | URL,
|
||||
init?: RequestInit,
|
||||
) => Promise<Response>;
|
||||
|
||||
export interface ObservatoryLabProjectionMutationResult {
|
||||
readonly schemaVersion: typeof PROJECTION_SCHEMA;
|
||||
readonly sessionId: string;
|
||||
readonly displayName: string;
|
||||
}
|
||||
|
||||
export class ObservatoryCatalogMutationError extends Error {
|
||||
readonly status: number | null;
|
||||
|
||||
constructor(message: string, status: number | null = null) {
|
||||
super(message);
|
||||
this.name = "ObservatoryCatalogMutationError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameObservatoryLabProjection(
|
||||
binding: ObservatoryRecordedRunBinding,
|
||||
displayName: string,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryCatalogMutationFetch;
|
||||
} = {},
|
||||
): Promise<ObservatoryLabProjectionMutationResult> {
|
||||
const sessionId = admittedProjectionId(binding);
|
||||
const normalizedName = displayName.trim();
|
||||
if (normalizedName.length < 1 || normalizedName.length > 160) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Название лабораторного результата должно содержать от 1 до 160 символов.",
|
||||
);
|
||||
}
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
schema_version: RENAME_SCHEMA,
|
||||
display_name: normalizedName,
|
||||
}),
|
||||
signal,
|
||||
},
|
||||
"Не удалось переименовать лабораторный результат.",
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok || response.status !== 200) {
|
||||
throw apiError(
|
||||
body,
|
||||
`Переименование лабораторного результата вернуло HTTP ${response.status}.`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
const decoded = decodeProjection(body);
|
||||
if (decoded.sessionId !== sessionId || decoded.displayName !== normalizedName) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Сервер не подтвердил точное переименование выбранного результата.",
|
||||
);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export async function deleteObservatoryLabProjection(
|
||||
binding: ObservatoryRecordedRunBinding,
|
||||
{
|
||||
signal,
|
||||
fetcher = globalThis.fetch,
|
||||
}: {
|
||||
signal?: AbortSignal;
|
||||
fetcher?: ObservatoryCatalogMutationFetch;
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
const sessionId = admittedProjectionId(binding);
|
||||
const response = await request(
|
||||
fetcher,
|
||||
`/api/v1/observatory/lab-projections/${encodeURIComponent(sessionId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Accept: "application/json" },
|
||||
signal,
|
||||
},
|
||||
"Не удалось удалить лабораторный результат из Обсерватории.",
|
||||
);
|
||||
const body = await responseBody(response);
|
||||
if (!response.ok || response.status !== 204) {
|
||||
throw apiError(
|
||||
body,
|
||||
`Удаление лабораторного результата вернуло HTTP ${response.status}.`,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
if (body !== undefined) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Сервер вернул данные после подтверждённого удаления лабораторного результата.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function admittedProjectionId(binding: ObservatoryRecordedRunBinding): string {
|
||||
const sessionId = binding.evidenceSessionId;
|
||||
if (
|
||||
binding.kind !== "canonical-recorded-rerun"
|
||||
|| binding.activation !== "explicit"
|
||||
|| binding.viewerProfile !== "recorded-session"
|
||||
|| binding.timeline !== "session_time"
|
||||
|| binding.resultId !== sessionId
|
||||
|| binding.sourceSessionId === sessionId
|
||||
|| !SAFE_SESSION_ID.test(sessionId)
|
||||
) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Выбранный результат не допущен к изменению каталога Обсерватории.",
|
||||
);
|
||||
}
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
async function request(
|
||||
fetcher: ObservatoryCatalogMutationFetch,
|
||||
input: string,
|
||||
init: RequestInit,
|
||||
fallback: string,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
return await fetcher(input, init);
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === "AbortError") throw error;
|
||||
throw new ObservatoryCatalogMutationError(fallback);
|
||||
}
|
||||
}
|
||||
|
||||
async function responseBody(response: Response): Promise<unknown> {
|
||||
const text = await response.text();
|
||||
if (!text) return undefined;
|
||||
try {
|
||||
return JSON.parse(text) as unknown;
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
function apiError(body: unknown, fallback: string, status: number): Error {
|
||||
if (isRecord(body) && typeof body.detail === "string" && body.detail.trim()) {
|
||||
return new ObservatoryCatalogMutationError(body.detail.trim(), status);
|
||||
}
|
||||
if (typeof body === "string" && body.trim()) {
|
||||
return new ObservatoryCatalogMutationError(body.trim(), status);
|
||||
}
|
||||
return new ObservatoryCatalogMutationError(fallback, status);
|
||||
}
|
||||
|
||||
function decodeProjection(value: unknown): ObservatoryLabProjectionMutationResult {
|
||||
if (!isRecord(value)) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Сервер вернул некорректное подтверждение лабораторного результата.",
|
||||
);
|
||||
}
|
||||
const keys = Object.keys(value).sort();
|
||||
const expected = ["display_name", "schema_version", "session_id"];
|
||||
if (keys.length !== expected.length || keys.some((key, index) => key !== expected[index])) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Подтверждение лабораторного результата содержит неизвестные поля.",
|
||||
);
|
||||
}
|
||||
if (
|
||||
value.schema_version !== PROJECTION_SCHEMA
|
||||
|| typeof value.session_id !== "string"
|
||||
|| !SAFE_SESSION_ID.test(value.session_id)
|
||||
|| typeof value.display_name !== "string"
|
||||
|| value.display_name.trim() !== value.display_name
|
||||
|| value.display_name.length < 1
|
||||
|| value.display_name.length > 160
|
||||
) {
|
||||
throw new ObservatoryCatalogMutationError(
|
||||
"Сервер вернул неподдерживаемый контракт лабораторного результата.",
|
||||
);
|
||||
}
|
||||
return {
|
||||
schemaVersion: PROJECTION_SCHEMA,
|
||||
sessionId: value.session_id,
|
||||
displayName: value.display_name,
|
||||
};
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
applyObservatoryCatalogMutationOverlay,
|
||||
fetchObservatoryCatalog,
|
||||
reconcileObservatoryCatalogMutationOverlay,
|
||||
type ObservatoryCatalog,
|
||||
type ObservatoryCatalogMutationOverlay,
|
||||
} from "./catalog";
|
||||
|
||||
export type ObservatoryCatalogState =
|
||||
@@ -16,9 +19,23 @@ export interface ObservatoryCatalogController {
|
||||
readonly catalog: ObservatoryCatalog | null;
|
||||
readonly state: ObservatoryCatalogState;
|
||||
readonly error: string | null;
|
||||
readonly refresh: () => void;
|
||||
readonly refresh: () => Promise<ObservatoryCatalog | null>;
|
||||
readonly applyEvidenceRename: (
|
||||
sessionId: string,
|
||||
displayName: string,
|
||||
) => void;
|
||||
readonly applyEvidenceDeletion: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
interface ActiveCatalogRequest {
|
||||
readonly id: number;
|
||||
readonly controller: AbortController;
|
||||
}
|
||||
|
||||
type ObservatoryCatalogMutationDraft =
|
||||
| { readonly kind: "rename"; readonly displayName: string }
|
||||
| { readonly kind: "delete" };
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message.trim()
|
||||
? error.message
|
||||
@@ -33,31 +50,99 @@ export function useObservatoryCatalog(): ObservatoryCatalogController {
|
||||
const [catalog, setCatalog] = useState<ObservatoryCatalog | null>(null);
|
||||
const [state, setState] = useState<ObservatoryCatalogState>("idle");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
const catalogRef = useRef<ObservatoryCatalog | null>(null);
|
||||
const overlayRef = useRef<ObservatoryCatalogMutationOverlay>(new Map());
|
||||
const mutationRevisionRef = useRef(0);
|
||||
const requestSequenceRef = useRef(0);
|
||||
const activeRequestRef = useRef<ActiveCatalogRequest | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const loadCatalog = useCallback(async (): Promise<ObservatoryCatalog | null> => {
|
||||
const id = requestSequenceRef.current + 1;
|
||||
requestSequenceRef.current = id;
|
||||
activeRequestRef.current?.controller.abort();
|
||||
const controller = new AbortController();
|
||||
activeRequestRef.current = { id, controller };
|
||||
const requestMutationRevision = mutationRevisionRef.current;
|
||||
|
||||
setState(catalogRef.current ? "refreshing" : "loading");
|
||||
setError(null);
|
||||
void fetchObservatoryCatalog({ signal: controller.signal })
|
||||
.then((next) => {
|
||||
if (controller.signal.aborted) return;
|
||||
catalogRef.current = next;
|
||||
setCatalog(next);
|
||||
setState("ready");
|
||||
})
|
||||
.catch((loadError: unknown) => {
|
||||
if (controller.signal.aborted || isAbortError(loadError)) return;
|
||||
setError(errorMessage(loadError));
|
||||
setState("error");
|
||||
try {
|
||||
const serverCatalog = await fetchObservatoryCatalog({
|
||||
signal: controller.signal,
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [generation]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
setGeneration((current) => current + 1);
|
||||
if (controller.signal.aborted || requestSequenceRef.current !== id) {
|
||||
return null;
|
||||
}
|
||||
const reconciled = reconcileObservatoryCatalogMutationOverlay(
|
||||
serverCatalog,
|
||||
overlayRef.current,
|
||||
requestMutationRevision,
|
||||
);
|
||||
overlayRef.current = reconciled.overlay;
|
||||
catalogRef.current = reconciled.catalog;
|
||||
activeRequestRef.current = null;
|
||||
setCatalog(reconciled.catalog);
|
||||
setState("ready");
|
||||
return reconciled.catalog;
|
||||
} catch (loadError: unknown) {
|
||||
if (
|
||||
controller.signal.aborted
|
||||
|| requestSequenceRef.current !== id
|
||||
|| isAbortError(loadError)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
activeRequestRef.current = null;
|
||||
setError(errorMessage(loadError));
|
||||
setState("error");
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { catalog, state, error, refresh };
|
||||
useEffect(() => {
|
||||
void loadCatalog();
|
||||
return () => {
|
||||
requestSequenceRef.current += 1;
|
||||
activeRequestRef.current?.controller.abort();
|
||||
activeRequestRef.current = null;
|
||||
};
|
||||
}, [loadCatalog]);
|
||||
|
||||
const applyMutation = useCallback((
|
||||
sessionId: string,
|
||||
mutation: ObservatoryCatalogMutationDraft,
|
||||
) => {
|
||||
const revision = mutationRevisionRef.current + 1;
|
||||
mutationRevisionRef.current = revision;
|
||||
const overlay = new Map(overlayRef.current);
|
||||
overlay.set(sessionId, { ...mutation, revision });
|
||||
overlayRef.current = overlay;
|
||||
if (!catalogRef.current) return;
|
||||
const projected = applyObservatoryCatalogMutationOverlay(
|
||||
catalogRef.current,
|
||||
overlay,
|
||||
);
|
||||
catalogRef.current = projected;
|
||||
setCatalog(projected);
|
||||
}, []);
|
||||
|
||||
const applyEvidenceRename = useCallback((
|
||||
sessionId: string,
|
||||
displayName: string,
|
||||
) => {
|
||||
applyMutation(sessionId, { kind: "rename", displayName });
|
||||
}, [applyMutation]);
|
||||
|
||||
const applyEvidenceDeletion = useCallback((sessionId: string) => {
|
||||
applyMutation(sessionId, { kind: "delete" });
|
||||
}, [applyMutation]);
|
||||
|
||||
return {
|
||||
catalog,
|
||||
state,
|
||||
error,
|
||||
refresh: loadCatalog,
|
||||
applyEvidenceRename,
|
||||
applyEvidenceDeletion,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user