feat(ui): add compact KPI metrics in tab bar (Total, Err%, Avg, P99)
New TabKpis component shows scope-aware metrics with trend arrows aligned right in the content tab bar. Each metric shows current value and an arrow indicating change vs previous period (green=good, red=bad). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
.wrapper {
|
.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);
|
border-bottom: 1px solid var(--border);
|
||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
|
gap: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Tabs } from '@cameleer/design-system';
|
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';
|
import styles from './ContentTabs.module.css';
|
||||||
|
|
||||||
const TABS = [
|
const TABS = [
|
||||||
@@ -11,9 +12,10 @@ const TABS = [
|
|||||||
interface ContentTabsProps {
|
interface ContentTabsProps {
|
||||||
active: TabKey;
|
active: TabKey;
|
||||||
onChange: (tab: TabKey) => void;
|
onChange: (tab: TabKey) => void;
|
||||||
|
scope: Scope;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ContentTabs({ active, onChange }: ContentTabsProps) {
|
export function ContentTabs({ active, onChange, scope }: ContentTabsProps) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrapper}>
|
<div className={styles.wrapper}>
|
||||||
<Tabs
|
<Tabs
|
||||||
@@ -21,6 +23,7 @@ export function ContentTabs({ active, onChange }: ContentTabsProps) {
|
|||||||
active={active}
|
active={active}
|
||||||
onChange={(v) => onChange(v as TabKey)}
|
onChange={(v) => onChange(v as TabKey)}
|
||||||
/>
|
/>
|
||||||
|
<TabKpis scope={scope} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -265,7 +265,7 @@ function LayoutContent() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{!isAdminPage && (
|
{!isAdminPage && (
|
||||||
<ContentTabs active={scope.tab} onChange={setTab} />
|
<ContentTabs active={scope.tab} onChange={setTab} scope={scope} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<main style={{ flex: 1, overflow: 'auto', padding: isAdminPage ? '1.5rem' : '0.75rem 1.5rem' }}>
|
<main style={{ flex: 1, overflow: 'auto', padding: isAdminPage ? '1.5rem' : '0.75rem 1.5rem' }}>
|
||||||
|
|||||||
44
ui/src/components/TabKpis.module.css
Normal file
44
ui/src/components/TabKpis.module.css
Normal file
@@ -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);
|
||||||
|
}
|
||||||
104
ui/src/components/TabKpis.tsx
Normal file
104
ui/src/components/TabKpis.tsx
Normal file
@@ -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 (
|
||||||
|
<div className={styles.kpis}>
|
||||||
|
{metrics.map((m) => {
|
||||||
|
const arrowClass = m.trend === 'flat'
|
||||||
|
? styles.flat
|
||||||
|
: (m.trend === 'up') === m.upIsBad
|
||||||
|
? styles.bad
|
||||||
|
: styles.good;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={m.label} className={styles.metric}>
|
||||||
|
<span className={styles.label}>{m.label}</span>
|
||||||
|
<span className={styles.value}>{m.value}</span>
|
||||||
|
<span className={`${styles.arrow} ${arrowClass}`}>{trendArrow(m.trend)}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user