- Sidebar: make +App button more subtle (lower opacity, brightens on hover) - Sidebar: add filter chips to hide empty routes and offline/stale apps - Sidebar: hide filter chips and +App button when sidebar is collapsed - Exchange table: reorder columns to Status, Attributes, App, Route, Started, Duration; remove ExchangeId and Agent columns - Exchange detail log tab: query by exchangeId only (no applicationId required), filter by processorId when processor selected - KPI tooltips: styled tooltips with current/previous values, time period labels, percentage change, themed with DS variables - KPI tooltips: fix overflow by left-aligning first two and right-aligning last two - Exchange detail: show full datetime (YYYY-MM-DD HH:mm:ss.SSS) for start/end times - Status labels: unify to title-case (Completed, Failed, Running) across all views - Status filter buttons: match title-case labels (Completed, Warning, Failed, Running) - Create app: show full external URL using routingDomain from env config or window.location.origin fallback - Create app: add Runtime Type selector and Custom Arguments to Resources tab - Create app: add Sensitive Keys tab with agent defaults, global keys, and app-specific keys (matching admin page design) - Create app: add placeholder text to all Input fields for consistency - Update design-system to 0.1.52 (sidebar collapse toggle fix) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
200 lines
7.2 KiB
TypeScript
200 lines
7.2 KiB
TypeScript
import { useMemo } from 'react';
|
|
import { useGlobalFilters, Tooltip } from '@cameleer/design-system';
|
|
import { useExecutionStats } from '../api/queries/executions';
|
|
import type { Scope } from '../hooks/useScope';
|
|
import { formatPercent } from '../utils/format-utils';
|
|
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`;
|
|
}
|
|
|
|
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';
|
|
}
|
|
}
|
|
|
|
function changePercent(current: number, previous: number): string | null {
|
|
if (previous === 0 && current === 0) return null;
|
|
if (previous === 0) return '+\u221e%';
|
|
const pct = ((current - previous) / previous) * 100;
|
|
const sign = pct > 0 ? '+' : '';
|
|
return `${sign}${pct.toFixed(1)}%`;
|
|
}
|
|
|
|
/* ── Time period labels ───────────────────────────── */
|
|
|
|
function shortTime(d: Date): string {
|
|
const h = String(d.getHours()).padStart(2, '0');
|
|
const m = String(d.getMinutes()).padStart(2, '0');
|
|
return `${h}:${m}`;
|
|
}
|
|
|
|
function shortDate(d: Date): string {
|
|
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
|
|
return `${months[d.getMonth()]} ${d.getDate()}`;
|
|
}
|
|
|
|
function formatRange(start: Date, end: Date): string {
|
|
const sameDay = start.toDateString() === end.toDateString();
|
|
if (sameDay) return `${shortDate(start)} ${shortTime(start)}\u2013${shortTime(end)}`;
|
|
return `${shortDate(start)} ${shortTime(start)} \u2013 ${shortDate(end)} ${shortTime(end)}`;
|
|
}
|
|
|
|
function computePreviousPeriod(start: Date, end: Date): { label: string; prevLabel: string } {
|
|
const durationMs = end.getTime() - start.getTime();
|
|
const prevStart = new Date(start.getTime() - durationMs);
|
|
const prevEnd = new Date(start.getTime());
|
|
return {
|
|
label: formatRange(start, end),
|
|
prevLabel: formatRange(prevStart, prevEnd),
|
|
};
|
|
}
|
|
|
|
/* ── Metric model ─────────────────────────────────── */
|
|
|
|
interface Metric {
|
|
label: string;
|
|
fullLabel: string;
|
|
value: string;
|
|
prevValue: string;
|
|
change: string | null;
|
|
trend: Trend;
|
|
upIsBad?: boolean;
|
|
}
|
|
|
|
/* ── Tooltip ──────────────────────────────────────── */
|
|
|
|
function MetricTooltip({ m, currentLabel, prevLabel }: { m: Metric; currentLabel: string; prevLabel: string }) {
|
|
const trendClass = m.trend === 'flat'
|
|
? styles.flat
|
|
: (m.trend === 'up') === m.upIsBad
|
|
? styles.bad
|
|
: styles.good;
|
|
|
|
return (
|
|
<div className={styles.tooltipBody}>
|
|
<div className={styles.tooltipTitle}>{m.fullLabel}</div>
|
|
|
|
<div className={styles.tooltipRows}>
|
|
<div className={styles.tooltipRow}>
|
|
<div className={styles.tooltipPeriod}>
|
|
<span className={styles.tooltipDot} />
|
|
<span className={styles.tooltipPeriodLabel}>Now</span>
|
|
<span className={styles.tooltipTime}>{currentLabel}</span>
|
|
</div>
|
|
<span className={styles.tooltipValue}>{m.value}</span>
|
|
</div>
|
|
|
|
<div className={styles.tooltipRow}>
|
|
<div className={styles.tooltipPeriod}>
|
|
<span className={`${styles.tooltipDot} ${styles.tooltipDotPrev}`} />
|
|
<span className={styles.tooltipPeriodLabel}>Prev</span>
|
|
<span className={styles.tooltipTime}>{prevLabel}</span>
|
|
</div>
|
|
<span className={`${styles.tooltipValue} ${styles.tooltipValuePrev}`}>{m.prevValue}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{m.change && (
|
|
<div className={`${styles.tooltipChange} ${trendClass}`}>
|
|
{trendArrow(m.trend)} {m.change}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── Main component ───────────────────────────────── */
|
|
|
|
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 { label: currentLabel, prevLabel } = useMemo(
|
|
() => computePreviousPeriod(timeRange.start, timeRange.end),
|
|
[timeRange.start, timeRange.end],
|
|
);
|
|
|
|
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', fullLabel: 'Total Exchanges', value: formatNum(total), prevValue: formatNum(prevTotal), change: changePercent(total, prevTotal), trend: trend(total, prevTotal) },
|
|
{ label: 'Err%', fullLabel: 'Error Rate', value: formatPercent(errorRate), prevValue: formatPercent(prevErrorRate), change: changePercent(errorRate, prevErrorRate), trend: trend(errorRate, prevErrorRate), upIsBad: true },
|
|
{ label: 'Avg', fullLabel: 'Avg Latency', value: formatMs(avgMs), prevValue: formatMs(prevAvgMs), change: changePercent(avgMs, prevAvgMs), trend: trend(avgMs, prevAvgMs), upIsBad: true },
|
|
{ label: 'P99', fullLabel: 'P99 Latency', value: formatMs(p99), prevValue: formatMs(prevP99), change: changePercent(p99, prevP99), trend: trend(p99, prevP99), upIsBad: true },
|
|
];
|
|
}, [stats]);
|
|
|
|
if (metrics.length === 0) return null;
|
|
|
|
return (
|
|
<div className={styles.kpis}>
|
|
{metrics.map((m, i) => {
|
|
const arrowClass = m.trend === 'flat'
|
|
? styles.flat
|
|
: (m.trend === 'up') === m.upIsBad
|
|
? styles.bad
|
|
: styles.good;
|
|
const isNearEnd = i >= metrics.length - 2;
|
|
|
|
return (
|
|
<Tooltip
|
|
key={m.label}
|
|
content={<MetricTooltip m={m} currentLabel={currentLabel} prevLabel={prevLabel} />}
|
|
position="bottom"
|
|
className={`${styles.tooltipWrap} ${isNearEnd ? styles.tooltipEnd : ''}`}
|
|
>
|
|
<div 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>
|
|
</Tooltip>
|
|
);
|
|
})}
|
|
</div>
|
|
);
|
|
}
|