feat(perception): publish immutable lab session instances
This commit is contained in:
@@ -208,7 +208,15 @@ export function ObservationSessionSelect({
|
||||
<i data-session-visual-state={visualState} aria-hidden="true" />
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__body">
|
||||
<span className="nodedc-dropdown-option__label">{session.label}</span>
|
||||
<span className="nodedc-dropdown-option__label">
|
||||
{session.lab &&
|
||||
!session.label.startsWith(`${session.lab.labId} ·`) ? (
|
||||
<span className="observation-session-option__lab">
|
||||
{session.lab.labId}
|
||||
</span>
|
||||
) : null}
|
||||
{session.label}
|
||||
</span>
|
||||
<span className="nodedc-dropdown-option__description">
|
||||
{sessionDescription(session)}
|
||||
</span>
|
||||
@@ -263,10 +271,18 @@ export function ObservationSessionSelect({
|
||||
title="Удалить сохранённую сессию?"
|
||||
description={deleteTarget ? <>
|
||||
<strong>{deleteTarget.label}</strong>
|
||||
<p>
|
||||
Сессия, исходные данные наблюдения, подготовленная Rerun-запись и
|
||||
видеоматериалы будут удалены с этого сервера без возможности восстановления.
|
||||
</p>
|
||||
{deleteTarget.lab ? (
|
||||
<p>
|
||||
Будут удалены только LAB-запись из каталога и её подготовленный кэш.
|
||||
Исходная запись {deleteTarget.lab.sourceSessionId} и зафиксированный
|
||||
лабораторный результат останутся неизменными.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
Сессия, исходные данные наблюдения, подготовленная Rerun-запись и
|
||||
видеоматериалы будут удалены с этого сервера без возможности восстановления.
|
||||
</p>
|
||||
)}
|
||||
{sessions.error && sessions.deletingSessionId === null ? (
|
||||
<p className="observation-session-delete-error" role="alert">{sessions.error}</p>
|
||||
) : null}
|
||||
|
||||
@@ -9,6 +9,18 @@ export type ObservationSessionStatus =
|
||||
| "interrupted"
|
||||
| "failed";
|
||||
|
||||
export interface ObservationLabInstance {
|
||||
labId: string;
|
||||
sourceSessionId: string;
|
||||
resultKind: string;
|
||||
resultId: string;
|
||||
sourceResultId: string | null;
|
||||
configSha256: string | null;
|
||||
runCreatedAtUtc: string;
|
||||
publishedAtUtc: string;
|
||||
provenance: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
export interface ObservationSessionSummary {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -19,6 +31,7 @@ export interface ObservationSessionSummary {
|
||||
durationSeconds: number;
|
||||
replayable: boolean;
|
||||
preparation: ObservationSessionCatalogPreparation | null;
|
||||
lab: ObservationLabInstance | null;
|
||||
}
|
||||
|
||||
export interface ObservationSessionCatalog {
|
||||
@@ -135,6 +148,18 @@ const ITEM_KEYS = new Set([
|
||||
"duration_seconds",
|
||||
"replayable",
|
||||
"preparation",
|
||||
"lab",
|
||||
]);
|
||||
const LAB_KEYS = new Set([
|
||||
"lab_id",
|
||||
"source_session_id",
|
||||
"result_kind",
|
||||
"result_id",
|
||||
"source_result_id",
|
||||
"config_sha256",
|
||||
"run_created_at_utc",
|
||||
"published_at_utc",
|
||||
"provenance",
|
||||
]);
|
||||
const CATALOG_PREPARATION_KEYS = new Set([
|
||||
"preparation_id",
|
||||
@@ -344,6 +369,72 @@ function decodeItem(value: unknown, index: number): ObservationSessionSummary {
|
||||
durationSeconds: value.duration_seconds,
|
||||
replayable: value.replayable,
|
||||
preparation: decodeCatalogPreparation(value.preparation, id),
|
||||
lab: decodeLabInstance(value.lab, id),
|
||||
};
|
||||
}
|
||||
|
||||
function decodeLabInstance(
|
||||
value: unknown,
|
||||
sessionId: string,
|
||||
): ObservationLabInstance | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (!isRecord(value)) {
|
||||
throw new ObservationSessionContractError(
|
||||
`LAB-привязка сессии ${sessionId} должна быть объектом или null.`,
|
||||
);
|
||||
}
|
||||
assertExactKeys(value, LAB_KEYS, `LAB-привязка сессии ${sessionId}`);
|
||||
const labId = requireString(value.lab_id, `lab(${sessionId}).lab_id`, 36);
|
||||
if (!/^LAB [A-Z][A-Z0-9._-]{0,31}$/.test(labId)) {
|
||||
throw new ObservationSessionContractError("Каталог содержит некорректный LAB-маркер.");
|
||||
}
|
||||
const sourceSessionId = requireString(
|
||||
value.source_session_id,
|
||||
`lab(${sessionId}).source_session_id`,
|
||||
128,
|
||||
);
|
||||
const resultKind = requireString(value.result_kind, `lab(${sessionId}).result_kind`, 128);
|
||||
const resultId = requireString(value.result_id, `lab(${sessionId}).result_id`, 128);
|
||||
const sourceResultId = value.source_result_id === null
|
||||
? null
|
||||
: requireString(value.source_result_id, `lab(${sessionId}).source_result_id`, 128);
|
||||
for (const [field, entry] of [
|
||||
["source_session_id", sourceSessionId],
|
||||
["result_kind", resultKind],
|
||||
["result_id", resultId],
|
||||
["source_result_id", sourceResultId],
|
||||
] as const) {
|
||||
if (entry !== null && !SAFE_ID.test(entry)) {
|
||||
throw new ObservationSessionContractError(
|
||||
`Поле lab.${field} содержит небезопасный идентификатор.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const configSha256 = value.config_sha256 === null
|
||||
? null
|
||||
: requireString(value.config_sha256, `lab(${sessionId}).config_sha256`, 64);
|
||||
if (configSha256 !== null && !SHA256.test(configSha256)) {
|
||||
throw new ObservationSessionContractError("LAB-конфигурация не содержит SHA-256.");
|
||||
}
|
||||
if (!isRecord(value.provenance)) {
|
||||
throw new ObservationSessionContractError("LAB provenance должен быть JSON-объектом.");
|
||||
}
|
||||
return {
|
||||
labId,
|
||||
sourceSessionId,
|
||||
resultKind,
|
||||
resultId,
|
||||
sourceResultId,
|
||||
configSha256,
|
||||
runCreatedAtUtc: requireUtcTimestamp(
|
||||
value.run_created_at_utc,
|
||||
`lab(${sessionId}).run_created_at_utc`,
|
||||
),
|
||||
publishedAtUtc: requireUtcTimestamp(
|
||||
value.published_at_utc,
|
||||
`lab(${sessionId}).published_at_utc`,
|
||||
),
|
||||
provenance: value.provenance,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -201,6 +201,22 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.observation-session-option__lab {
|
||||
align-items: center;
|
||||
background: color-mix(in srgb, var(--accent, #b8ff5a) 14%, transparent);
|
||||
border: 1px solid color-mix(in srgb, var(--accent, #b8ff5a) 42%, transparent);
|
||||
border-radius: 999px;
|
||||
color: var(--accent, #b8ff5a);
|
||||
display: inline-flex;
|
||||
font-size: 9px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1;
|
||||
margin-right: 7px;
|
||||
padding: 4px 6px 3px;
|
||||
vertical-align: 1px;
|
||||
}
|
||||
|
||||
.observation-session-menu__empty {
|
||||
display: grid;
|
||||
min-height: 9rem;
|
||||
|
||||
Reference in New Issue
Block a user