diff --git a/src/App.tsx b/src/App.tsx index d95ce5ee..34c2e983 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -95,7 +95,9 @@ 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 DashboardLogTracingScreen = lazy(() => import('./screens/dashboard-log-tracing.screen')); +const DashboardLogTracingRealunitScreen = lazy(() => import('./screens/dashboard-log-tracing-realunit.screen')); +const DashboardLogTracingAllScreen = lazy(() => import('./screens/dashboard-log-tracing-all.screen')); const SitemapScreen = lazy(() => import('./screens/sitemap.screen')); setupLanguages(); @@ -550,8 +552,21 @@ export const Routes = [ ], }, { - path: 'realunit-tracing', - element: withSuspense(), + path: 'log-tracing', + children: [ + { + index: true, + element: withSuspense(), + }, + { + path: 'realunit', + element: withSuspense(), + }, + { + path: 'all', + element: withSuspense(), + }, + ], }, ], }, diff --git a/src/components/dashboard/log-trace-time-chart.tsx b/src/components/dashboard/log-trace-time-chart.tsx new file mode 100644 index 00000000..0d3b49ad --- /dev/null +++ b/src/components/dashboard/log-trace-time-chart.tsx @@ -0,0 +1,90 @@ +import { ApexOptions } from 'apexcharts'; +import { useMemo } from 'react'; +import Chart from 'react-apexcharts'; +import { ParsedTrace } from 'src/hooks/log-tracing.hook'; + +interface Props { + traces: ParsedTrace[]; + windowMs: number; + binMs: number; + endTime: number; + dark?: boolean; +} + +interface Bucket { + normal: number; + errors: number; +} + +function bucketize(traces: ParsedTrace[], startTime: number, endTime: number, binMs: number): Bucket[] { + const numBins = Math.max(1, Math.ceil((endTime - startTime) / binMs)); + const buckets: Bucket[] = Array.from({ length: numBins }, () => ({ normal: 0, errors: 0 })); + for (const t of traces) { + const ts = new Date(t.timestamp).getTime(); + if (ts < startTime || ts > endTime) continue; + const idx = Math.min(numBins - 1, Math.floor((ts - startTime) / binMs)); + if (t.status >= 400) { + buckets[idx].errors += 1; + } else { + buckets[idx].normal += 1; + } + } + return buckets; +} + +export function LogTraceTimeChart({ traces, windowMs, binMs, endTime, dark }: Props): JSX.Element { + const series = useMemo(() => { + const startTime = endTime - windowMs; + const buckets = bucketize(traces, startTime, endTime, binMs); + const normalData = buckets.map((b, i) => [startTime + i * binMs, b.normal] as [number, number]); + const errorData = buckets.map((b, i) => [startTime + i * binMs, b.errors] as [number, number]); + return [ + { name: '2xx / 3xx', data: normalData }, + { name: '4xx / 5xx', data: errorData }, + ]; + }, [traces, endTime, windowMs, binMs]); + + const options: ApexOptions = useMemo( + () => ({ + chart: { + type: 'area', + stacked: true, + toolbar: { show: false }, + // Re-enabled: pulses are rare enough at the every-60s refresh cadence + // that a smooth transition is preferable to an instant snap. + animations: { enabled: true }, + background: '0', + }, + theme: { mode: dark ? 'dark' : 'light' }, + stroke: { width: 1.5, curve: 'smooth' }, + colors: ['#22c55e', '#ef4444'], + dataLabels: { enabled: false }, + fill: { type: 'gradient', gradient: { opacityFrom: 0.45, opacityTo: 0.05 } }, + grid: { borderColor: dark ? '#0A355C' : '#e5e7eb' }, + xaxis: { + type: 'datetime', + labels: { datetimeUTC: false, format: 'HH:mm' }, + min: endTime - windowMs, + max: endTime, + }, + yaxis: { + title: { text: 'Calls / bin' }, + labels: { formatter: (val: number) => String(Math.round(val)) }, + forceNiceScale: true, + }, + tooltip: { + x: { format: 'dd MMM HH:mm' }, + y: { formatter: (val: number) => String(Math.round(val)) }, + }, + legend: { position: 'bottom' }, + }), + [endTime, windowMs, dark], + ); + + return ( +
+
Calls over time
+ +
+ ); +} diff --git a/src/hooks/realunit-tracing.hook.ts b/src/hooks/log-tracing.hook.ts similarity index 62% rename from src/hooks/realunit-tracing.hook.ts rename to src/hooks/log-tracing.hook.ts index 6bc288ab..7934faa3 100644 --- a/src/hooks/realunit-tracing.hook.ts +++ b/src/hooks/log-tracing.hook.ts @@ -53,7 +53,33 @@ export function parseTrace(timestamp: string, message: string): ParsedTrace | nu }; } -export function useRealunitTracing() { +export interface GenericTrace { + timestamp: string; + severityLevel: number; // 0 verbose, 1 info, 2 warn, 3 error, 4 critical + context: string; // extracted from "[Context]" prefix, '' if none + message: string; // remainder after the prefix + operationId: string; +} + +const CONTEXT_PREFIX_RE = /^\[([^\]]+)\]\s+([\s\S]*)$/; + +export function parseGenericTrace( + timestamp: string, + severityLevel: number, + message: string, + operationId: string, +): GenericTrace { + const m = message.match(CONTEXT_PREFIX_RE); + return { + timestamp, + severityLevel, + context: m?.[1] ?? '', + message: m?.[2] ?? message, + operationId, + }; +} + +export function useLogTracing() { const { call } = useApi(); async function getRealunitTraces(hours: number): Promise { @@ -68,5 +94,13 @@ export function useRealunitTracing() { }); } - return useMemo(() => ({ getRealunitTraces }), [call]); + async function getAllTraces(hours: number): Promise { + return call({ + url: 'gs/debug/logs', + method: 'POST', + data: { template: 'all-traces', hours }, + }); + } + + return useMemo(() => ({ getRealunitTraces, getAllTraces }), [call]); } diff --git a/src/screens/dashboard-log-tracing-all.screen.tsx b/src/screens/dashboard-log-tracing-all.screen.tsx new file mode 100644 index 00000000..b0ed8054 --- /dev/null +++ b/src/screens/dashboard-log-tracing-all.screen.tsx @@ -0,0 +1,244 @@ +import { useSessionContext } from '@dfx.swiss/react'; +import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; +import { useEffect, useMemo, useState } from 'react'; +import { SummaryCard } from 'src/components/dashboard/summary-card'; +import { useAdminGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; +import { GenericTrace, LogQueryResult, parseGenericTrace, useLogTracing } from 'src/hooks/log-tracing.hook'; + +// Dark-theme palette — matches dashboard-financial-overview.screen.tsx +const LABEL_COLOR = '#9AA5B8'; +const TEXT_COLOR = '#ffffff'; +const BORDER_COLOR = '#0A355C'; +const BUTTON_BG = '#082948'; +const BUTTON_BG_ACTIVE = '#3b82f6'; +const SUBTLE_TEXT = '#D6DBE2'; + +const TIME_RANGES: { label: string; hours: number }[] = [ + { label: '1 h', hours: 1 }, + { label: '6 h', hours: 6 }, + { label: '24 h', hours: 24 }, +]; + +// 60s rather than tighter: matches the RealUnit screen so /gs/debug/logs +// audit volume from this dashboard stays comparable. +const REFRESH_MS = 60_000; +const MAX_MESSAGE_LEN = 240; + +interface SeverityStyle { + label: string; + color: string; +} + +const SEVERITY_STYLES: Record = { + 0: { label: 'verbose', color: '#6b7280' }, + 1: { label: 'info', color: '#9AA5B8' }, + 2: { label: 'warn', color: '#f59e0b' }, + 3: { label: 'error', color: '#ef4444' }, + 4: { label: 'critical', color: '#ef4444' }, +}; + +function severityStyle(level: number): SeverityStyle { + return SEVERITY_STYLES[level] ?? { label: String(level), color: LABEL_COLOR }; +} + +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 rowsToGenericTraces(result: LogQueryResult): GenericTrace[] { + const tsIdx = colIdx(result, 'timestamp'); + const sevIdx = colIdx(result, 'severityLevel'); + const msgIdx = colIdx(result, 'message'); + const opIdx = colIdx(result, 'operation_Id'); + if (tsIdx === -1 || sevIdx === -1 || msgIdx === -1) return []; + return result.rows.map((row) => { + const sevRaw = row[sevIdx]; + const severity = typeof sevRaw === 'number' ? sevRaw : parseInt(String(sevRaw), 10); + const operationId = opIdx === -1 ? '' : String(row[opIdx] ?? ''); + return parseGenericTrace(String(row[tsIdx]), severity, String(row[msgIdx]), operationId); + }); +} + +function truncate(text: string, max: number): string { + if (text.length <= max) return text; + return text.slice(0, max) + '…'; +} + +export default function DashboardLogTracingAllScreen(): JSX.Element { + // Backend /gs/debug/logs is RoleGuard(DEBUG); additionalRoles allows + // ADMIN+SUPER_ADMIN, but not REALUNIT — so admin-only is the right gate. + useAdminGuard(); + useLayoutOptions({ title: 'All Logs', noMaxWidth: true }); + + const { isLoggedIn } = useSessionContext(); + const { getAllTraces } = useLogTracing(); + + const [rangeIdx, setRangeIdx] = useState(0); // default: 1h + 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 getAllTraces(range.hours); + if (cancelled) return; + setTraces(rowsToGenericTraces(result)); + 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, getAllTraces]); + + const stats = useMemo(() => { + const total = traces.length; + const errors = traces.filter((t) => t.severityLevel >= 3).length; + const warnings = traces.filter((t) => t.severityLevel === 2).length; + const info = traces.filter((t) => t.severityLevel === 1).length; + return { total, errors, warnings, info }; + }, [traces]); + + const recent = useMemo( + () => + [...traces] + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, 50), + [traces], + ); + + if (isInitialLoading) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* Summary */} +
+ + 0 ? '#ef4444' : undefined} dark /> + 0 ? '#f59e0b' : undefined} + dark + /> + +
+ + {/* 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…} +
+
+ + {/* Recent Entries */} +
+
Recent Entries (last {recent.length})
+
+ + + + + + + + + + + {recent.map((t, i) => { + const style = severityStyle(t.severityLevel); + return ( + + + + + + + ); + })} + {recent.length === 0 && ( + + + + )} + +
TimeSeverityContextMessage
{formatTime(t.timestamp)} + + {style.label} + + {t.context} + {truncate(t.message, MAX_MESSAGE_LEN)} +
+ No traces in window +
+
+
+
+ ); +} diff --git a/src/screens/dashboard-realunit-tracing.screen.tsx b/src/screens/dashboard-log-tracing-realunit.screen.tsx similarity index 78% rename from src/screens/dashboard-realunit-tracing.screen.tsx rename to src/screens/dashboard-log-tracing-realunit.screen.tsx index b80835cb..0b674aa6 100644 --- a/src/screens/dashboard-realunit-tracing.screen.tsx +++ b/src/screens/dashboard-log-tracing-realunit.screen.tsx @@ -1,18 +1,32 @@ import { useSessionContext } from '@dfx.swiss/react'; import { SpinnerSize, StyledLoadingSpinner } from '@dfx.swiss/react-components'; import { useEffect, useMemo, useState } from 'react'; +import { LogTraceTimeChart } from 'src/components/dashboard/log-trace-time-chart'; +import { SummaryCard } from 'src/components/dashboard/summary-card'; 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'; +import { LogQueryResult, ParsedTrace, parseTrace, useLogTracing } from 'src/hooks/log-tracing.hook'; + +// Dark-theme palette — matches dashboard-financial-overview.screen.tsx +const LABEL_COLOR = '#9AA5B8'; +const TEXT_COLOR = '#ffffff'; +const BORDER_COLOR = '#0A355C'; +const BUTTON_BG = '#082948'; +const BUTTON_BG_ACTIVE = '#3b82f6'; +const SUBTLE_TEXT = '#D6DBE2'; // 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 }, +// `binMs` is the time-chart bucket width, chosen for ~30-100 buckets per range. +const TIME_RANGES: { label: string; hours: number; tightenToMs?: number; binMs: number }[] = [ + { label: '1 h', hours: 1, binMs: 60 * 1000 }, + { label: '15 min', hours: 1, tightenToMs: 15 * 60 * 1000, binMs: 30 * 1000 }, + { label: '6 h', hours: 6, binMs: 5 * 60 * 1000 }, + { label: '24 h', hours: 24, binMs: 15 * 60 * 1000 }, ]; -const REFRESH_MS = 5000; +// 60s rather than tighter: at this rate, the /gs/debug/logs audit log (one entry +// per poll, ~60/h) is small enough vs real RealUnitTrace volume that audit +// entries don't crowd real traces out of the 200-row TRACES_BY_MESSAGE response. +const REFRESH_MS = 60_000; const SLOW_MS = 2000; const VERY_SLOW_MS = 5000; @@ -43,7 +57,7 @@ function statusColor(status: number): string { function durationColor(ms: number): string { if (ms >= VERY_SLOW_MS) return '#ef4444'; if (ms >= SLOW_MS) return '#f59e0b'; - return '#111827'; + return SUBTLE_TEXT; } function formatMs(ms: number): string { @@ -67,25 +81,6 @@ function rowsToTraces(result: LogQueryResult): ParsedTrace[] { .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; @@ -145,12 +140,14 @@ function aggregateIps(traces: ParsedTrace[]): IpStat[] { .sort((a, b) => b.count - a.count); } -export default function DashboardRealunitTracingScreen(): JSX.Element { +export default function DashboardLogTracingRealunitScreen(): JSX.Element { + // Backend /gs/debug/logs is RoleGuard(DEBUG); additionalRoles allows + // ADMIN+SUPER_ADMIN, but not REALUNIT — so admin-only is the right gate. useAdminGuard(); useLayoutOptions({ title: 'RealUnit Tracing', noMaxWidth: true }); const { isLoggedIn } = useSessionContext(); - const { getRealunitTraces } = useRealunitTracing(); + const { getRealunitTraces } = useLogTracing(); const [rangeIdx, setRangeIdx] = useState(0); // default: 1h (first option) const [traces, setTraces] = useState([]); @@ -214,30 +211,43 @@ export default function DashboardRealunitTracingScreen(): JSX.Element { if (isInitialLoading) { return ( -
+
); } return ( -
+
+ {/* Summary */} +
+ + 0 ? '#ef4444' : undefined} dark /> + 0 ? '#f59e0b' : undefined} dark /> + 0 ? '#f59e0b' : undefined} dark /> +
+ {/* Toolbar */} -
-
+
+
{TIME_RANGES.map((r, i) => ( ))}
-
+
{fetchError ? ( Fetch failed: {fetchError} ) : ( @@ -251,22 +261,27 @@ export default function DashboardRealunitTracingScreen(): JSX.Element {
- {/* Summary */} -
- - 0 ? '#ef4444' : undefined} /> - 0 ? '#f59e0b' : undefined} /> - 0 ? '#f59e0b' : undefined} /> -
+ {/* Calls over time — gated on lastFetched so we don't paint with a Date.now() fallback */} + {lastFetched && ( +
+ +
+ )}
{/* Top Endpoints */} -
+
Top Endpoints
- + @@ -276,13 +291,13 @@ export default function DashboardRealunitTracingScreen(): JSX.Element { {endpoints.map((e) => ( - + - - @@ -306,11 +321,11 @@ export default function DashboardRealunitTracingScreen(): JSX.Element { {/* Top IPs */} -
+
Top IPs
Endpoint Count Errors
{e.method} {e.pathPattern} {e.count} 0 ? '#f59e0b' : '#6b7280' }}> + 0 ? '#f59e0b' : LABEL_COLOR }}> {e.errors} @@ -295,7 +310,7 @@ export default function DashboardRealunitTracingScreen(): JSX.Element { ))} {endpoints.length === 0 && (
+ No traces in window
- + @@ -318,17 +333,17 @@ export default function DashboardRealunitTracingScreen(): JSX.Element { {ips.map((ip) => ( - + - ))} {ips.length === 0 && ( - @@ -339,12 +354,12 @@ export default function DashboardRealunitTracingScreen(): JSX.Element { {/* Recent Activity */} -
+
Recent Activity (last {recent.length})
IP Calls Last seen
{ip.ip} {ip.count} + {formatTime(ip.lastSeen)}
+ -
- + @@ -359,7 +374,7 @@ export default function DashboardRealunitTracingScreen(): JSX.Element { @@ -377,14 +392,14 @@ export default function DashboardRealunitTracingScreen(): JSX.Element { {formatMs(t.durationMs)} - ))} {recent.length === 0 && ( - diff --git a/src/screens/dashboard-log-tracing.screen.tsx b/src/screens/dashboard-log-tracing.screen.tsx new file mode 100644 index 00000000..829f926a --- /dev/null +++ b/src/screens/dashboard-log-tracing.screen.tsx @@ -0,0 +1,33 @@ +import { useAdminGuard } from 'src/hooks/guard.hook'; +import { useLayoutOptions } from 'src/hooks/layout-config.hook'; +import { useNavigation } from 'src/hooks/navigation.hook'; + +export default function DashboardLogTracingScreen(): JSX.Element { + // Backend /gs/debug/logs is RoleGuard(DEBUG); additionalRoles allows + // ADMIN+SUPER_ADMIN, but not REALUNIT — so admin-only is the right gate. + useAdminGuard(); + useLayoutOptions({ title: 'Log Tracing', noMaxWidth: true }); + const { navigate } = useNavigation(); + return ( +
+
navigate('/dashboard/log-tracing/realunit')} + > +
RealUnit
+
+ Live API-call tracing for the RealUnit wallet (test phase) +
+
+
navigate('/dashboard/log-tracing/all')} + > +
All Logs
+
+ All API trace entries, grouped by severity and context +
+
+
+ ); +} diff --git a/src/screens/dashboard.screen.tsx b/src/screens/dashboard.screen.tsx index 7cb359be..6836dcc5 100644 --- a/src/screens/dashboard.screen.tsx +++ b/src/screens/dashboard.screen.tsx @@ -21,11 +21,11 @@ export default function DashboardScreen(): JSX.Element {
navigate('/dashboard/realunit-tracing')} + onClick={() => navigate('/dashboard/log-tracing')} > -
RealUnit Tracing
+
Log Tracing
- Live API-call tracing for the RealUnit wallet (test phase) + Live tracing of API calls — RealUnit dashboard and all logs
diff --git a/src/screens/sitemap.screen.tsx b/src/screens/sitemap.screen.tsx index dc8ee045..0015f26f 100644 --- a/src/screens/sitemap.screen.tsx +++ b/src/screens/sitemap.screen.tsx @@ -52,6 +52,7 @@ const sections: PageSection[] = [ title: 'Payment Links & Invoices', pages: [ { path: '/pl', label: 'Payment Link' }, + { path: '/pl/assign', label: 'Payment Link Assign' }, { path: '/pl/pos', label: 'Payment Link POS' }, { path: '/pl/result', label: 'Payment Link Result' }, { path: '/payment-link', label: 'Payment Link (Legacy)' }, @@ -95,6 +96,8 @@ const sections: PageSection[] = [ { path: '/compliance/custody-orders', label: 'Custody Orders' }, { path: '/compliance/recalls', label: 'Recalls' }, { path: '/compliance/mros', label: 'MROS Reports' }, + { path: '/compliance/mros/create', label: 'Create MROS Report' }, + { path: '/compliance/call-queues', label: 'Call Queues' }, ], }, { @@ -102,6 +105,8 @@ const sections: PageSection[] = [ pages: [ { path: '/support/dashboard', label: 'Support Dashboard' }, { path: '/support/dashboard/create', label: 'Create Issue' }, + { path: '/notes', label: 'Notes' }, + { path: '/templates', label: 'Support Templates' }, ], }, { @@ -114,14 +119,18 @@ const sections: PageSection[] = [ ], }, { - title: 'Financial Dashboard', + title: 'Dashboard', pages: [ { path: '/dashboard', label: 'Dashboard' }, { path: '/dashboard/financial', label: 'Financial' }, + { path: '/dashboard/financial/overview', label: 'Financial Overview' }, { path: '/dashboard/financial/live', label: 'Financial Live' }, { path: '/dashboard/financial/history', label: 'Financial History' }, { path: '/dashboard/financial/history/expenses', label: 'Expenses' }, { path: '/dashboard/financial/liquidity', label: 'Liquidity' }, + { path: '/dashboard/log-tracing', label: 'Log Tracing' }, + { path: '/dashboard/log-tracing/realunit', label: 'RealUnit Tracing' }, + { path: '/dashboard/log-tracing/all', label: 'All Logs' }, ], }, {
Time Method URL
{formatTime(t.timestamp)} {t.method} {t.client} + {t.ip}
+ No traces yet