diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 50ef52f..98f8e1f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/Makefile b/Makefile index bc269bc..4077bc8 100644 --- a/Makefile +++ b/Makefile @@ -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: diff --git a/README.md b/README.md index 922111a..93048c4 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/cmd/netkit/main.go b/cmd/netkit/main.go index 9df769a..b1e3e7a 100644 --- a/cmd/netkit/main.go +++ b/cmd/netkit/main.go @@ -5,6 +5,7 @@ import ( "log" "os" "os/signal" + "strings" "syscall" "github.com/biancarosa/netkit/internal/proxy" @@ -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") @@ -68,6 +70,7 @@ func runServe() { config := &proxy.Config{ InspectionResponseHeaderTimeout: *inspectionTimeout, InspectionCA: ca, + RedactFields: strings.Split(*redactFields, ","), Port: *port, AdminPort: *adminPort, HistorySize: *historySize, diff --git a/dashboard/.gitignore b/dashboard/.gitignore index 5ef6a52..46348db 100644 --- a/dashboard/.gitignore +++ b/dashboard/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +.unit-test-build/ diff --git a/dashboard/package.json b/dashboard/package.json index e973eb7..42c5f00 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -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", diff --git a/dashboard/src/components/CaptureNotice.tsx b/dashboard/src/components/CaptureNotice.tsx new file mode 100644 index 0000000..af1eb1f --- /dev/null +++ b/dashboard/src/components/CaptureNotice.tsx @@ -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
+ +
+

{lastRefresh ? 'Showing saved results · refresh failed' : 'Capture data unavailable'}

+

{lastRefresh ? `Last successful update: ${lastRefresh.toLocaleString()}. These results may be out of date.` : 'Check the API connection, then try again.'}

+
+ +
; +} diff --git a/dashboard/src/components/Header.tsx b/dashboard/src/components/Header.tsx index f0d8766..a88f9c0 100644 --- a/dashboard/src/components/Header.tsx +++ b/dashboard/src/components/Header.tsx @@ -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(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 ( - -
- Checking... - - ); - case 'online': - return ( - -
- Proxy Online - - ); - case 'offline': - return ( - -
- Proxy Offline - - ); - } - }; - - 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 ( -
-
-
-
-
- -
-
-

netkit

-

HTTP Proxy Dashboard

-
-
- - {/* Navigation */} - - -
- {getStatusBadge()} - - - - - - {proxyAddress} - - - -

Proxy Server Address

-

Configure your HTTP client to use this proxy when exposed

-
-
-
- - - - - {adminAddress} - - - -

Admin Server Port

-

Health checks and metrics endpoint

-
-
-
-
-
- -
- - - - - - -
-

How to use:

-
    -
  • • Enter an API URL and click Send
  • -
  • • Configure headers and request body as needed
  • -
  • • View responses in real-time
  • -
  • • Access request history in the sidebar
  • -
-
-
-
-
- - - - - - - Settings (Coming Soon) - - -
+ return
+
+ netkit + +
+ + {status} + {checkedAt && · checked {Math.max(0, Math.floor((now - checkedAt.getTime()) / 1000))}s ago} +
+
+
+
+ {info ? (info.capture_mode === 'http_and_https_inspection' ? 'HTTP + HTTPS inspection' : 'HTTP inspection · HTTPS tunnels') : 'Capture scope unavailable'} + {info && {info.history_capacity.toLocaleString()} record capacity · in memory} + Sensitive fields masked
-
- ); +
+ 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 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.' : ''}

+

Proxy: {apiService.getProxyDisplayAddress()}
Admin API: {apiService.getAdminDisplayAddress()}

+
+
+ {storageWarning &&

Unable to remove older saved drafts. Clear this site’s storage in your browser.

} +
+
; } diff --git a/dashboard/src/components/ProxyRequestsTable.tsx b/dashboard/src/components/ProxyRequestsTable.tsx index be40603..3fdcd65 100644 --- a/dashboard/src/components/ProxyRequestsTable.tsx +++ b/dashboard/src/components/ProxyRequestsTable.tsx @@ -1,5 +1,9 @@ 'use client'; +import { CaptureNotice } from './CaptureNotice'; + +import { outcomeLabel } from '../lib/inspection'; + import React, { useState, useEffect, useMemo } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; @@ -10,18 +14,17 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '. import { ScrollArea } from './ui/scroll-area'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from './ui/dialog'; -import { - Server, - RefreshCw, - Search, - Filter, - ExternalLink, - Eye, - ChevronDown, +import { + Server, + RefreshCw, + Search, + Filter, + Copy, + Eye, + ChevronDown, ChevronUp, Clock, Database, - Globe, AlertCircle } from 'lucide-react'; import { apiService, BackendRequestRecord } from '../services/api'; @@ -38,7 +41,8 @@ interface SortConfig { export function ProxyRequestsTable() { const [requests, setRequests] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); const [searchTerm, setSearchTerm] = useState(''); const [methodFilter, setMethodFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all'); @@ -53,12 +57,13 @@ export function ProxyRequestsTable() { const fetchRequests = async () => { setIsLoading(true); + setError(null); try { const records = await apiService.getRequestHistory(); setRequests(records); setLastRefresh(new Date()); - } catch (error) { - console.error('Failed to fetch requests history:', error); + } catch { + setError('Cannot refresh traffic. Check the API connection and retry.'); } finally { setIsLoading(false); } @@ -76,13 +81,13 @@ export function ProxyRequestsTable() { // Filter and sort requests const filteredAndSortedRequests = useMemo(() => { const filtered = requests.filter(request => { - const matchesSearch = searchTerm === '' || + const matchesSearch = searchTerm === '' || request.url.toLowerCase().includes(searchTerm.toLowerCase()) || request.method.toLowerCase().includes(searchTerm.toLowerCase()); - + const matchesMethod = methodFilter === 'all' || request.method === methodFilter; - - const matchesStatus = statusFilter === 'all' || + + const matchesStatus = statusFilter === 'all' || (statusFilter === 'success' && request.success) || (statusFilter === 'error' && !request.success) || (statusFilter === '2xx' && request.response_status >= 200 && request.response_status < 300) || @@ -201,16 +206,16 @@ export function ProxyRequestsTable() {
- +
Requests History - {filteredAndSortedRequests.length} of {requests.length} + {lastRefresh ? `${filteredAndSortedRequests.length} of ${requests.length}` : 'Not loaded'}
{lastRefresh && ( - Updated {formatDistanceToNow(lastRefresh, { addSuffix: true })} + Updated {lastRefresh.toLocaleTimeString()} )}
diff --git a/dashboard/src/components/RequestStats.tsx b/dashboard/src/components/RequestStats.tsx index 035f456..bd96ae6 100644 --- a/dashboard/src/components/RequestStats.tsx +++ b/dashboard/src/components/RequestStats.tsx @@ -15,7 +15,7 @@ interface RequestStatsProps { export function RequestStats({ className }: RequestStatsProps) { const [stats, setStats] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const { onRefresh } = useRefresh(); @@ -67,6 +67,9 @@ export function RequestStats({ className }: RequestStatsProps) { return ( + {stats &&

+ Retained: {stats.total_requests} / {stats.capacity}. Transfer counts, not HTTP success. +

}
@@ -92,14 +95,15 @@ export function RequestStats({ className }: RequestStatsProps) { - {error ? ( + {error && stats &&

{error}. Showing stale results; retry Refresh.

} + {error && !stats ? (

{error}

- ) : isLoading ? ( + ) : isLoading && !stats ? (
Loading stats...
@@ -110,14 +114,14 @@ export function RequestStats({ className }: RequestStatsProps) {
-
Success
+
Complete
{stats.success_count}
-
Errors
+
Incomplete
{stats.error_count}
@@ -125,14 +129,14 @@ export function RequestStats({ className }: RequestStatsProps) { {/* Success rate */}
- Success Rate + Transfer Completion = 90 ? 'bg-green-100 text-green-800' : getSuccessRate() >= 70 ? 'bg-yellow-100 text-yellow-800' : 'bg-red-100 text-red-800'} > - {getSuccessRate().toFixed(1)}% + {stats.total_requests ? `${getSuccessRate().toFixed(1)}%` : '—'}
@@ -140,26 +144,26 @@ export function RequestStats({ className }: RequestStatsProps) {
Avg Duration - {safeFormatDuration(stats.avg_duration_us)} + {stats.total_requests ? safeFormatDuration(stats.avg_duration_us) : '—'}
- Avg Upstream - {safeFormatDuration(stats.avg_upstream_latency_us)} + Avg to headers/attempt + {stats.total_requests ? safeFormatDuration(stats.avg_upstream_latency_us) : '—'}
- Avg Proxy - {safeFormatDuration(stats.avg_proxy_overhead_us)} + Avg other elapsed + {stats.total_requests ? safeFormatDuration(stats.avg_proxy_overhead_us) : '—'}
{/* Data transfer */}
- Total Requests + Request bytes {formatBytes(stats.total_request_size)}
- Total Responses + Response bytes {formatBytes(stats.total_response_size)}
diff --git a/dashboard/src/components/Sidebar.tsx b/dashboard/src/components/Sidebar.tsx index 461cdc7..8ed2b26 100644 --- a/dashboard/src/components/Sidebar.tsx +++ b/dashboard/src/components/Sidebar.tsx @@ -1,12 +1,14 @@ 'use client'; +import { outcomeLabel } from '../lib/inspection'; + import React, { useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; import { Button } from './ui/button'; import { ScrollArea } from './ui/scroll-area'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; -import { History, ExternalLink, Server, RefreshCw } from 'lucide-react'; +import { History, Copy, Server, RefreshCw } from 'lucide-react'; import { useRequestStore } from '../hooks/useRequestStore'; import { useRefresh } from '../hooks/useRefreshContext'; import { apiService, BackendRequestRecord } from '../services/api'; @@ -23,17 +25,19 @@ export function Sidebar() { const { onRefresh } = useRefresh(); const [backendHistory, setBackendHistory] = useState([]); - const [isLoadingBackend, setIsLoadingBackend] = useState(false); + const [isLoadingBackend, setIsLoadingBackend] = useState(true); + const [error, setError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); const fetchBackendHistory = async () => { setIsLoadingBackend(true); + setError(null); try { const records = await apiService.getRequestHistory(); setBackendHistory(records); setLastRefresh(new Date()); - } catch (error) { - console.error('Failed to fetch backend history:', error); + } catch { + setError("Traffic unavailable. Retry refresh; any displayed records are stale."); } finally { setIsLoadingBackend(false); } @@ -78,15 +82,6 @@ export function Sidebar() { } }; - const getStatusColor = (statusCode?: number) => { - if (!statusCode) return 'bg-gray-100 text-gray-800'; - if (statusCode >= 200 && statusCode < 300) return 'bg-green-100 text-green-800'; - if (statusCode >= 300 && statusCode < 400) return 'bg-blue-100 text-blue-800'; - if (statusCode >= 400 && statusCode < 500) return 'bg-yellow-100 text-yellow-800'; - if (statusCode >= 500) return 'bg-red-100 text-red-800'; - return 'bg-gray-100 text-gray-800'; - }; - const truncateUrl = (url: string, maxLength: number = 40) => { if (url.length <= maxLength) return url; return '...' + url.slice(-(maxLength - 3)); @@ -103,7 +98,7 @@ export function Sidebar() {
- {backendHistory.length} + {lastRefresh ? backendHistory.length : '—'} @@ -111,6 +106,7 @@ export function Sidebar() { - Open in new tab + Copy sanitized URL
diff --git a/dashboard/src/components/StatisticsOverview.tsx b/dashboard/src/components/StatisticsOverview.tsx index 169c036..c226a25 100644 --- a/dashboard/src/components/StatisticsOverview.tsx +++ b/dashboard/src/components/StatisticsOverview.tsx @@ -1,14 +1,16 @@ 'use client'; +import { CaptureNotice } from './CaptureNotice'; + import React, { useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; import { Badge } from './ui/badge'; import { Button } from './ui/button'; -import { - BarChart3, - CheckCircle, - XCircle, - RefreshCw, +import { + BarChart3, + CheckCircle, + XCircle, + RefreshCw, Clock, Database, TrendingUp, @@ -19,11 +21,10 @@ import { } from 'lucide-react'; import { apiService, RequestStats as RequestStatsType } from '../services/api'; import { useRefresh } from '../hooks/useRefreshContext'; -import { formatDistanceToNow } from 'date-fns'; export function StatisticsOverview() { const [stats, setStats] = useState(null); - const [isLoading, setIsLoading] = useState(false); + const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [lastRefresh, setLastRefresh] = useState(null); const { onRefresh } = useRefresh(); @@ -134,7 +135,7 @@ export function StatisticsOverview() { return 'text-red-500'; }; - if (error) { + if (error && !stats) { return (
@@ -160,6 +161,12 @@ export function StatisticsOverview() { return (
+ {error && } + {stats &&

+ Retained buffer: {stats.total_requests} / {stats.capacity} records. {stats.oldest_at && stats.newest_at ? `${new Date(stats.oldest_at).toLocaleString()} – ${new Date(stats.newest_at).toLocaleString()}.` : 'No captured time window.'} + +

} +
About these statistics

These statistics describe retained records only. Completion counts transfers, including tunnel establishment. Other elapsed time includes body transfer. Process-lifetime metrics are available from the metrics endpoint.

{/* Header */} @@ -174,7 +181,7 @@ export function StatisticsOverview() {
{lastRefresh && ( - Updated {formatDistanceToNow(lastRefresh, { addSuffix: true })} + Updated {lastRefresh.toLocaleTimeString()} )}