diff --git a/ui/src/components/ContentTabs.module.css b/ui/src/components/ContentTabs.module.css index d7d3d07b..e934a9ae 100644 --- a/ui/src/components/ContentTabs.module.css +++ b/ui/src/components/ContentTabs.module.css @@ -1,5 +1,10 @@ .wrapper { - padding: 0.625rem 1.5rem 0; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 1.5rem; + padding-top: 0.375rem; border-bottom: 1px solid var(--border); background: var(--surface); + gap: 1rem; } diff --git a/ui/src/components/ContentTabs.tsx b/ui/src/components/ContentTabs.tsx index ddbdabc5..5ac5df7b 100644 --- a/ui/src/components/ContentTabs.tsx +++ b/ui/src/components/ContentTabs.tsx @@ -1,5 +1,6 @@ import { Tabs } from '@cameleer/design-system'; -import type { TabKey } from '../hooks/useScope'; +import type { TabKey, Scope } from '../hooks/useScope'; +import { TabKpis } from './TabKpis'; import styles from './ContentTabs.module.css'; const TABS = [ @@ -11,9 +12,10 @@ const TABS = [ interface ContentTabsProps { active: TabKey; onChange: (tab: TabKey) => void; + scope: Scope; } -export function ContentTabs({ active, onChange }: ContentTabsProps) { +export function ContentTabs({ active, onChange, scope }: ContentTabsProps) { return (
onChange(v as TabKey)} /> +
); } diff --git a/ui/src/components/LayoutShell.tsx b/ui/src/components/LayoutShell.tsx index e35ad12a..a6d125d6 100644 --- a/ui/src/components/LayoutShell.tsx +++ b/ui/src/components/LayoutShell.tsx @@ -265,7 +265,7 @@ function LayoutContent() { /> {!isAdminPage && ( - + )}
diff --git a/ui/src/components/TabKpis.module.css b/ui/src/components/TabKpis.module.css new file mode 100644 index 00000000..a82fe49c --- /dev/null +++ b/ui/src/components/TabKpis.module.css @@ -0,0 +1,44 @@ +.kpis { + display: flex; + align-items: center; + gap: 1rem; +} + +.metric { + display: flex; + align-items: baseline; + gap: 0.25rem; + white-space: nowrap; +} + +.label { + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-muted); +} + +.value { + font-family: var(--font-mono); + font-size: 0.75rem; + font-weight: 600; + color: var(--text-primary); +} + +.arrow { + font-size: 0.6875rem; + font-weight: 700; +} + +.good { + color: var(--success); +} + +.bad { + color: var(--error); +} + +.flat { + color: var(--text-muted); +} diff --git a/ui/src/components/TabKpis.tsx b/ui/src/components/TabKpis.tsx new file mode 100644 index 00000000..58041f46 --- /dev/null +++ b/ui/src/components/TabKpis.tsx @@ -0,0 +1,104 @@ +import { useMemo } from 'react'; +import { useGlobalFilters } from '@cameleer/design-system'; +import { useExecutionStats } from '../api/queries/executions'; +import type { Scope } from '../hooks/useScope'; +import styles from './TabKpis.module.css'; + +interface TabKpisProps { + scope: Scope; +} + +function formatNum(n: number): string { + if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`; + return n.toLocaleString(); +} + +function formatMs(ms: number): string { + if (ms >= 60_000) return `${(ms / 1000).toFixed(0)}s`; + if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`; + return `${Math.round(ms)}ms`; +} + +function formatPct(pct: number): string { + return `${pct.toFixed(1)}%`; +} + +type Trend = 'up' | 'down' | 'flat'; + +function trend(current: number, previous: number): Trend { + if (current > previous) return 'up'; + if (current < previous) return 'down'; + return 'flat'; +} + +function trendArrow(t: Trend): string { + switch (t) { + case 'up': return '\u2191'; + case 'down': return '\u2193'; + case 'flat': return '\u2192'; + } +} + +interface Metric { + label: string; + value: string; + trend: Trend; + /** Whether "up" is bad (e.g. error rate, latency) */ + upIsBad?: boolean; +} + +export function TabKpis({ scope }: TabKpisProps) { + const { timeRange } = useGlobalFilters(); + const timeFrom = timeRange.start.toISOString(); + const timeTo = timeRange.end.toISOString(); + + const { data: stats } = useExecutionStats( + timeFrom, timeTo, + scope.routeId, scope.appId, + ); + + const metrics: Metric[] = useMemo(() => { + if (!stats) return []; + + const total = stats.totalCount ?? 0; + const failed = stats.failedCount ?? 0; + const prevTotal = stats.prevTotalCount ?? 0; + const prevFailed = stats.prevFailedCount ?? 0; + const errorRate = total > 0 ? (failed / total) * 100 : 0; + const prevErrorRate = prevTotal > 0 ? (prevFailed / prevTotal) * 100 : 0; + const avgMs = stats.avgDurationMs ?? 0; + const prevAvgMs = stats.prevAvgDurationMs ?? 0; + const p99 = stats.p99LatencyMs ?? 0; + const prevP99 = stats.prevP99LatencyMs ?? 0; + + return [ + { label: 'Total', value: formatNum(total), trend: trend(total, prevTotal) }, + { label: 'Err%', value: formatPct(errorRate), trend: trend(errorRate, prevErrorRate), upIsBad: true }, + { label: 'Avg', value: formatMs(avgMs), trend: trend(avgMs, prevAvgMs), upIsBad: true }, + { label: 'P99', value: formatMs(p99), trend: trend(p99, prevP99), upIsBad: true }, + ]; + }, [stats]); + + if (metrics.length === 0) return null; + + return ( +
+ {metrics.map((m) => { + const arrowClass = m.trend === 'flat' + ? styles.flat + : (m.trend === 'up') === m.upIsBad + ? styles.bad + : styles.good; + + return ( +
+ {m.label} + {m.value} + {trendArrow(m.trend)} +
+ ); + })} +
+ ); +}