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
20 changes: 20 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,26 @@ jobs:
path: ${{ runner.temp }}/platform-doctor-report.md
retention-days: 30

dashboard:
name: Dashboard Safety and Build
runs-on: ubuntu-latest
timeout-minutes: 10
defaults:
run:
working-directory: dashboard
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: dashboard/package-lock.json
- run: npm ci
- run: npm run test:unit
- run: npx tsc --noEmit
- run: npx eslint src
- run: npm run build

test:
name: Test and Lint
runs-on: ubuntu-latest
Expand Down
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -261,7 +261,7 @@ test-docker-dashboard:

# Run end-to-end tests
e2e:
$(GO) test -v -race -coverprofile=coverage-e2e.txt -covermode=atomic ./test/e2e/... -tags=e2e
$(GO) test -count=1 -v -race -coverprofile=coverage-e2e.txt -covermode=atomic ./test/e2e/... -tags=e2e

# Run golangci-lint for Go code
lint:
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,3 +364,7 @@ requests can wait for a response. Client cancellation still cancels the upstream
request. To impose a deadline, use `--inspection-response-header-timeout=90s`
with a duration longer than the upstream polling interval. TLS handshake and
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.
3 changes: 3 additions & 0 deletions cmd/netkit/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"log"
"os"
"os/signal"
"strings"
"syscall"

"github.com/biancarosa/netkit/internal/proxy"
Expand Down Expand Up @@ -49,6 +50,7 @@ func runServe() {
caCert := flag.String("ca-cert", "", "Inspection CA certificate PEM file")
caKey := flag.String("ca-key", "", "Inspection CA private key PEM file")
inspectionTimeout := flag.Duration("inspection-response-header-timeout", 0, "Upstream response-header timeout in inspection mode (0 disables; allow for long polling)")
redactFields := flag.String("redact-fields", "", "Comma-separated additional JSON/header field names to redact from captures")
flag.Parse()
if *inspectionTimeout < 0 {
log.Fatal("--inspection-response-header-timeout must not be negative")
Expand All @@ -68,6 +70,7 @@ func runServe() {
config := &proxy.Config{
InspectionResponseHeaderTimeout: *inspectionTimeout,
InspectionCA: ca,
RedactFields: strings.Split(*redactFields, ","),
Port: *port,
AdminPort: *adminPort,
HistorySize: *historySize,
Expand Down
2 changes: 2 additions & 0 deletions dashboard/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,5 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts

.unit-test-build/
3 changes: 2 additions & 1 deletion dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:headed": "playwright test --headed",
"test:e2e:report": "playwright show-report"
"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"
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.10",
Expand Down
15 changes: 15 additions & 0 deletions dashboard/src/components/CaptureNotice.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
'use client';

import { AlertTriangle, RefreshCw } from 'lucide-react';
import { Button } from './ui/button';

export function CaptureNotice({ lastRefresh, loading, onRetry }: { lastRefresh: Date | null; loading: boolean; onRetry: () => void }) {
return <div role="alert" className="flex flex-wrap items-center gap-3 rounded-lg border border-amber-300 bg-amber-50 p-4 text-amber-950">
<AlertTriangle className="h-5 w-5 shrink-0" />
<div className="min-w-0 flex-1 text-sm">
<p className="font-semibold">{lastRefresh ? 'Showing saved results · refresh failed' : 'Capture data unavailable'}</p>
<p className="mt-1 text-xs">{lastRefresh ? `Last successful update: ${lastRefresh.toLocaleString()}. These results may be out of date.` : 'Check the API connection, then try again.'}</p>
</div>
<Button variant="outline" size="sm" onClick={onRetry} disabled={loading} className="bg-background text-foreground"><RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} />Retry</Button>
</div>;
}
217 changes: 48 additions & 169 deletions dashboard/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,183 +1,62 @@
'use client';

import React, { useEffect, useState } from 'react';
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip';
import { Globe, Settings, Info, Zap, Home, Server, BarChart3 } from 'lucide-react';
import { ShieldCheck, Zap } from 'lucide-react';
import { apiService } from '../services/api';
import { cn } from '../lib/utils';

export function Header() {
const [proxyStatus, setProxyStatus] = useState<'checking' | 'online' | 'offline'>('checking');
const pathname = usePathname();
const proxyAddress = apiService.getProxyDisplayAddress();
const adminAddress = apiService.getAdminDisplayAddress();

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(/\/$/, '') || '/';
useEffect(() => {
const checkProxyStatus = async () => {
try { localStorage.removeItem('fetchr-request-store'); } catch { setStorageWarning(true); }
let active = true;
const check = async () => {
try {
const isOnline = await apiService.checkProxyHealth();
setProxyStatus(isOnline ? 'online' : 'offline');
} catch {
setProxyStatus('offline');
}
const result = await apiService.getCaptureInfo();
if (active) { setInfo(result); setStatus('Connected'); }
} catch { if (active) setStatus('Disconnected'); }
finally { if (active) setCheckedAt(new Date()); }
};

checkProxyStatus();
const interval = setInterval(checkProxyStatus, 30000); // Check every 30 seconds

return () => clearInterval(interval);
void check();
const poll = setInterval(check, 30000);
const clock = setInterval(() => setNow(Date.now()), 1000);
return () => { active = false; clearInterval(poll); clearInterval(clock); };
}, []);

const getStatusBadge = () => {
switch (proxyStatus) {
case 'checking':
return (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-800">
<div className="w-2 h-2 bg-yellow-500 rounded-full mr-2 animate-pulse" />
Checking...
</Badge>
);
case 'online':
return (
<Badge variant="secondary" className="bg-green-100 text-green-800">
<div className="w-2 h-2 bg-green-500 rounded-full mr-2" />
Proxy Online
</Badge>
);
case 'offline':
return (
<Badge variant="secondary" className="bg-red-100 text-red-800">
<div className="w-2 h-2 bg-red-500 rounded-full mr-2" />
Proxy Offline
</Badge>
);
}
};

const navItems = [
{
href: '/',
label: 'Request Builder',
icon: Home,
isActive: pathname === '/'
},
{
href: '/requests-history',
label: 'Requests History',
icon: Server,
isActive: pathname === '/requests-history'
},
{
href: '/requests-statistics',
label: 'Requests Statistics',
icon: BarChart3,
isActive: pathname === '/requests-statistics'
}
];

return (
<header className="border-b bg-background">
<div className="flex items-center justify-between h-16 px-6">
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<div className="flex items-center justify-center w-8 h-8 bg-primary rounded-lg">
<Zap className="h-5 w-5 text-primary-foreground" />
</div>
<div>
<h1 className="text-xl font-bold">netkit</h1>
<p className="text-xs text-muted-foreground">HTTP Proxy Dashboard</p>
</div>
</div>

{/* Navigation */}
<nav className="flex items-center gap-1">
{navItems.map((item) => (
<Link key={item.href} href={item.href}>
<Button
variant={item.isActive ? "default" : "ghost"}
size="sm"
className={cn(
"flex items-center gap-2",
item.isActive && "bg-primary text-primary-foreground"
)}
>
<item.icon className="h-4 w-4" />
{item.label}
</Button>
</Link>
))}
</nav>

<div className="flex items-center gap-2">
{getStatusBadge()}
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-xs">
<Globe className="h-3 w-3 mr-1" />
{proxyAddress}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Proxy Server Address</p>
<p className="text-xs text-muted-foreground">Configure your HTTP client to use this proxy when exposed</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Badge variant="outline" className="text-xs bg-blue-50">
{adminAddress}
</Badge>
</TooltipTrigger>
<TooltipContent>
<p>Admin Server Port</p>
<p className="text-xs text-muted-foreground">Health checks and metrics endpoint</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
</div>

<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm">
<Info className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
<div className="max-w-sm">
<p className="font-medium">How to use:</p>
<ul className="text-xs mt-1 space-y-1">
<li>• Enter an API URL and click Send</li>
<li>• Configure headers and request body as needed</li>
<li>• View responses in real-time</li>
<li>• Access request history in the sidebar</li>
</ul>
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>

<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm">
<Settings className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Settings (Coming Soon)</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
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>
</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 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>
</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