feat(frontend): improve investigation analyst UX

This commit is contained in:
2026-09-24 11:09:41 +03:00
parent 870df0ac35
commit 9acd9badab
7 changed files with 158 additions and 32 deletions

View File

@@ -8,6 +8,7 @@
<!-- @UX_STATE error -> recovery message with retry action -->
<!-- @UX_RECOVERY 409 -> persistent conflict panel; 403 -> permission note; retry reloads case -->
<!-- @UX_FEEDBACK Disposition success updates status/version inline (CAS response bumps decision_version locally) -->
<!-- @UX_TEST: Loaded -> evidence cards expose type/access state while refs remain in collapsed technical details. -->
<!-- @INVARIANT Disposition is inline card — never a modal/dialog (SCAN-FR-011). -->
<script lang="ts">
import { t } from "$lib/i18n/index.svelte.js";
@@ -67,6 +68,13 @@
}
void model.dispose("accepted", rationale);
}
function caseSummary(): string {
if (model.status === "resolved") return dt.case_summary_resolved;
if (model.status === "accepted") return dt.case_summary_accepted;
if (model.status === "investigating" || model.status === "verifying") return dt.case_summary_in_progress;
return dt.case_summary_open;
}
</script>
<section class="rounded-lg border border-border bg-surface-card p-4" aria-label={dt.case_workspace_aria}>
@@ -81,16 +89,28 @@
</div>
{#if model.case}
<div class="mt-4 rounded border border-warning/30 bg-warning-light p-4" aria-label={dt.case_summary_aria}>
<h3 class="text-sm font-semibold text-text">{dt.case_summary_title}</h3>
<p class="mt-1 text-sm text-text">{caseSummary()}</p>
<dl class="mt-3 grid gap-2 text-sm sm:grid-cols-3">
<div><dt class="text-xs text-text-subtle">{dt.case_summary_scenario}</dt><dd class="text-text">{model.case.scenario_id}</dd></div>
<div><dt class="text-xs text-text-subtle">{dt.case_summary_when}</dt><dd class="text-text">{model.case.created_at ?? "—"}</dd></div>
<div><dt class="text-xs text-text-subtle">{dt.case_summary_next}</dt><dd class="text-text">{isTerminal ? dt.case_summary_no_action : dt.case_summary_review}</dd></div>
</dl>
</div>
<dl class="mt-3 grid gap-2 text-sm sm:grid-cols-2">
<div>
<dt class="text-xs text-text-subtle">{dt.fingerprint}</dt>
<dd class="break-all font-mono text-xs text-text">{model.case.fingerprint}</dd>
</div>
<div>
<dt class="text-xs text-text-subtle">{dt.decision_version}</dt>
<dd class="text-text">{model.decisionVersion}</dd>
</div>
</dl>
<details class="mt-3 rounded border border-border p-3 text-sm">
<summary class="cursor-pointer font-medium text-text">{dt.case_technical_details}</summary>
<dl class="mt-2 space-y-1 text-xs text-text-muted">
<div><dt class="inline">{dt.fingerprint}:</dt> <dd class="inline break-all font-mono">{model.case.fingerprint}</dd></div>
<div><dt class="inline">{dt.case_id}:</dt> <dd class="inline break-all font-mono">{model.case.id}</dd></div>
</dl>
</details>
{@const linkedRuns = model.case.linked_run_ids ?? model.linkedRunIds ?? []}
{@const evidenceRefs = model.case.evidence_refs ?? model.evidenceRefs ?? []}
{@const showRunsBlock = linkedRuns.length > 0}
@@ -109,15 +129,17 @@
{#if showEvidenceBlock}
<div>
<h3 class="text-xs font-medium text-text-subtle">{dt.case_evidence_refs}</h3>
<ul class="mt-1 space-y-1">
<ul class="mt-1 space-y-2">
{#each evidenceRefs as ref}
<li class="break-all text-xs text-text-muted">
{ref.kind}: {ref.content_ref} ({ref.sha256})
<li class="rounded border border-border bg-surface p-2 text-xs text-text-muted">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="font-medium text-text">{ref.kind ?? dt.case_evidence_type}</span>
<span>{ref.run_id && ref.artifact_id ? dt.case_evidence_available : dt.case_evidence_unavailable}</span>
</div>
{#if ref.run_id && ref.artifact_id}
<button class="ml-2 text-primary underline" onclick={() => { previewEvidenceIndex = evidenceRefs.indexOf(ref); }}>{dt.case_open_evidence}</button>
{:else}
<span class="ml-2">{dt.case_evidence_unavailable}</span>
{/if}
<details class="mt-1"><summary class="cursor-pointer">{dt.case_evidence_technical}</summary><p class="mt-1 break-all font-mono">{ref.content_ref ?? "—"} · {ref.sha256 ?? "—"}</p></details>
</li>
{/each}
</ul>

View File

@@ -5,6 +5,7 @@
<!-- @UX_STATE loaded -> severity/source/recurrence rows with an "open investigation" action -->
<!-- @UX_STATE empty -> explicit empty state (no agent started) -->
<!-- @UX_STATE error -> recovery message with retry action -->
<!-- @UX_TEST: Loaded -> technical details remain collapsed until the analyst opens the disclosure. -->
<!-- @INVARIANT An in_case row exposes its durable case_id; the action is disabled when the server -->
<!-- did not provide one, so the analyst can never navigate to a phantom case. -->
<script lang="ts">
@@ -36,6 +37,16 @@
if (severity === "warning") return "bg-warning-light text-warning";
return "bg-surface-muted text-text-muted";
}
function severityLabel(severity: string): string {
if (severity === "critical") return dt.severity_critical;
if (severity === "warning") return dt.severity_warning;
return dt.severity_info;
}
function signalLabel(signalType: string | null | undefined): string {
return (signalType || dt.queue_signal_unknown).replaceAll("_", " ");
}
</script>
<section class="rounded-lg border border-border bg-surface-card p-4" aria-label={dt.queue_aria}>
@@ -56,28 +67,43 @@
{dt.queue_empty}
</p>
{:else}
<ul class="mt-4 space-y-2">
{#each items as item (item.id)}
<li class="flex items-center justify-between gap-3 rounded border border-border bg-surface p-3">
<div class="min-w-0">
<div class="flex items-center gap-2">
<span class="rounded px-2 py-0.5 text-xs font-medium {severityClass(item.severity)}">{item.severity}</span>
<span class="truncate text-sm font-medium text-text">{item.title}</span>
<span class="rounded px-2 py-0.5 text-xs text-text-muted">{item.status === "in_case" ? dt.queue_in_case : item.status}</span>
</div>
<p class="mt-1 text-xs text-text-muted">
{item.scenario_name || item.scenario_id} · {item.signal_type} · {dt.queue_occurrences}: {item.occurrence_count}{item.run_id ? ` · ${item.run_id}` : ""}
</p>
<p class="mt-1 text-xs text-text-subtle">{item.step_id ?? "—"}{item.error_code ? ` · ${item.error_code}` : ""}{item.environment_id ? ` · ${item.environment_id}` : ""}</p>
</div>
<button
class="shrink-0 rounded bg-primary px-3 py-2 text-sm text-white"
disabled={item.status === "in_case" && !item.case_id}
onclick={() => item.status === "in_case" ? onCase?.(item) : onOpen(item)}
>{item.status === "in_case" ? dt.queue_open_case : dt.queue_investigate}</button>
</li>
{/each}
</ul>
{@const needsInvestigation = items.filter((item) => item.status === "queued")}
{@const inProgress = items.filter((item) => item.status === "in_case")}
{#each [{ key: "needs", label: dt.queue_needs_investigation, rows: needsInvestigation }, { key: "progress", label: dt.queue_in_progress, rows: inProgress }] as group (group.key)}
{#if group.rows.length > 0}
<div class="mt-4">
<h3 class="text-xs font-semibold uppercase tracking-wide text-text-subtle">{group.label} ({group.rows.length})</h3>
<ul class="mt-2 space-y-2">
{#each group.rows as item (item.id)}
<li class="flex items-start justify-between gap-3 rounded border border-border bg-surface p-3">
<div class="min-w-0">
<div class="flex flex-wrap items-center gap-2">
<span class="rounded px-2 py-0.5 text-xs font-medium {severityClass(item.severity)}">{severityLabel(item.severity)}</span>
<span class="text-sm font-medium text-text">{item.title}</span>
<span class="rounded px-2 py-0.5 text-xs text-text-muted">{item.status === "in_case" ? dt.queue_in_case : dt.queue_unreviewed}</span>
</div>
<p class="mt-1 text-sm text-text-muted">{item.scenario_name || item.scenario_id} · {signalLabel(item.signal_type)}</p>
<p class="mt-1 text-xs text-text-subtle">{dt.queue_occurrences}: {item.occurrence_count}{item.step_id ? ` · ${dt.queue_step}: ${item.step_id}` : ""}{item.environment_id ? ` · ${dt.queue_environment}: ${item.environment_id}` : ""}</p>
<details class="mt-2 text-xs text-text-subtle">
<summary class="cursor-pointer">{dt.queue_technical_details}</summary>
<dl class="mt-2 space-y-1 font-mono">
<div><dt class="inline font-sans">{dt.queue_run}:</dt> <dd class="inline break-all">{item.run_id ?? "—"}</dd></div>
<div><dt class="inline font-sans">{dt.queue_error_code}:</dt> <dd class="inline break-all">{item.error_code ?? "—"}</dd></div>
<div><dt class="inline font-sans">{dt.queue_fingerprint}:</dt> <dd class="inline break-all">{item.fingerprint}</dd></div>
</dl>
</details>
</div>
<button
class="shrink-0 rounded bg-primary px-3 py-2 text-sm text-white"
disabled={item.status === "in_case" && !item.case_id}
onclick={() => item.status === "in_case" ? onCase?.(item) : onOpen(item)}
>{item.status === "in_case" ? dt.queue_open_case : dt.queue_investigate}</button>
</li>
{/each}
</ul>
</div>
{/if}
{/each}
{/if}
</section>
<!-- #endregion ScenarioAnalytics.Component.QueueList -->
<!-- #endregion ScenarioAnalytics.Component.QueueList -->

View File

@@ -47,6 +47,11 @@ describe("CaseWorkspace", () => {
expect(screen.getByText("Аудит действий кейса")).toBeTruthy();
expect(screen.getByText("Кейс открыт")).toBeTruthy();
expect(screen.getByRole("button", { name: "Закрыть кейс" })).toBeTruthy();
expect(screen.getByText("Что произошло")).toBeTruthy();
expect(screen.getByText("Сигнал требует расследования и подтверждения аналитика.")).toBeTruthy();
const technicalDetails = screen.getByText("Технические данные и идентификаторы").closest("details");
expect(technicalDetails).toBeTruthy();
expect(technicalDetails?.hasAttribute("open")).toBe(false);
});
it("cannot resolve without selecting a verifiable evidence reference", async () => {
@@ -72,6 +77,14 @@ describe("CaseWorkspace", () => {
expect((screen.getByRole("button", { name: "Закрыть кейс" }) as HTMLButtonElement).disabled).toBe(true);
});
it("renders evidence as an available card with technical ref collapsed", () => {
render(CaseWorkspace, { props: { model: modelWith() } });
expect(screen.getByText("screenshot")).toBeTruthy();
expect(screen.getByText("Доступно для проверки")).toBeTruthy();
const technical = screen.getByText("Технические данные").closest("details");
expect(technical?.hasAttribute("open")).toBe(false);
});
it("requires an accepted-risk rationale before submitting accepted", async () => {
const fetchMock = vi.spyOn(api, "fetchApi").mockResolvedValue({ id: "case-1", decision_version: 2, status: "accepted" });
render(CaseWorkspace, { props: { model: modelWith({ evidenceRefs: [] }) } });

View File

@@ -121,6 +121,22 @@
"case_evidence_none": "No authorized evidence in this case — resolved is unavailable",
"case_open_evidence": "View evidence",
"case_evidence_unavailable": "Content unavailable",
"case_evidence_available": "Available for verification",
"case_evidence_type": "evidence",
"case_evidence_technical": "Technical data",
"case_summary_aria": "Investigation summary",
"case_summary_title": "What happened",
"case_summary_open": "This signal needs analyst investigation and confirmation.",
"case_summary_in_progress": "Investigation is in progress; review the run and evidence before deciding.",
"case_summary_resolved": "The issue was verified as fixed using the selected evidence.",
"case_summary_accepted": "The risk was accepted with a recorded rationale.",
"case_summary_scenario": "Scenario",
"case_summary_when": "Detected",
"case_summary_next": "Next action",
"case_summary_review": "Review evidence",
"case_summary_no_action": "Decision recorded",
"case_technical_details": "Technical data and identifiers",
"case_id": "Case identifier",
"case_audit_timeline_title": "Case action audit",
"case_rationale_label": "Accepted risk rationale",
"case_rationale_placeholder": "Why the risk is accepted",
@@ -135,6 +151,16 @@
"queue_investigate": "Open investigation",
"queue_in_case": "in case",
"queue_occurrences": "occurrences",
"queue_needs_investigation": "Needs investigation",
"queue_in_progress": "In progress",
"queue_unreviewed": "not investigated",
"queue_technical_details": "Technical details",
"queue_step": "step",
"queue_environment": "environment",
"queue_run": "run",
"queue_error_code": "error code",
"queue_fingerprint": "fingerprint",
"queue_signal_unknown": "signal",
"queue_open_case": "Open case",
"queue_severity_filter": "Severity filter",
"queue_all_severities": "All severities",

View File

@@ -121,6 +121,22 @@
"case_evidence_none": "В кейсе нет авторизованных доказательств — resolved недоступен",
"case_open_evidence": "Просмотреть доказательство",
"case_evidence_unavailable": "Содержимое недоступно",
"case_evidence_available": "Доступно для проверки",
"case_evidence_type": "доказательство",
"case_evidence_technical": "Технические данные",
"case_summary_aria": "Сводка расследования",
"case_summary_title": "Что произошло",
"case_summary_open": "Сигнал требует расследования и подтверждения аналитика.",
"case_summary_in_progress": "Расследование выполняется; проверьте запуск и доказательства перед решением.",
"case_summary_resolved": "Проблема подтверждённо устранена по выбранному доказательству.",
"case_summary_accepted": "Риск принят с сохранённым обоснованием.",
"case_summary_scenario": "Сценарий",
"case_summary_when": "Обнаружено",
"case_summary_next": "Следующее действие",
"case_summary_review": "Проверить доказательства",
"case_summary_no_action": "Решение записано",
"case_technical_details": "Технические данные и идентификаторы",
"case_id": "Идентификатор кейса",
"case_audit_timeline_title": "Аудит действий кейса",
"case_rationale_label": "Причина принятого риска",
"case_rationale_placeholder": "Почему риск принят",
@@ -135,6 +151,16 @@
"queue_investigate": "Открыть расследование",
"queue_in_case": "в кейсе",
"queue_occurrences": "повторов",
"queue_needs_investigation": "Требует расследования",
"queue_in_progress": "В работе",
"queue_unreviewed": "не расследовано",
"queue_technical_details": "Технические детали",
"queue_step": "шаг",
"queue_environment": "окружение",
"queue_run": "запуск",
"queue_error_code": "код ошибки",
"queue_fingerprint": "fingerprint",
"queue_signal_unknown": "сигнал",
"queue_open_case": "Открыть кейс",
"queue_severity_filter": "Фильтр по важности",
"queue_all_severities": "Все важности",

View File

@@ -27,6 +27,10 @@ describe("Analytics queue route", () => {
expect(await screen.findByRole("heading", { level: 1, name: "Очередь расследований" })).toBeTruthy();
expect(screen.getByText("1 attention item(s)")).toBeTruthy();
expect(screen.getByRole("button", { name: "Открыть расследование" })).toBeTruthy();
expect(screen.getByText("Требует расследования (1)")).toBeTruthy();
const technicalDetails = screen.getByText("Технические детали").closest("details");
expect(technicalDetails).toBeTruthy();
expect(technicalDetails?.hasAttribute("open")).toBe(false);
});
it("opens a case explicitly and navigates to the case workspace", async () => {

View File

@@ -49,6 +49,15 @@ The score is an expert review from the visible workflow/screenshots and live smo
- Live smoke: queue→case, protected evidence loading, automation page/preview, run center and scenario editor routes load on the local stand; remote test stands are reachable.
- Axiom verification was run after source changes; no new parse warnings were reported for the touched paths.
## Follow-up UX Slice (2026-09-24)
- Investigation queue now groups attention items into `Needs investigation` and `In progress`, uses localized severity/state labels, and keeps run/error/fingerprint values behind a technical-details disclosure.
- Case workspace now leads with a business-language `What happened` summary, scenario/time/next-action fields, evidence cards with availability state, and technical IDs/refs behind disclosures.
- Focused frontend proof: `analytics_queue.ux.test.ts` + `CaseWorkspace.test.ts` = **12 passed**; `npm run lint` = 0 errors (existing warnings); `npm run build` = successful.
- Follow-up contract proof: queue/case disclosures and evidence-card availability = **13 focused tests passed**; component contracts now include explicit `@UX_TEST` coverage for collapsed technical details.
- Full frontend lint/build remain green after the follow-up; lint reports the repository's existing warnings only.
- This is an implementation slice, not participant evidence. `G-BA-UX-READINESS` remains **OPEN**; five-analyst study, keyboard/screen-reader acceptance and release gates remain unconfirmed.
## Do Not Claim Yet
- Do not mark `G-BA-UX-READINESS` closed from the 1.9/5 expert assessment.