feat(frontend): improve investigation analyst UX
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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: [] }) } });
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Все важности",
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user