Only complete JSON bodies up to 64 KiB are retained. Masking follows configured field names and patterns; unfamiliar secrets may need custom fields.
-
History is lost on restart. Drafts stay in this tab. Traffic refresh is manual.
-
{info?.capture_mode === 'http_and_https_inspection' ? 'Protocol upgrades are unsupported.' : 'HTTPS tunnel contents are encrypted and unavailable for inspection.'}{status === 'Disconnected' && info ? ' Capture configuration shown is from the last successful check.' : ''}
Unable to remove older saved drafts. Clear this site’s storage in your browser.
}
-
- ;
+
+
+ Capture policy & connection
+
+
+
+ Only complete JSON bodies up to 64 KiB are retained. Masking
+ follows configured field names and patterns; unfamiliar secrets
+ may need custom fields.
+
+
+ History is lost on restart. Drafts stay in this tab. Traffic
+ refresh is controlled in the workspace.
+
+
+ {info?.capture_mode === "http_and_https_inspection"
+ ? "Protocol upgrades are unsupported."
+ : "HTTPS tunnel contents are encrypted and unavailable for inspection."}
+ {status === "Disconnected" && info
+ ? " Capture configuration shown is from the last successful check."
+ : ""}
+
+ Latency covers upstream HTTP responses only, excluding tunnels and
+ attempts without a response. Request types are not classified: delayed
+ headers from long polling can affect this population. Filter to a
+ comparable host, route and method. Total duration includes streaming
+ and is available in request details. Free-text search is kept in this
+ tab, not shared in links.
+
+ Paths are sanitized captured paths, not inferred route templates.
+
+
+
+
+ Trend across matching evidence
+
+
+ Equal time buckets across retained matches. Counts are observed
+ records, not a complete traffic rate. An empty bucket does not
+ prove no traffic occurred.
+
+ Total duration includes body transfer and streaming lifetime. Other
+ elapsed time is not CPU overhead. CONNECT records describe tunnel
+ establishment only.
+
+
+
+
+ );
+}
diff --git a/dashboard/src/hooks/useInvestigation.ts b/dashboard/src/hooks/useInvestigation.ts
new file mode 100644
index 0000000..8c54f0f
--- /dev/null
+++ b/dashboard/src/hooks/useInvestigation.ts
@@ -0,0 +1,27 @@
+"use client";
+import { create } from "zustand";
+import type { BackendRequestRecord } from "../services/api";
+import { emptyScope, type Scope } from "../lib/investigation";
+// Keep the investigation across client navigation, without persisting captures to storage.
+interface State {
+ records: BackendRequestRecord[];
+ pending: BackendRequestRecord[] | null;
+ scope: Scope;
+ pendingAt: Date | null;
+ selected: string;
+ live: boolean;
+ lastRefresh: Date | null;
+ search: string;
+ set: (patch: Partial>) => void;
+}
+export const useInvestigation = create()((set) => ({
+ records: [],
+ pending: null,
+ pendingAt: null,
+ scope: { ...emptyScope },
+ selected: "",
+ live: false,
+ lastRefresh: null,
+ search: "",
+ set,
+}));
diff --git a/dashboard/src/lib/investigation.test.ts b/dashboard/src/lib/investigation.test.ts
new file mode 100644
index 0000000..c580678
--- /dev/null
+++ b/dashboard/src/lib/investigation.test.ts
@@ -0,0 +1,103 @@
+import assert from "node:assert/strict";
+import { test } from "node:test";
+import type { BackendRequestRecord } from "../services/api";
+import {
+ buckets,
+ emptyScope,
+ failed,
+ groups,
+ matches,
+ summary,
+} from "./investigation";
+const record = (
+ id: string,
+ overrides: Partial = {},
+): BackendRequestRecord =>
+ ({
+ id,
+ timestamp: "2026-09-22T12:00:00Z",
+ method: "GET",
+ url: "https://example.test/api/tasks?token=%5BREDACTED%5D",
+ response_status: 200,
+ outcome: "complete",
+ success: true,
+ response_source: "upstream",
+ upstream_latency_us: 1000,
+ total_duration_us: 30000000,
+ ...overrides,
+ }) as BackendRequestRecord;
+
+test("latency excludes opaque tunnels and failed attempts; streaming lifetime does not become header latency", () => {
+ const stats = summary([
+ record("stream"),
+ record("tunnel", {
+ method: "CONNECT",
+ response_source: "proxy",
+ upstream_latency_us: 0,
+ outcome: "tunnel_only",
+ }),
+ record("failed", {
+ outcome: "upstream_error",
+ response_source: "proxy",
+ response_status: 502,
+ upstream_latency_us: 999999,
+ }),
+ ]);
+ assert.equal(stats.p95, 1000);
+ assert.equal(stats.latencyCount, 1);
+ assert.equal(stats.failures, 1);
+ assert.equal(
+ failed(
+ record("unknown", {
+ outcome: "unknown",
+ success: false,
+ response_status: 0,
+ }),
+ ),
+ false,
+ );
+ assert.equal(
+ failed(record("partial", { outcome: "interrupted", success: false })),
+ true,
+ );
+ assert.equal(failed(record("401", { response_status: 401 })), true);
+});
+test("host and path groups drill into exactly their captured population without query values", () => {
+ const records = [
+ record("1"),
+ record("2", { url: "https://example.test/api/tasks?different=yes" }),
+ record("3", { url: "https://other.test/api/tasks" }),
+ ];
+ for (const group of groups(records))
+ assert.deepEqual(
+ records
+ .filter((r) =>
+ matches(r, { ...emptyScope, host: group.host, route: group.route }),
+ )
+ .map((r) => r.id),
+ group.records.map((r) => r.id),
+ );
+ assert.equal(groups(records).length, 2);
+});
+test("trend bucket boundaries partition records exactly and produce matching drill-down counts", () => {
+ const records = Array.from({ length: 17 }, (_, i) =>
+ record(String(i), {
+ timestamp: new Date(Date.UTC(2026, 8, 22, 12, 0, 0, i)).toISOString(),
+ response_status: i % 3 ? 200 : 500,
+ }),
+ );
+ const trend = buckets(records);
+ assert.equal(
+ trend.reduce((n, b) => n + b.count, 0),
+ records.length,
+ );
+ for (const bucket of trend) {
+ const evidence = records.filter((r) =>
+ matches(r, { ...emptyScope, from: bucket.from, to: bucket.to }),
+ );
+ assert.equal(evidence.length, bucket.count);
+ assert.equal(evidence.filter(failed).length, bucket.failures);
+ }
+ assert.equal(buckets([record("a"), record("b")]).length, 1);
+ assert.equal(summary([]).p95, null);
+});
diff --git a/dashboard/src/lib/investigation.ts b/dashboard/src/lib/investigation.ts
new file mode 100644
index 0000000..d37c46d
--- /dev/null
+++ b/dashboard/src/lib/investigation.ts
@@ -0,0 +1,121 @@
+import type { BackendRequestRecord as Record } from "../services/api";
+export type Scope = {
+ host: string;
+ route: string;
+ method: string;
+ outcome: string;
+ from: string;
+ to: string;
+};
+export const emptyScope: Scope = {
+ host: "",
+ route: "",
+ method: "",
+ outcome: "",
+ from: "",
+ to: "",
+};
+export function endpoint(record: Record) {
+ try {
+ const url = new URL(record.url);
+ return { host: url.host, route: url.pathname };
+ } catch {
+ return {
+ host: record.method === "CONNECT" ? record.url : "Unknown",
+ route: record.method === "CONNECT" ? "(tunnel)" : "(unavailable)",
+ };
+ }
+}
+export function failed(record: Record) {
+ return (
+ record.response_status >= 400 ||
+ [
+ "interrupted",
+ "client_canceled",
+ "blocked_by_inspection",
+ "upstream_error",
+ "proxy_error",
+ ].includes(record.outcome)
+ );
+}
+export function matches(record: Record, scope: Scope) {
+ const target = endpoint(record);
+ const time = Date.parse(record.timestamp);
+ return (
+ (!scope.host || scope.host === target.host) &&
+ (!scope.route || scope.route === target.route) &&
+ (!scope.method || scope.method === record.method) &&
+ (!scope.outcome ||
+ (scope.outcome === "failures"
+ ? failed(record)
+ : record.outcome === scope.outcome)) &&
+ (!scope.from || time >= Date.parse(scope.from)) &&
+ (!scope.to || time <= Date.parse(scope.to))
+ );
+}
+export function headersEligible(record: Record) {
+ return (
+ record.response_source === "upstream" &&
+ record.response_status > 0 &&
+ record.method !== "CONNECT" &&
+ record.upstream_latency_us >= 0
+ );
+}
+export function percentile(values: number[], fraction = 0.95) {
+ return values.length
+ ? [...values].sort((a, b) => a - b)[
+ Math.max(0, Math.ceil(values.length * fraction) - 1)
+ ]
+ : null;
+}
+export function summary(records: Record[]) {
+ const eligible = records.filter(headersEligible);
+ return {
+ count: records.length,
+ failures: records.filter(failed).length,
+ p95: percentile(eligible.map((r) => r.upstream_latency_us)),
+ latencyCount: eligible.length,
+ };
+}
+export function groups(records: Record[]) {
+ const map = new Map<
+ string,
+ { host: string; route: string; records: Record[] }
+ >();
+ for (const record of records) {
+ const target = endpoint(record);
+ const key = JSON.stringify(target);
+ const group = map.get(key) || { ...target, records: [] };
+ group.records.push(record);
+ map.set(key, group);
+ }
+ return [...map.values()].sort((a, b) => b.records.length - a.records.length);
+}
+// Explicit bucket bounds are also the drill-down bounds, including the last millisecond.
+export function buckets(records: Record[], count = 8) {
+ if (!records.length) return [];
+ const times = records.map((r) => Date.parse(r.timestamp));
+ const start = Math.min(...times);
+ const end = Math.max(...times);
+ const width = Math.max(1, Math.ceil((end - start + 1) / count));
+ return Array.from({ length: Math.min(count, end - start + 1) }, (_, i) => {
+ const from = new Date(start + i * width).toISOString();
+ const to = new Date(start + (i + 1) * width - 1).toISOString();
+ return {
+ from,
+ to,
+ ...summary(
+ records.filter((r) => matches(r, { ...emptyScope, from, to })),
+ ),
+ };
+ });
+}
+export function duration(us: number | null) {
+ return us === null
+ ? "—"
+ : us < 1000
+ ? `${Math.round(us)} μs`
+ : us < 1e6
+ ? `${(us / 1000).toFixed(1)} ms`
+ : `${(us / 1e6).toFixed(2)} s`;
+}
diff --git a/docs/investigation-workspace.md b/docs/investigation-workspace.md
new file mode 100644
index 0000000..168395f
--- /dev/null
+++ b/docs/investigation-workspace.md
@@ -0,0 +1,29 @@
+# Investigation workspace
+
+Traffic is the dashboard landing page. Compose is available at `/compose/`; the existing `/requests-history/` and `/requests-statistics/` links remain available (Traffic and Overview respectively).
+
+## Investigate without losing context
+
+Select a host, method, outcome, UTC time range, or search captured URLs. Open a request to see its HTTP status and transfer outcome separately, followed by Request, Response, and Timing tabs. Previous/Next move within the current filtered result set. At desktop widths the inspection panel sits beside the table and its width is adjustable; smaller windows show the selected detail with Back to results.
+
+Traffic and Overview share the current in-memory evidence snapshot, filters, search and selection during workspace navigation. Structured filters and request ID are encoded in the URL; arbitrary search text stays in memory. Captures are not persisted to browser storage. Reloading a link retrieves the current server buffer. Missing selected requests display an explicit unavailable/expired-evidence explanation. A link preserves scope, not an immutable incident snapshot.
+
+## Live and freshness
+
+Live checks the history API every five seconds while the workspace is mounted. New snapshots are buffered: Apply updates the displayed population deliberately, without rows shifting beneath an investigation. Pause stops scheduled checks; an already-started check may finish. Manual Refresh immediately applies the latest snapshot. Neither control affects proxy capture, server metrics, or external alert evaluation.
+
+The workspace reports the displayed snapshot's retrieval time and the last check. API errors preserve the displayed evidence and show Retry. A pending snapshot is bounded by the server history capacity and replaced by each successful check. Requests that arrive and expire between checks cannot be recovered; this is not a lossless event stream.
+
+## Overview populations
+
+Overview groups the same filtered evidence by host and sanitized captured path, not inferred route templates. Investigate carries the scope into Traffic. Time trends use equal buckets across the matching retained window; bucket drill-downs use exact inclusive timestamp bounds. Counts are retained samples, not a complete traffic rate. Empty buckets mean no retained samples, not proven inactivity.
+
+Failure counts include HTTP 4xx/5xx and explicit incomplete outcomes (interruption, client cancellation, inspection rejection, upstream failure, proxy error). Unknown outcomes without an HTTP failure are not inferred to be failures.
+
+Latency is nearest-rank p95 **time to upstream response headers**, with the sample count shown. Tunnels and attempts without upstream responses are excluded. Streaming body lifetime is not included. Request types are not classified: long polling that delays headers can still affect latency, so the UI explicitly discloses this limitation and provides host/path/method scoping. Total duration remains available per request. These statistics are not SLOs, ordinary-request-only percentiles, or process-lifetime metrics.
+
+## Validation
+
+Unit tests verify grouping/drill-down population equality, time-bucket partition boundaries, empty samples, and latency exclusion of tunnels/failed attempts. Existing sanitization and prefix API tests remain in the dashboard safety suite. Browser acceptance includes filtering a host's failures, adjacent inspection, Traffic → Overview → Traffic context preservation, live arrival buffering, explicit missing evidence, and responsive inspection.
+
+Focused alert rules, durable incident snapshots, protocol classification, and capture-to-draft reproduction remain separate roadmap work.