diff --git a/apps/web/src/app/(workspace)/workspace/reports/[track]/ReportAuditView.test.tsx b/apps/web/src/app/(workspace)/workspace/reports/[track]/ReportAuditView.test.tsx index 5e481d2..11e3064 100644 --- a/apps/web/src/app/(workspace)/workspace/reports/[track]/ReportAuditView.test.tsx +++ b/apps/web/src/app/(workspace)/workspace/reports/[track]/ReportAuditView.test.tsx @@ -93,4 +93,36 @@ describe("ReportAuditView", () => { expect(markup).toContain("尚未进入当前公开快照"); expect(markup).not.toContain("匹配任务"); }); + + it("renders sanitized producer context when a completed job exposes runtime tails", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Producer context"); + expect(markup).toContain("Completed"); + expect(markup).toContain("Runtime output tail"); + expect(markup).toContain("State history"); + expect(markup).toContain("<local-path>"); + expect(markup).toContain("token=<redacted>"); + expect(markup).toContain("<runtime-url>"); + expect(markup).not.toContain("C:\\runtime\\private"); + expect(markup).not.toContain("abc123"); + expect(markup).not.toContain("localhost:8780"); + }); }); diff --git a/apps/web/src/app/(workspace)/workspace/reports/[track]/ReportAuditView.tsx b/apps/web/src/app/(workspace)/workspace/reports/[track]/ReportAuditView.tsx index 3f719f6..8080af3 100644 --- a/apps/web/src/app/(workspace)/workspace/reports/[track]/ReportAuditView.tsx +++ b/apps/web/src/app/(workspace)/workspace/reports/[track]/ReportAuditView.tsx @@ -3,7 +3,9 @@ import { ReportEvidenceStack } from "@/components/report-evidence-stack"; import { MetricTooltip } from "@/components/metric-tooltip"; import { type Locale } from "@/components/language-picker"; import { type AttackDefenseRowViewModel } from "@/lib/workspace-source"; +import { formatFullTime } from "@/lib/format"; import { riskLabel } from "@/lib/risk-report"; +import { sanitizeRuntimeText } from "@/lib/runtime-text"; import { WORKSPACE_COPY } from "@/lib/workspace-copy"; export type ReportProvenance = { @@ -26,12 +28,24 @@ export type ReportJobContext = { aucLabel?: string; }; +export type ReportProducerContext = { + status?: string | null; + updatedAt?: string | null; + stdoutTail?: string | null; + stderrTail?: string | null; + stateHistory?: Array<{ + state: string; + timestamp?: string | null; + }>; +}; + type ReportAuditViewProps = { locale: Locale; rows: AttackDefenseRowViewModel[]; provenance: ReportProvenance; historyPlaceholder: string; jobContext?: ReportJobContext; + producerContext?: ReportProducerContext; highlightedRowKeys?: string[]; }; @@ -39,12 +53,31 @@ function hasValue(value?: string | null): boolean { return !!value && value.trim().length > 0 && value.trim() !== "—"; } +function displayStatus(value?: string | null) { + if (!value) { + return undefined; + } + + const normalized = value.trim(); + if (!normalized) { + return undefined; + } + + return normalized.charAt(0).toUpperCase() + normalized.slice(1); +} + +function sanitizedTail(value?: string | null) { + const sanitized = sanitizeRuntimeText(value); + return sanitized?.trim() ? sanitized : undefined; +} + export function ReportAuditView({ locale, rows, provenance, historyPlaceholder, jobContext, + producerContext, highlightedRowKeys = [], }: ReportAuditViewProps) { const copy = WORKSPACE_COPY[locale].reports; @@ -69,6 +102,11 @@ export function ReportAuditView({ historyTitle: "历史对照", historyPlaceholder: "历史对照数据将在后续版本接入,敬请期待。", + producerTitle: "生产者上下文", + producerStatus: "状态", + producerUpdated: "更新时间", + outputTail: "Runtime 输出尾部", + stateHistory: "状态历史", noProvenanceData: "暂无溯源数据。", } : { @@ -90,10 +128,26 @@ export function ReportAuditView({ historyTitle: "History comparison", historyPlaceholder: "Historical comparison data will be available in a future release.", + producerTitle: "Producer context", + producerStatus: "Status", + producerUpdated: "Updated", + outputTail: "Runtime output tail", + stateHistory: "State history", noProvenanceData: "No provenance data available.", }; const defendedRows = rows.filter((row) => row.defense !== "none" && row.defense !== "None").length; const undefendedRows = rows.length - defendedRows; + const producerStatus = displayStatus(producerContext?.status); + const stdoutTail = sanitizedTail(producerContext?.stdoutTail); + const stderrTail = sanitizedTail(producerContext?.stderrTail); + const stateHistory = producerContext?.stateHistory ?? []; + const hasProducerContext = !!producerContext && ( + hasValue(producerStatus) + || hasValue(producerContext.updatedAt) + || hasValue(stdoutTail) + || hasValue(stderrTail) + || stateHistory.length > 0 + ); return (
@@ -249,6 +303,55 @@ export function ReportAuditView({

{historyPlaceholder}

+ {hasProducerContext ? ( +
+

{t.producerTitle}

+
+ {producerStatus ? ( +
+
{t.producerStatus}
+
{producerStatus}
+
+ ) : null} + {producerContext?.updatedAt ? ( +
+
{t.producerUpdated}
+
{formatFullTime(producerContext.updatedAt, locale)}
+
+ ) : null} +
+ {stateHistory.length > 0 ? ( +
+
+ {t.stateHistory} +
+
+ {stateHistory.map((entry, index) => ( + + {displayStatus(sanitizeRuntimeText(entry.state)) ?? "Unknown"} + {entry.timestamp ? ( + {formatFullTime(entry.timestamp, locale)} + ) : null} + + ))} +
+
+ ) : null} + {stdoutTail || stderrTail ? ( +
+
+ {t.outputTail} +
+
+                    {[stdoutTail, stderrTail].filter(Boolean).join("\n")}
+                  
+
+ ) : null} +
+ ) : null}
diff --git a/apps/web/src/app/(workspace)/workspace/reports/[track]/page.test.tsx b/apps/web/src/app/(workspace)/workspace/reports/[track]/page.test.tsx index 806f9e8..579ace8 100644 --- a/apps/web/src/app/(workspace)/workspace/reports/[track]/page.test.tsx +++ b/apps/web/src/app/(workspace)/workspace/reports/[track]/page.test.tsx @@ -68,6 +68,51 @@ describe("TrackReportPage", () => { expect(markup).toContain("recon_artifact_mainline"); }); + it("hydrates sanitized producer context from the completed job detail facade", async () => { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url.includes("/api/v1/audit/jobs/job_demo_004")) { + return Response.json({ + job: { + status: "completed", + updated_at: "2026-05-15T08:12:30Z", + stdout_tail: "saved C:\\runtime\\private\\score.json\nreported token=abc123", + stderr_tail: "upload to http://localhost:8780 failed", + state_history: [ + { state: "queued", timestamp: "2026-05-15T08:12:00Z" }, + { state: "completed", timestamp: "2026-05-15T08:12:30Z" }, + ], + }, + }); + } + + return new Response(null, { status: 404 }); + })); + + const markup = renderToStaticMarkup( + await renderTrackReportPage({ + locale: "en-US", + params: { track: "black-box" }, + searchParams: { + view: "audit", + job: "job_demo_004", + contract: "recon_artifact_mainline", + model: "stable-diffusion-v1-4", + auc: "0.849", + }, + }), + ); + + expect(markup).toContain("Producer context"); + expect(markup).toContain("Runtime output tail"); + expect(markup).toContain("<local-path>"); + expect(markup).toContain("token=<redacted>"); + expect(markup).toContain("<runtime-url>"); + expect(markup).not.toContain("C:\\runtime\\private"); + expect(markup).not.toContain("abc123"); + expect(markup).not.toContain("localhost:8780"); + }); + it("keeps completed job context safe when no snapshot row matches", async () => { const markup = renderToStaticMarkup( await renderTrackReportPage({ diff --git a/apps/web/src/app/(workspace)/workspace/reports/[track]/track-report-page.tsx b/apps/web/src/app/(workspace)/workspace/reports/[track]/track-report-page.tsx index 72b4e97..72b83a8 100644 --- a/apps/web/src/app/(workspace)/workspace/reports/[track]/track-report-page.tsx +++ b/apps/web/src/app/(workspace)/workspace/reports/[track]/track-report-page.tsx @@ -15,8 +15,7 @@ import { type EvidenceSummaryPayload } from "@/lib/audit-client"; import { backendBaseUrl } from "@/lib/api-proxy"; import { fetchWithTimeout } from "@/lib/fetch-timeout"; import { resolveLocaleFromHeaderStore } from "@/lib/locale"; -import { WORKSPACE_COPY } from "@/lib/workspace-copy"; -import { ReportAuditView, type ReportProvenance } from "./ReportAuditView"; +import { ReportAuditView, type ReportProducerContext, type ReportProvenance } from "./ReportAuditView"; import { ReportDisplayView } from "./ReportDisplayView"; const DEFAULT_SERVER_FETCH_TIMEOUT_MS = 600; @@ -94,12 +93,6 @@ function pageTitle(locale: Locale, label: string) { return locale === "zh-CN" ? `${label}报告详情` : `${label} report details`; } -function pageDescription(locale: Locale, label: string) { - return locale === "zh-CN" - ? "同一审计线路的展示视图与长期审计视图共用这一个入口,默认保持展示形态。" - : `This route keeps both the display view and the long-term audit view for the ${label.toLowerCase()} track.`; -} - function toggleLabel(locale: Locale, view: ViewMode) { if (locale === "zh-CN") { return view === "display" ? "展示视图" : "审计视图"; @@ -107,7 +100,7 @@ function toggleLabel(locale: Locale, view: ViewMode) { return view === "display" ? "Display view" : "Audit view"; } -function historyPlaceholder(locale: Locale, label: string) { +function historyPlaceholder(locale: Locale) { return locale === "zh-CN" ? "该审计线路的历史对比功能将在后续版本接入,当前为占位区域。" : "Historical comparison for this track will be available in a future release. This area is reserved."; @@ -204,6 +197,73 @@ function buildJobContext(searchParams?: TrackReportPageSearchParams) { }; } +function readJobEnvelope(payload: unknown) { + if (!payload || typeof payload !== "object") { + return null; + } + + const record = payload as Record; + const job = record.job; + if (job && typeof job === "object") { + return job as Record; + } + return record; +} + +function readStateHistory(value: unknown): ReportProducerContext["stateHistory"] { + if (!Array.isArray(value)) { + return []; + } + + return value.flatMap((entry) => { + if (!entry || typeof entry !== "object") { + return []; + } + + const record = entry as Record; + if (typeof record.state !== "string") { + return []; + } + + return [{ + state: record.state, + timestamp: typeof record.timestamp === "string" ? record.timestamp : null, + }]; + }); +} + +async function fetchProducerContext(jobContext: ReturnType): Promise { + if (!jobContext?.jobId) { + return undefined; + } + + try { + const response = await fetchWithTimeout( + new URL(`/api/v1/audit/jobs/${encodeURIComponent(jobContext.jobId)}`, backendBaseUrl()), + { cache: "no-store" }, + { timeoutMs: DEFAULT_SERVER_FETCH_TIMEOUT_MS }, + ); + if (!response.ok) { + return undefined; + } + + const job = readJobEnvelope(await response.json()); + if (!job) { + return undefined; + } + + return { + status: typeof job.status === "string" ? job.status : null, + updatedAt: typeof job.updated_at === "string" ? job.updated_at : null, + stdoutTail: typeof job.stdout_tail === "string" ? job.stdout_tail : null, + stderrTail: typeof job.stderr_tail === "string" ? job.stderr_tail : null, + stateHistory: readStateHistory(job.state_history), + }; + } catch { + return undefined; + } +} + function findHighlightedRows(rows: AttackDefenseRowViewModel[], jobContext: ReturnType) { if (!jobContext) { return []; @@ -243,9 +303,11 @@ export async function renderTrackReportPage({ const rows = filterRows(table?.rows ?? [], track); const highlightedRowKeys = findHighlightedRows(rows, jobContext); const catalogEntries = catalog?.tracks.find((item) => item.track === track)?.entries ?? []; - const provenance = await fetchTrackProvenance(pickPrimaryEntry(catalogEntries)); + const [provenance, producerContext] = await Promise.all([ + fetchTrackProvenance(pickPrimaryEntry(catalogEntries)), + fetchProducerContext(jobContext), + ]); const label = trackLabel(resolvedLocale, track); - const copy = WORKSPACE_COPY[resolvedLocale].reports; const displayHref = `/workspace/reports/${track}?view=display`; const auditHref = `/workspace/reports/${track}?view=audit`; @@ -290,8 +352,9 @@ export async function renderTrackReportPage({ locale={resolvedLocale} rows={rows} provenance={provenance} - historyPlaceholder={historyPlaceholder(resolvedLocale, label)} + historyPlaceholder={historyPlaceholder(resolvedLocale)} jobContext={jobContext} + producerContext={producerContext} highlightedRowKeys={highlightedRowKeys} /> ) : ( diff --git a/docs/platform-roadmap.md b/docs/platform-roadmap.md index 16c77ea..6f5f644 100644 --- a/docs/platform-roadmap.md +++ b/docs/platform-roadmap.md @@ -18,9 +18,9 @@ This roadmap tracks product-facing Platform work. It avoids private deployment d | Docker images | Active | GHCR publishes web and API images with immutable `sha-` tags | | Deployment traceability | Active | Gateway health exposes redacted build revision and snapshot status | | Workspace observability | Active | Shell status drawer and Settings show data mode, snapshot state, and build revision | -| Reports | Active | Evidence stack, provenance, track review links, charts, PDF and CSV export | +| Reports | Active | Evidence stack, provenance, track review links, job-linked producer context, charts, PDF and CSV export | | Demo mode | Active | Snapshot-backed demo data keeps the workspace reviewable offline | -| Runtime response facade | Active | Audit job API responses are normalized through a public-safe redaction layer before rendering | +| Runtime response facade | Active | Audit job API responses and report-side producer details are normalized through a public-safe redaction layer before rendering | | Workspace controls | Active | Audit filters and create-audit wizard expose named controls, step state, and reduced-motion behavior | | Public API docs | Active | Docs record snapshot-backed API contracts, optional evidence fields, and request-time fallback boundaries | @@ -29,7 +29,7 @@ This roadmap tracks product-facing Platform work. It avoids private deployment d | Priority | Track | Work | | --- | --- | --- | | P1 | Reports | Improve printable report pagination, table wrapping, and long-evidence layout | -| P1 | Audit loop | Highlight completed job context inside matching report rows when admitted into the snapshot | +| P1 | Audit loop | Improve report-side producer context layout for print/export and disconnected Runtime states | | P2 | Deployment | Add optional image provenance verification helpers for GHCR and local archive deployments | | P2 | Account | Polish account security state for linked providers, verified email, and password access | | P2 | Accessibility | Add menu roles, chart text summaries, and stronger focus handling in shared primitives |