diff --git a/src/App.tsx b/src/App.tsx index ad813ffc..d95ce5ee 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -95,6 +95,7 @@ const DashboardFinancialHistoryScreen = lazy(() => import('./screens/dashboard-f const DashboardFinancialLiveScreen = lazy(() => import('./screens/dashboard-financial-live.screen')); const DashboardFinancialExpensesScreen = lazy(() => import('./screens/dashboard-financial-expenses.screen')); const DashboardFinancialLiquidityScreen = lazy(() => import('./screens/dashboard-financial-liquidity.screen')); +const DashboardRealunitTracingScreen = lazy(() => import('./screens/dashboard-realunit-tracing.screen')); const SitemapScreen = lazy(() => import('./screens/sitemap.screen')); setupLanguages(); @@ -548,6 +549,10 @@ export const Routes = [ }, ], }, + { + path: 'realunit-tracing', + element: withSuspense(), + }, ], }, ], diff --git a/src/hooks/realunit-tracing.hook.ts b/src/hooks/realunit-tracing.hook.ts new file mode 100644 index 00000000..6bc288ab --- /dev/null +++ b/src/hooks/realunit-tracing.hook.ts @@ -0,0 +1,72 @@ +import { useApi } from '@dfx.swiss/react'; +import { useMemo } from 'react'; + +export interface LogQueryColumn { + name: string; + type: string; +} + +export interface LogQueryResult { + columns: LogQueryColumn[]; + rows: unknown[][]; +} + +export interface ParsedTrace { + timestamp: string; + method: string; + url: string; + pathPattern: string; + status: number; + durationMs: number; + client: string; + ip: string; +} + +const TRACE_HEADLINE_RE = + /^\[RealUnitTrace\]\s+([A-Z]+)\s+(\S+)\s+→\s+(\d{3})\s+\((\d+)ms\)\s+client=(\S+?)\s+ip=(\S+)/; + +function normalizePath(url: string): string { + const path = url.split('?')[0]; + return path + .split('/') + .map((seg) => { + if (/^0x[a-f0-9]{40}$/i.test(seg)) return ':address'; + if (/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/i.test(seg)) return ':uuid'; + if (/^\d+$/.test(seg)) return ':id'; + return seg; + }) + .join('/'); +} + +export function parseTrace(timestamp: string, message: string): ParsedTrace | null { + const m = message.match(TRACE_HEADLINE_RE); + if (!m) return null; + return { + timestamp, + method: m[1], + url: m[2], + pathPattern: normalizePath(m[2]), + status: parseInt(m[3], 10), + durationMs: parseInt(m[4], 10), + client: m[5], + ip: m[6], + }; +} + +export function useRealunitTracing() { + const { call } = useApi(); + + async function getRealunitTraces(hours: number): Promise { + return call({ + url: 'gs/debug/logs', + method: 'POST', + data: { + template: 'traces-by-message', + messageFilter: 'RealUnitTrace', + hours, + }, + }); + } + + return useMemo(() => ({ getRealunitTraces }), [call]); +} diff --git a/src/screens/dashboard-realunit-tracing.screen.tsx b/src/screens/dashboard-realunit-tracing.screen.tsx new file mode 100644 index 00000000..b80835cb --- /dev/null +++ b/src/screens/dashboard-realunit-tracing.screen.tsx @@ -0,0 +1,398 @@ +import { useSessionContext } from '@dfx.swiss/react'; +import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; +import { useEffect, useMemo, useState } from 'react'; +import { useAdminGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; +import { LogQueryResult, ParsedTrace, parseTrace, useRealunitTracing } from 'src/hooks/realunit-tracing.hook'; + +// KQL granularity is hours; entries with `tightenToMs` are filtered client-side to a tighter window. +const TIME_RANGES: { label: string; hours: number; tightenToMs?: number }[] = [ + { label: '1 h', hours: 1 }, + { label: '15 min', hours: 1, tightenToMs: 15 * 60 * 1000 }, + { label: '6 h', hours: 6 }, + { label: '24 h', hours: 24 }, +]; +const REFRESH_MS = 5000; +const SLOW_MS = 2000; +const VERY_SLOW_MS = 5000; + +function median(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 === 0 ? Math.round((sorted[mid - 1] + sorted[mid]) / 2) : sorted[mid]; +} + +function percentile(values: number[], p: number): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const rank = (p / 100) * (sorted.length - 1); + const lo = Math.floor(rank); + const hi = Math.ceil(rank); + if (lo === hi) return sorted[lo]; + return Math.round(sorted[lo] + (sorted[hi] - sorted[lo]) * (rank - lo)); +} + +function statusColor(status: number): string { + if (status >= 500) return '#ef4444'; + if (status >= 400) return '#f59e0b'; + if (status >= 300) return '#3b82f6'; + return '#22c55e'; +} + +function durationColor(ms: number): string { + if (ms >= VERY_SLOW_MS) return '#ef4444'; + if (ms >= SLOW_MS) return '#f59e0b'; + return '#111827'; +} + +function formatMs(ms: number): string { + return ms >= 1000 ? `${(ms / 1000).toFixed(2)} s` : `${ms} ms`; +} + +function formatTime(iso: string): string { + return new Date(iso).toLocaleTimeString('de-CH', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +} + +function colIdx(result: LogQueryResult, name: string): number { + return result.columns.findIndex((c) => c.name === name); +} + +function rowsToTraces(result: LogQueryResult): ParsedTrace[] { + const tsIdx = colIdx(result, 'timestamp'); + const msgIdx = colIdx(result, 'message'); + if (tsIdx === -1 || msgIdx === -1) return []; + return result.rows + .map((row) => parseTrace(String(row[tsIdx]), String(row[msgIdx]))) + .filter((t): t is ParsedTrace => t !== null); +} + +interface SummaryCardProps { + label: string; + value: string; + color?: string; +} + +function SummaryCard({ label, value, color }: SummaryCardProps) { + return ( +
+
+ {label} +
+
+ {value} +
+
+ ); +} + +interface EndpointStat { + key: string; + method: string; + pathPattern: string; + count: number; + median: number; + p95: number; + errors: number; +} + +function aggregateEndpoints(traces: ParsedTrace[]): EndpointStat[] { + const buckets = new Map(); + for (const t of traces) { + const key = `${t.method} ${t.pathPattern}`; + const arr = buckets.get(key) ?? []; + arr.push(t); + buckets.set(key, arr); + } + return Array.from(buckets.entries()) + .map(([key, arr]) => { + const durations = arr.map((t) => t.durationMs); + return { + key, + method: arr[0].method, + pathPattern: arr[0].pathPattern, + count: arr.length, + median: median(durations), + p95: percentile(durations, 95), + errors: arr.filter((t) => t.status >= 400).length, + }; + }) + .sort((a, b) => b.count - a.count); +} + +interface IpStat { + ip: string; + count: number; + lastSeen: string; +} + +function aggregateIps(traces: ParsedTrace[]): IpStat[] { + const buckets = new Map(); + for (const t of traces) { + const arr = buckets.get(t.ip) ?? []; + arr.push(t); + buckets.set(t.ip, arr); + } + return Array.from(buckets.entries()) + .map(([ip, arr]) => ({ + ip, + count: arr.length, + lastSeen: arr.reduce( + (max, t) => (new Date(t.timestamp).getTime() > new Date(max).getTime() ? t.timestamp : max), + arr[0].timestamp, + ), + })) + .sort((a, b) => b.count - a.count); +} + +export default function DashboardRealunitTracingScreen(): JSX.Element { + useAdminGuard(); + useLayoutOptions({ title: 'RealUnit Tracing', noMaxWidth: true }); + + const { isLoggedIn } = useSessionContext(); + const { getRealunitTraces } = useRealunitTracing(); + + const [rangeIdx, setRangeIdx] = useState(0); // default: 1h (first option) + const [traces, setTraces] = useState([]); + const [lastFetched, setLastFetched] = useState(null); + const [fetchError, setFetchError] = useState(null); + const [isInitialLoading, setIsInitialLoading] = useState(true); + const [isRefreshing, setIsRefreshing] = useState(false); + + useEffect(() => { + if (!isLoggedIn) return; + let cancelled = false; + + const run = async () => { + setIsRefreshing(true); + try { + const range = TIME_RANGES[rangeIdx]; + const result = await getRealunitTraces(range.hours); + if (cancelled) return; + const parsed = rowsToTraces(result); + // Ranges with `tightenToMs` reuse a coarser KQL window and are filtered client-side. + const cutoff = range.tightenToMs ? Date.now() - range.tightenToMs : 0; + setTraces(cutoff ? parsed.filter((t) => new Date(t.timestamp).getTime() >= cutoff) : parsed); + setLastFetched(new Date()); + setFetchError(null); + } catch (e) { + if (cancelled) return; + setFetchError(e instanceof Error ? e.message : 'unknown error'); + } finally { + if (!cancelled) { + setIsInitialLoading(false); + setIsRefreshing(false); + } + } + }; + + run(); + const interval = setInterval(run, REFRESH_MS); + return () => { + cancelled = true; + clearInterval(interval); + }; + }, [isLoggedIn, rangeIdx, getRealunitTraces]); + + const stats = useMemo(() => { + const total = traces.length; + const errors5xx = traces.filter((t) => t.status >= 500).length; + const errors4xx = traces.filter((t) => t.status >= 400 && t.status < 500).length; + const slow = traces.filter((t) => t.durationMs >= SLOW_MS).length; + return { total, errors5xx, errors4xx, slow }; + }, [traces]); + + const endpoints = useMemo(() => aggregateEndpoints(traces).slice(0, 12), [traces]); + const ips = useMemo(() => aggregateIps(traces).slice(0, 10), [traces]); + const recent = useMemo( + () => + [...traces] + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, 30), + [traces], + ); + + if (isInitialLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Toolbar */} +
+
+ {TIME_RANGES.map((r, i) => ( + + ))} +
+
+ {fetchError ? ( + Fetch failed: {fetchError} + ) : ( + + + live · refresh {REFRESH_MS / 1000}s + + )} + last update {lastFetched ? lastFetched.toLocaleTimeString('de-CH') : '-'} + {isRefreshing && loading…} +
+
+ + {/* Summary */} +
+ + 0 ? '#ef4444' : undefined} /> + 0 ? '#f59e0b' : undefined} /> + 0 ? '#f59e0b' : undefined} /> +
+ +
+ {/* Top Endpoints */} +
+
Top Endpoints
+
+ + + + + + + + + + + + {endpoints.map((e) => ( + + + + + + + + ))} + {endpoints.length === 0 && ( + + + + )} + +
EndpointCountErrorsMedianp95
+ {e.method} + {e.pathPattern} + {e.count} 0 ? '#f59e0b' : '#6b7280' }}> + {e.errors} + + {formatMs(e.median)} + + {formatMs(e.p95)} +
+ No traces in window +
+
+
+ + {/* Top IPs */} +
+
Top IPs
+ + + + + + + + + + {ips.map((ip) => ( + + + + + + ))} + {ips.length === 0 && ( + + + + )} + +
IPCallsLast seen
{ip.ip}{ip.count} + {formatTime(ip.lastSeen)} +
+ - +
+
+
+ + {/* Recent Activity */} +
+
Recent Activity (last {recent.length})
+
+ + + + + + + + + + + + + + {recent.map((t) => ( + + + + + + + + + + ))} + {recent.length === 0 && ( + + + + )} + +
TimeMethodURLStatusDurationClientIP
{formatTime(t.timestamp)}{t.method}{t.url} + {t.status} + + {formatMs(t.durationMs)} + {t.client} + {t.ip} +
+ No traces yet +
+
+
+
+ ); +} diff --git a/src/screens/dashboard.screen.tsx b/src/screens/dashboard.screen.tsx index c1eeb4a9..7cb359be 100644 --- a/src/screens/dashboard.screen.tsx +++ b/src/screens/dashboard.screen.tsx @@ -19,6 +19,15 @@ export default function DashboardScreen(): JSX.Element { Balance overview, history, liquidity & expenses +
navigate('/dashboard/realunit-tracing')} + > +
RealUnit Tracing
+
+ Live API-call tracing for the RealUnit wallet (test phase) +
+
); }