Add a run details panel and move the privacy note to a dismissable toast

This commit is contained in:
Jonathan Singer
2026-07-20 19:06:40 -04:00
parent 68f1d19504
commit 4be5d7716c
8 changed files with 409 additions and 164 deletions
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -6,8 +6,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="dark" />
<title>Strix Results</title>
<script type="module" crossorigin src="./assets/index-Cmmg8DTB.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-B-nLXAmX.css">
<script type="module" crossorigin src="./assets/index-C6BkTz7a.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-C0NveaV7.css">
</head>
<body>
<div id="root"></div>
+8 -9
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ShieldCheck,
ArrowLeft,
AlertCircle,
Waypoints,
@@ -44,6 +43,8 @@ import { runTitle } from "@/lib/target-utils";
import Sidebar from "@/components/Sidebar";
import PastRunsView from "@/components/PastRunsView";
import EmailReportView from "@/components/EmailReportView";
import { RunDetails } from "@/components/RunDetails";
import { TrustToast } from "@/components/TrustToast";
import FeatureDetail from "@/components/FeatureDetail";
import { ProTile, ProInlineCta, type ProItem } from "@/components/ProCta";
import { FEATURES } from "@/lib/pro-features";
@@ -321,14 +322,6 @@ export default function App() {
</div>
<div className="max-w-[72rem] mx-auto px-6 py-8 space-y-6">
{/* Trust banner (not on the Pro feature or email pages) */}
{view !== "feature" && view !== "email" && (
<div className="rounded-lg px-4 py-3 flex gap-3 items-start" style={{ border: "1px solid rgba(255,255,255,0.08)" }}>
<ShieldCheck className="w-5 h-5 flex-shrink-0 mt-0.5 text-emerald-400" aria-hidden="true" />
<p className="text-sm text-[#aaa] leading-relaxed">{TRUST_BANNER}</p>
</div>
)}
{error && !run && view !== "history" && view !== "email" && view !== "feature" && (
<div className="rounded-lg px-4 py-3 flex gap-3 items-start border border-red-500/30 bg-red-500/5">
<AlertCircle className="w-5 h-5 flex-shrink-0 mt-0.5 text-red-400" aria-hidden="true" />
@@ -393,6 +386,7 @@ export default function App() {
counts={counts}
total={run.vulnerabilities.length}
reportMarkdown={run.reportMarkdown}
raw={run.raw}
onOpenEmail={openEmailFromOverview}
/>
) : view === "agents" && agentCount > 0 ? (
@@ -418,6 +412,7 @@ export default function App() {
) : null}
</div>
</div>
<TrustToast message={TRUST_BANNER} />
</div>
);
}
@@ -651,12 +646,14 @@ function OverviewTab({
counts,
total,
reportMarkdown,
raw,
onOpenEmail,
}: {
summary: ParsedRunSummary;
counts: Record<VulnerabilitySeverity, number>;
total: number;
reportMarkdown: string | null;
raw: Record<string, unknown>;
onOpenEmail: () => void;
}) {
const sections = (
@@ -672,6 +669,8 @@ function OverviewTab({
return (
<div className="space-y-6">
<RunDetails raw={raw} durationSeconds={summary.durationSeconds} />
{total > 0 && (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<IssueSeveritySummary findings={{ total, ...counts }} />
@@ -0,0 +1,185 @@
import { Info } from "lucide-react";
import { formatNumber } from "@/lib/display-number";
/**
* "Run details" card for the Overview tab: the launch configuration the run was
* started with (targets, instruction, scope, mode) and its LLM usage + cost.
* Everything is read defensively from the raw run.json record, which may be
* partial while a scan is still live.
*/
type Rec = Record<string, unknown>;
function rec(v: unknown): Rec {
return v && typeof v === "object" && !Array.isArray(v) ? (v as Rec) : {};
}
function arr(v: unknown): unknown[] {
return Array.isArray(v) ? v : [];
}
function str(v: unknown): string | null {
return typeof v === "string" && v.trim() ? v : null;
}
function num(v: unknown): number | null {
return typeof v === "number" && Number.isFinite(v) ? v : null;
}
function humanize(s: string): string {
return s.replace(/_/g, " ");
}
function cap(s: string | null): string | null {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
function fmtDuration(seconds: number | null): string {
if (seconds == null || seconds < 0) return "n/a";
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
if (h) return `${h}h ${m}m ${s}s`;
if (m) return `${m}m ${s}s`;
return `${s}s`;
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="grid grid-cols-[7rem_1fr] gap-3 items-baseline">
<dt className="text-[11px] uppercase tracking-wide text-[#666]">{label}</dt>
<dd className="min-w-0 break-words text-sm text-[#ddd]">{children}</dd>
</div>
);
}
export function RunDetails({
raw,
durationSeconds,
}: {
raw: Rec;
durationSeconds: number | null;
}) {
// Configuration (launch inputs)
const targets = arr(raw.targets_info).map((t) => {
const o = rec(t);
const display = str(o.original) ?? str(rec(o.details).target_url) ?? "unknown target";
const type = str(o.type);
return { display, type: type ? humanize(type) : null };
});
const instruction = str(raw.instruction);
const scanMode = cap(str(raw.scan_mode));
const scopeMode = str(raw.scope_mode);
const diff = rec(raw.diff_scope);
const diffActive = diff.active === true;
const diffMode = str(diff.mode);
const diffBase = str(raw.diff_base);
const nonInteractive = raw.non_interactive === true;
const localSources = arr(raw.local_sources).map((x) => String(x)).filter(Boolean);
const status = cap(str(raw.status));
let scope = scopeMode ?? "auto";
if (diffActive) {
scope += ` (diff${diffMode ? `: ${diffMode}` : ""}${diffBase ? ` vs ${diffBase}` : ""})`;
}
// Usage & cost
const usage = rec(raw.llm_usage);
const hasUsage = Object.keys(usage).length > 0;
const agents = arr(usage.agents).map(rec);
const models = Array.from(
new Set(agents.map((a) => str(a.model)).filter((m): m is string => !!m))
);
const requests = num(usage.requests);
const inputTokens = num(usage.input_tokens);
const cached = num(rec(arr(usage.input_tokens_details)[0]).cached_tokens);
const outputTokens = num(usage.output_tokens);
const reasoning = num(rec(arr(usage.output_tokens_details)[0]).reasoning_tokens);
const totalTokens = num(usage.total_tokens);
const cost = num(usage.cost);
const sub = (n: number, word: string) => (
<span className="text-[#666]"> ({formatNumber(n)} {word})</span>
);
return (
<div className="rounded-xl border border-[#222] bg-[rgba(255,255,255,0.02)] p-5">
<div className="mb-4 flex items-center gap-2">
<Info className="h-4 w-4 text-[#888]" aria-hidden="true" />
<h2 className="text-sm font-semibold text-white">Run details</h2>
</div>
<div className="grid grid-cols-1 gap-x-8 gap-y-6 md:grid-cols-2">
<section>
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]">
Configuration
</h3>
<dl className="space-y-2.5">
{targets.length > 0 && (
<Field label="Targets">
<div className="space-y-1">
{targets.map((t, i) => (
<div key={i} className="flex flex-wrap items-center gap-2">
<span className="font-mono text-[#ddd]">{t.display}</span>
{t.type && (
<span className="rounded-full border border-[#2a2a2a] px-1.5 py-0.5 text-[10px] text-[#888]">
{t.type}
</span>
)}
</div>
))}
</div>
</Field>
)}
<Field label="Instruction">
{instruction ? (
<span className="whitespace-pre-wrap">{instruction}</span>
) : (
<span className="text-[#666]">None</span>
)}
</Field>
{scanMode && <Field label="Scan mode">{scanMode}</Field>}
<Field label="Scope">{scope}</Field>
<Field label="Mode">{nonInteractive ? "Non-interactive" : "Interactive"}</Field>
{localSources.length > 0 && (
<Field label="Local sources">
<div className="space-y-0.5 font-mono text-[#ddd]">
{localSources.map((s, i) => (
<div key={i}>{s}</div>
))}
</div>
</Field>
)}
{status && <Field label="Status">{status}</Field>}
</dl>
</section>
<section>
<h3 className="mb-3 text-[11px] font-semibold uppercase tracking-wide text-[#555]">
Usage &amp; cost
</h3>
{hasUsage ? (
<dl className="space-y-2.5 tabular-nums">
<Field label="Model">{models.length ? models.join(", ") : "n/a"}</Field>
<Field label="Run time">{fmtDuration(durationSeconds)}</Field>
{requests != null && <Field label="Requests">{formatNumber(requests)}</Field>}
{inputTokens != null && (
<Field label="Input tokens">
{formatNumber(inputTokens)}
{cached != null && sub(cached, "cached")}
</Field>
)}
{outputTokens != null && (
<Field label="Output tokens">
{formatNumber(outputTokens)}
{reasoning != null && sub(reasoning, "reasoning")}
</Field>
)}
{totalTokens != null && <Field label="Total tokens">{formatNumber(totalTokens)}</Field>}
{cost != null && <Field label="Cost">${cost.toFixed(2)}</Field>}
{agents.length > 0 && <Field label="Agents">{formatNumber(agents.length)}</Field>}
</dl>
) : (
<p className="text-sm text-[#666]">Not available yet.</p>
)}
</section>
</div>
</div>
);
}
export default RunDetails;
@@ -0,0 +1,51 @@
import { useState } from "react";
import { ShieldCheck, X } from "lucide-react";
const DISMISS_KEY = "strix_viewer_trust_dismissed";
/**
* One-time privacy notice, shown as a toast pinned over the sidebar. Dismissing
* it persists to localStorage so it never returns on reload or view changes.
*/
export function TrustToast({ message }: { message: string }) {
const [dismissed, setDismissed] = useState<boolean>(() => {
try {
return localStorage.getItem(DISMISS_KEY) === "1";
} catch {
return false;
}
});
if (dismissed) return null;
const dismiss = () => {
try {
localStorage.setItem(DISMISS_KEY, "1");
} catch {
/* non-fatal: worst case the toast shows again next session */
}
setDismissed(true);
};
return (
<div
className="fixed bottom-3 left-3 z-[60] max-w-xs rounded-lg bg-[#0a0a0a] p-3 shadow-2xl"
style={{ border: "1px solid #2a2a2a" }}
role="status"
>
<div className="flex gap-2.5">
<ShieldCheck className="mt-0.5 h-4 w-4 flex-shrink-0 text-emerald-400" aria-hidden="true" />
<p className="text-xs leading-relaxed text-[#aaa]">{message}</p>
<button
onClick={dismiss}
aria-label="Dismiss"
className="-mr-0.5 -mt-0.5 flex-shrink-0 cursor-pointer rounded p-0.5 text-[#666] transition-colors hover:text-white"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
export default TrustToast;
@@ -5,3 +5,8 @@
export function formatStrixId(num: number): string {
return `STRIX-${num}`;
}
/** Format an integer with locale thousands separators (e.g. 68339486 -> "68,339,486"). */
export function formatNumber(num: number): string {
return new Intl.NumberFormat("en-US").format(num);
}