Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 99 additions & 17 deletions src/agentsight/dashboard/src/i18n.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import React, {
createContext,
useCallback,
useContext,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';

Expand Down Expand Up @@ -35,6 +37,19 @@ const enUSMessages = {
'nav.riskEnforcement': 'Risk Enforcement',
'nav.trajectoryViewer': 'Trajectory Viewer',
'nav.settings': 'Settings',
'latency.title': 'Latency Metrics',
'latency.ttft': 'TTFT',
'latency.tps': 'TPS',
'latency.tpot': 'TPOT',
'latency.e2e': 'E2E',
'latency.p50': 'P50',
'latency.p95': 'P95',
'latency.p99': 'P99',
'latency.loading': 'Loading latency metrics...',
'latency.error': 'Failed to load latency metrics',
'latency.range24h': 'Last 24h',
'latency.range7d': 'Last 7d',
'latency.range30d': 'Last 30d',
'login.subtitle': 'Enter your dashboard token to continue',
'login.tokenLabel': 'Dashboard Token',
'login.tokenPlaceholder': 'Paste your token here',
Expand Down Expand Up @@ -1016,6 +1031,19 @@ export const messages: Record<Locale, Record<MessageKey, string>> = {
'nav.riskEnforcement': '风险拦截',
'nav.trajectoryViewer': '轨迹查看',
'nav.settings': '设置',
'latency.title': '延迟指标',
'latency.ttft': 'TTFT',
'latency.tps': 'TPS',
'latency.tpot': 'TPOT',
'latency.e2e': 'E2E',
'latency.p50': 'P50',
'latency.p95': 'P95',
'latency.p99': 'P99',
'latency.loading': '正在加载延迟指标...',
'latency.error': '延迟指标加载失败',
'latency.range24h': '最近 24h',
'latency.range7d': '最近 7d',
'latency.range30d': '最近 30d',
'login.subtitle': '请输入 Dashboard 令牌以继续',
'login.tokenLabel': 'Dashboard 令牌',
'login.tokenPlaceholder': '在此粘贴令牌',
Expand Down Expand Up @@ -2135,33 +2163,87 @@ interface LanguageSwitcherProps {
className?: string;
}

const LOCALE_OPTIONS: Array<{ value: Locale; label: string }> = [
{ value: 'en-US', label: 'English' },
{ value: 'zh-CN', label: '简体中文' },
];

export const LanguageSwitcher: React.FC<LanguageSwitcherProps> = ({
id,
className = '',
}) => {
const { locale, setLocale, t } = useI18n();
const [open, setOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);

const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
const nextLocale = event.target.value;
if (isSupportedLocale(nextLocale)) {
setLocale(nextLocale);
}
};
useEffect(() => {
if (!open) return;

const closeOnPointerDown = (event: PointerEvent) => {
if (!containerRef.current?.contains(event.target as Node)) {
setOpen(false);
}
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
setOpen(false);
}
};

document.addEventListener('pointerdown', closeOnPointerDown);
document.addEventListener('keydown', closeOnEscape);
return () => {
document.removeEventListener('pointerdown', closeOnPointerDown);
document.removeEventListener('keydown', closeOnEscape);
};
}, [open]);

const selectedLabel = LOCALE_OPTIONS.find((option) => option.value === locale)?.label ?? 'English';

return (
<label className={`inline-flex items-center gap-1.5 ${className}`} htmlFor={id}>
<div ref={containerRef} className={`relative inline-flex items-center gap-1.5 ${className}`}>
<span aria-hidden="true">🌐</span>
<span className="sr-only">{t('language.label')}</span>
<select
<button
type="button"
id={id}
aria-label={t('language.label')}
value={locale}
onChange={handleChange}
className="rounded-md border border-gray-300 bg-white px-2 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
aria-label={`${t('language.label')}: ${selectedLabel}`}
aria-haspopup="menu"
aria-expanded={open}
aria-controls={`${id}-menu`}
onClick={() => setOpen((current) => !current)}
className="inline-flex items-center gap-1 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-sm text-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="en-US">English</option>
<option value="zh-CN">简体中文</option>
</select>
</label>
{selectedLabel}
<span aria-hidden="true" className="text-xs text-gray-400">▾</span>
</button>
{open && (
<div
id={`${id}-menu`}
role="menu"
aria-label={t('language.label')}
className="absolute right-0 top-full z-50 mt-1 min-w-[120px] rounded-md border border-gray-200 bg-white p-1 shadow-lg"
>
{LOCALE_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
role="menuitemradio"
aria-checked={locale === option.value}
onClick={() => {
setLocale(option.value);
setOpen(false);
}}
className={`block w-full rounded px-3 py-2 text-left text-sm ${
locale === option.value
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 hover:bg-gray-100'
}`}
>
{option.label}
</button>
))}
</div>
)}
</div>
);
};
161 changes: 156 additions & 5 deletions src/agentsight/dashboard/src/pages/AgentHealthPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,9 @@ import {
restartAgentHealth,
fetchInterruptions,
resolveInterruption,
fetchLatencyMetrics,
} from '../utils/apiClient';
import type { InterruptionRecord, InterruptionSeverity } from '../utils/apiClient';
import type { InterruptionRecord, InterruptionSeverity, LatencyMetricsSummary, MetricPercentiles } from '../utils/apiClient';
import type { AgentHealthStatus } from '../types';
import { useI18n, useLocaleTag, INTERRUPTION_TYPES, interruptionTypeKey } from '../i18n';
import type { MessageKey } from '../i18n';
Expand Down Expand Up @@ -59,13 +60,85 @@ interface Toast {
message: string;
}

function formatMetricValue(value: number): string {
return value.toLocaleString(undefined, { maximumFractionDigits: 2 });
}

function formatMetricP50(metric: MetricPercentiles | null, unit: string): string {
return metric ? formatMetricValue(metric.p50) + ' ' + unit : '—';
}

function canonicalAgentKey(agentName: string): string {
return agentName.toLowerCase();
}

const LatencyMetricsRow: React.FC<{ metrics: LatencyMetricsSummary }> = ({ metrics }) => {
const { t } = useI18n();
const items = [
{ label: t('latency.ttft'), metric: metrics.ttft_ms, unit: 'ms' },
{ label: t('latency.tps'), metric: metrics.tps_tokens_per_second, unit: 'tokens/s' },
{ label: t('latency.tpot'), metric: metrics.tpot_ms_per_token, unit: 'ms/token' },
{ label: t('latency.e2e'), metric: metrics.e2e_latency_ms, unit: 'ms' },
];

if (!items.some(item => item.metric !== null)) return null;

const tooltip = items
.map(({ label, metric, unit }) => {
if (!metric) return label + ' —';
return (
label +
' ' +
t('latency.p50') +
' ' +
formatMetricValue(metric.p50) +
' ' +
unit +
' · ' +
t('latency.p95') +
' ' +
formatMetricValue(metric.p95) +
' ' +
unit +
' · ' +
t('latency.p99') +
' ' +
formatMetricValue(metric.p99) +
' ' +
unit
);
})
.join(' · ');

return (
<div
className="mt-2 pt-2 border-t border-gray-100 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-gray-700"
title={tooltip}
aria-label={tooltip}
>
{items.map(({ label, metric, unit }) => (
<span key={label}>
<span className="text-gray-400">{label}</span> {formatMetricP50(metric, unit)}
</span>
))}
</div>
);
};

const LATENCY_TIME_PRESETS = [
{ key: 'latency.range24h', ms: 24 * 3600 * 1000 },
{ key: 'latency.range7d', ms: 7 * 24 * 3600 * 1000 },
{ key: 'latency.range30d', ms: 30 * 24 * 3600 * 1000 },
] as const;

const AgentCard: React.FC<{
agent: AgentHealthStatus;
related: AgentHealthStatus[];
onDelete: (pid: number) => void;
onRestart: (pid: number) => void;
restarting: boolean;
}> = ({ agent, related, onDelete, onRestart, restarting }) => {
latency?: LatencyMetricsSummary;
}> = ({ agent, related, onDelete, onRestart, restarting, latency }) => {
const { t } = useI18n();
const [showRelated, setShowRelated] = useState(false);

Expand Down Expand Up @@ -177,6 +250,7 @@ const AgentCard: React.FC<{
</div>
)}
</div>
{latency && <LatencyMetricsRow metrics={latency} />}
{(isOffline || canRestart) && (
<div className="mt-2 flex items-center gap-3">
{isOffline && (
Expand Down Expand Up @@ -236,6 +310,34 @@ const AgentStatusSection: React.FC<{ addToast: (msg: string) => void }> = ({ add
const [error, setError] = useState<string | null>(null);
const [restartingPids, setRestartingPids] = useState<Set<number>>(new Set());
const hasDataRef = useRef(false);
const [rangeMs, setRangeMs] = useState(7 * 24 * 3600 * 1000);
const [latencyMetrics, setLatencyMetrics] = useState<LatencyMetricsSummary[]>([]);
const [latencyLoading, setLatencyLoading] = useState(true);
const [latencyError, setLatencyError] = useState<string | null>(null);
const latencyRequestIdRef = useRef(0);

const loadLatency = useCallback(async () => {
const requestId = ++latencyRequestIdRef.current;
setLatencyLoading(true);
setLatencyError(null);
try {
const endNs = Date.now() * 1_000_000;
const startNs = endNs - rangeMs * 1_000_000;
const data = await fetchLatencyMetrics(startNs, endNs);
if (requestId === latencyRequestIdRef.current) {
setLatencyMetrics(data);
setLatencyError(null);
}
} catch (e: any) {
if (requestId === latencyRequestIdRef.current) {
setLatencyError(e.message || '');
}
} finally {
if (requestId === latencyRequestIdRef.current) {
setLatencyLoading(false);
}
}
}, [rangeMs]);

const refresh = useCallback(async () => {
try {
Expand Down Expand Up @@ -289,6 +391,10 @@ const AgentStatusSection: React.FC<{ addToast: (msg: string) => void }> = ({ add
return () => clearInterval(timer);
}, [refresh]);

useEffect(() => {
void loadLatency();
}, [loadLatency]);

// Sort: hung/unhealthy first (real problems), healthy in the middle, offline last (less prominent)
const sorted = [...agents].sort((a, b) => {
const order: Record<string, number> = {
Expand All @@ -307,6 +413,24 @@ const AgentStatusSection: React.FC<{ addToast: (msg: string) => void }> = ({ add
const hungCount = agents.filter(a => a.status === 'hung').length;
const totalCount = agents.length;

const latencyByAgent = new Map<string, LatencyMetricsSummary[]>();
for (const metric of latencyMetrics) {
if (metric.agent_name !== null) {
const key = canonicalAgentKey(metric.agent_name);
const summaries = latencyByAgent.get(key);
if (summaries) {
summaries.push(metric);
} else {
latencyByAgent.set(key, [metric]);
}
}
}
const latencyForAgent = (agentName: string): LatencyMetricsSummary | undefined => {
const summaries = latencyByAgent.get(canonicalAgentKey(agentName));
// Do not silently choose one when casing variants produce separate summaries.
return summaries?.length === 1 ? summaries[0] : undefined;
};

const gatewayPids = new Set(sorted.map(a => a.pid));
// Orphaned related processes: workers whose parent is not any main card (should not happen, fallback).
// Filter out status=offline processes — they are TTL-cleaned after 5 minutes.
Expand Down Expand Up @@ -342,9 +466,35 @@ const AgentStatusSection: React.FC<{ addToast: (msg: string) => void }> = ({ add
</span>
)}
</div>
{lastScan > 0 && (
<span className="text-xs text-gray-400">{t('ah.lastScan', { time: relativeTime(lastScan, t) })}</span>
)}
<div className="flex items-center gap-2 flex-wrap justify-end">
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-gray-400">{t('latency.title')}</span>
{LATENCY_TIME_PRESETS.map(({ key, ms }) => (
<button
key={key}
type="button"
onClick={() => setRangeMs(ms)}
aria-pressed={rangeMs === ms}
className={rangeMs === ms
? 'px-2 py-1 text-[11px] rounded bg-blue-100 text-blue-700 font-medium'
: 'px-2 py-1 text-[11px] rounded bg-gray-100 hover:bg-gray-200 text-gray-600'}
>
{t(key)}
</button>
))}
</div>
{latencyLoading && (
<span className="text-[11px] text-gray-400">{t('latency.loading')}</span>
)}
{latencyError !== null && (
<span className="text-[11px] text-red-400" title={latencyError || t('latency.error')}>
{t('latency.error')}
</span>
)}
{lastScan > 0 && (
<span className="text-xs text-gray-400">{t('ah.lastScan', { time: relativeTime(lastScan, t) })}</span>
)}
</div>
</div>

{loading ? (
Expand All @@ -367,6 +517,7 @@ const AgentStatusSection: React.FC<{ addToast: (msg: string) => void }> = ({ add
onDelete={handleDelete}
onRestart={handleRestart}
restarting={restartingPids.has(agent.pid)}
latency={latencyForAgent(agent.agent_name)}
/>
))}
</div>
Expand Down
Loading