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)}
+
+ );
+ })}
+
+ );
+}