Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -368,3 +368,7 @@ idle-connection timeouts remain in effect.
## Trustworthy capture and metrics

Captured history is sanitized before retention. The dashboard separates HTTP status from transfer outcome, discloses capture/retention scope, and preserves stale data when refresh fails. Real cumulative counters and histograms survive history eviction and clearing. See [capture safety and signal semantics](docs/trustworthy-capture.md) for the redaction policy, `--redact-fields`, compatibility changes, timing definitions, and metrics.

### Investigation workspace

Traffic is the landing page, with preserved filters, adjacent request inspection, buffered Live/Pause, and an Overview that drills into matching retained evidence. Compose is available at `/compose/`. See [investigation workspace semantics](docs/investigation-workspace.md) for freshness, URL scope, latency populations, and retention limits.
9 changes: 5 additions & 4 deletions dashboard/e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test, expect } from '@playwright/test';

test.describe('Netkit Dashboard E2E', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/');
await page.goto('/compose/');
});

test('should load the dashboard', async ({ page }) => {
Expand Down Expand Up @@ -41,9 +41,10 @@ test.describe('Netkit Dashboard E2E', () => {
});

test('should display proxy request history', async ({ page }) => {
// Look for "Requests History" text which is visible in the sidebar
const historySection = page.locator('text=/Requests History/i').first();
await expect(historySection).toBeVisible();
await page.goto('/');
await expect(page.getByRole('heading', { name: 'Traffic', exact: true })).toBeVisible();
await expect(page.getByRole('region', { name: 'Traffic filters' })).toBeVisible();
await expect(page.getByLabel('Host', { exact: true })).toBeVisible();
});

test('should display request builder heading', async ({ page }) => {
Expand Down
2 changes: 1 addition & 1 deletion dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:report": "playwright show-report",
"test:unit": "tsc --module commonjs --target ES2022 --esModuleInterop --skipLibCheck --outDir .unit-test-build src/lib/inspection.test.ts src/services/api.test.ts && node --test .unit-test-build/lib/inspection.test.js .unit-test-build/services/api.test.js"
"test:unit": "tsc --module commonjs --target ES2022 --esModuleInterop --skipLibCheck --outDir .unit-test-build src/lib/inspection.test.ts src/lib/investigation.test.ts src/services/api.test.ts && node --test .unit-test-build/lib/inspection.test.js .unit-test-build/lib/investigation.test.js .unit-test-build/services/api.test.js"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
Expand Down
15 changes: 15 additions & 0 deletions dashboard/src/app/compose/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
"use client";
import { Header } from "../../components/Header";
import { RequestBuilder } from "../../components/RequestBuilder";
import { RefreshProvider } from "../../hooks/useRefreshContext";
export default function ComposePage() {
return (
<RefreshProvider>
<Header />
<main className="mx-auto max-w-6xl p-4 md:p-6">
<h1 className="mb-4 text-2xl font-semibold">Compose</h1>
<RequestBuilder />
</main>
</RefreshProvider>
);
}
37 changes: 10 additions & 27 deletions dashboard/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -1,30 +1,13 @@
'use client';

import { Header } from '../components/Header';
import { Sidebar } from '../components/Sidebar';
import { RequestBuilder } from '../components/RequestBuilder';
import { RequestStats } from '../components/RequestStats';
import { RefreshProvider } from '../hooks/useRefreshContext';

export default function Home() {
"use client";
import { Header } from "../components/Header";
import { InvestigationWorkspace } from "../components/InvestigationWorkspace";
export default function Page() {
return (
<RefreshProvider>
<div className="h-screen flex flex-col">
<Header />
<div className="flex flex-1 overflow-hidden">
<div className="flex flex-col w-80 border-r bg-background">
<div className="flex-1 min-h-0">
<Sidebar />
</div>
<div className="border-t">
<RequestStats className="rounded-none border-0" />
</div>
</div>
<main className="flex-1 p-6 overflow-auto">
<RequestBuilder />
</main>
</div>
</div>
</RefreshProvider>
<>
<Header />
<main className="p-4 md:p-6">
<InvestigationWorkspace />
</main>
</>
);
}
27 changes: 11 additions & 16 deletions dashboard/src/app/requests-history/page.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
'use client';

import { Header } from '../../components/Header';
import { ProxyRequestsTable } from '../../components/ProxyRequestsTable';
import { RefreshProvider } from '../../hooks/useRefreshContext';

export default function RequestsHistoryPage() {
"use client";
import { Header } from "../../components/Header";
import { InvestigationWorkspace } from "../../components/InvestigationWorkspace";
export default function Page() {
return (
<RefreshProvider>
<div className="h-screen flex flex-col">
<Header />
<main className="flex-1 p-6 overflow-auto">
<ProxyRequestsTable />
</main>
</div>
</RefreshProvider>
<>
<Header />
<main className="p-4 md:p-6">
<InvestigationWorkspace />
</main>
</>
);
}
}
27 changes: 11 additions & 16 deletions dashboard/src/app/requests-statistics/page.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,13 @@
'use client';

import { Header } from '../../components/Header';
import { StatisticsOverview } from '../../components/StatisticsOverview';
import { RefreshProvider } from '../../hooks/useRefreshContext';

export default function RequestsStatisticsPage() {
"use client";
import { Header } from "../../components/Header";
import { InvestigationWorkspace } from "../../components/InvestigationWorkspace";
export default function Page() {
return (
<RefreshProvider>
<div className="h-screen flex flex-col">
<Header />
<main className="flex-1 p-6 overflow-auto">
<StatisticsOverview />
</main>
</div>
</RefreshProvider>
<>
<Header />
<main className="p-4 md:p-6">
<InvestigationWorkspace overview />
</main>
</>
);
}
}
207 changes: 164 additions & 43 deletions dashboard/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,62 +1,183 @@
'use client';
"use client";

import { useEffect, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { ShieldCheck, Zap } from 'lucide-react';
import { apiService } from '../services/api';
import { cn } from '../lib/utils';
import { useEffect, useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ShieldCheck, Zap } from "lucide-react";
import { apiService } from "../services/api";
import { useInvestigation } from "../hooks/useInvestigation";
import { cn } from "../lib/utils";

export function Header() {
const [info, setInfo] = useState<{ capture_mode: string; history_capacity: number } | null>(null);
const [status, setStatus] = useState('Checking connection');
const [info, setInfo] = useState<{
capture_mode: string;
history_capacity: number;
} | null>(null);
const [status, setStatus] = useState("Checking connection");
const [checkedAt, setCheckedAt] = useState<Date | null>(null);
const [now, setNow] = useState(Date.now());
const [storageWarning, setStorageWarning] = useState(false);
const pathname = usePathname().replace(/\/$/, '') || '/';
const pathname = usePathname().replace(/\/$/, "") || "/";
const investigation = useInvestigation();
const workspaceNavigation = (
event: React.MouseEvent<HTMLAnchorElement>,
href: string,
) => {
if (
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey ||
!["/", "/requests-history", "/requests-statistics"].includes(pathname) ||
href === "/compose"
)
return;
event.preventDefault();
const url = new URL(event.currentTarget.href);
for (const [key, value] of Object.entries(investigation.scope))
if (value) url.searchParams.set(key, value);
if (investigation.selected)
url.searchParams.set("request", investigation.selected);
window.history.pushState(window.history.state, "", url);
window.dispatchEvent(new Event("netkit-navigation"));
};
useEffect(() => {
try { localStorage.removeItem('fetchr-request-store'); } catch { setStorageWarning(true); }
try {
localStorage.removeItem("fetchr-request-store");
} catch {
setStorageWarning(true);
}
let active = true;
const check = async () => {
try {
const result = await apiService.getCaptureInfo();
if (active) { setInfo(result); setStatus('Connected'); }
} catch { if (active) setStatus('Disconnected'); }
finally { if (active) setCheckedAt(new Date()); }
if (active) {
setInfo(result);
setStatus("Connected");
}
} catch {
if (active) setStatus("Disconnected");
} finally {
if (active) setCheckedAt(new Date());
}
};
void check();
const poll = setInterval(check, 30000);
const clock = setInterval(() => setNow(Date.now()), 1000);
return () => { active = false; clearInterval(poll); clearInterval(clock); };
return () => {
active = false;
clearInterval(poll);
clearInterval(clock);
};
}, []);
return <header className="border-b bg-background">
<div className="flex flex-wrap items-center justify-between gap-4 px-6 py-4">
<Link href="/" className="flex items-center gap-2 font-bold text-xl"><Zap className="h-6 w-6" />netkit</Link>
<nav aria-label="Main navigation" className="flex flex-wrap gap-1">
{[['/', 'Request builder'], ['/requests-history', 'Traffic'], ['/requests-statistics', 'Statistics']].map(([href, label]) => <Link key={href} href={href} aria-current={pathname === href ? 'page' : undefined} className={cn('rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-muted', pathname === href ? 'bg-primary text-primary-foreground hover:bg-primary/90' : 'text-muted-foreground')}>{label}</Link>)}
</nav>
<div className="flex items-center gap-2 text-xs" title="Admin API reachability; checked every 30 seconds. Traffic refresh is manual.">
<span className={cn('h-2 w-2 rounded-full', status === 'Connected' ? 'bg-emerald-500' : status === 'Disconnected' ? 'bg-red-500' : 'bg-amber-500')} />
<span className="font-medium">{status}</span>
{checkedAt && <span className="text-muted-foreground">· checked {Math.max(0, Math.floor((now - checkedAt.getTime()) / 1000))}s ago</span>}
</div>
</div>
<div className="flex flex-wrap items-start justify-between gap-3 border-t bg-muted/30 px-6 py-3 text-xs">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-muted-foreground">
<span className="font-medium text-foreground">{info ? (info.capture_mode === 'http_and_https_inspection' ? 'HTTP + HTTPS inspection' : 'HTTP inspection · HTTPS tunnels') : 'Capture scope unavailable'}</span>
{info && <span>{info.history_capacity.toLocaleString()} record capacity · in memory</span>}
<span className="flex items-center gap-1"><ShieldCheck className="h-3.5 w-3.5" />Sensitive fields masked</span>
return (
<header className="border-b bg-background">
<div className="flex flex-wrap items-center justify-between gap-4 px-6 py-4">
<Link href="/" className="flex items-center gap-2 font-bold text-xl">
<Zap className="h-6 w-6" />
netkit
</Link>
<nav aria-label="Main navigation" className="flex flex-wrap gap-1">
{[
["/", "Traffic"],
["/compose", "Compose"],
["/requests-statistics", "Overview"],
].map(([href, label]) => (
<Link
key={href}
href={href}
onClick={(event) => workspaceNavigation(event, href)}
aria-current={pathname === href ? "page" : undefined}
className={cn(
"rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-muted",
pathname === href
? "bg-primary text-primary-foreground hover:bg-primary/90"
: "text-muted-foreground",
)}
>
{label}
</Link>
))}
</nav>
<div
className="flex items-center gap-2 text-xs"
title="Admin API reachability; checked every 30 seconds. Traffic refresh is controlled in the workspace."
>
<span
className={cn(
"h-2 w-2 rounded-full",
status === "Connected"
? "bg-emerald-500"
: status === "Disconnected"
? "bg-red-500"
: "bg-amber-500",
)}
/>
<span className="font-medium">{status}</span>
{checkedAt && (
<span className="text-muted-foreground">
· checked{" "}
{Math.max(0, Math.floor((now - checkedAt.getTime()) / 1000))}s ago
</span>
)}
</div>
</div>
<details className="max-w-xl text-muted-foreground">
<summary className="cursor-pointer font-medium text-foreground">Capture policy & connection</summary>
<div className="mt-3 space-y-2 leading-relaxed">
<p>Only complete JSON bodies up to 64 KiB are retained. Masking follows configured field names and patterns; unfamiliar secrets may need custom fields.</p>
<p>History is lost on restart. Drafts stay in this tab. Traffic refresh is manual.</p>
<p>{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.' : ''}</p>
<p className="break-all">Proxy: {apiService.getProxyDisplayAddress()}<br />Admin API: {apiService.getAdminDisplayAddress()}</p>
<div className="flex flex-wrap items-start justify-between gap-3 border-t bg-muted/30 px-6 py-3 text-xs">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2 text-muted-foreground">
<span className="font-medium text-foreground">
{info
? info.capture_mode === "http_and_https_inspection"
? "HTTP + HTTPS inspection"
: "HTTP inspection · HTTPS tunnels"
: "Capture scope unavailable"}
</span>
{info && (
<span>
{info.history_capacity.toLocaleString()} record capacity · in
memory
</span>
)}
<span className="flex items-center gap-1">
<ShieldCheck className="h-3.5 w-3.5" />
Sensitive fields masked
</span>
</div>
</details>
{storageWarning && <p role="alert" className="w-full text-red-700">Unable to remove older saved drafts. Clear this site’s storage in your browser.</p>}
</div>
</header>;
<details className="max-w-xl text-muted-foreground">
<summary className="cursor-pointer font-medium text-foreground">
Capture policy & connection
</summary>
<div className="mt-3 space-y-2 leading-relaxed">
<p>
Only complete JSON bodies up to 64 KiB are retained. Masking
follows configured field names and patterns; unfamiliar secrets
may need custom fields.
</p>
<p>
History is lost on restart. Drafts stay in this tab. Traffic
refresh is controlled in the workspace.
</p>
<p>
{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."
: ""}
</p>
<p className="break-all">
Proxy: {apiService.getProxyDisplayAddress()}
<br />
Admin API: {apiService.getAdminDisplayAddress()}
</p>
</div>
</details>
{storageWarning && (
<p role="alert" className="w-full text-red-700">
Unable to remove older saved drafts. Clear this site’s storage in
your browser.
</p>
)}
</div>
</header>
);
}
Loading
Loading