Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
124 changes: 107 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 All @@ -29,6 +31,23 @@ const enUSMessages = {
'nav.riskEnforcement': 'Risk Enforcement',
'nav.trajectoryViewer': 'Trajectory Viewer',
'nav.settings': 'Settings',
'latency.title': 'Latency Metrics',
'latency.agent': 'Agent',
'latency.calls': 'Calls',
'latency.streaming': 'Streaming',
'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.empty': 'No latency data in this range',
'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 @@ -62,6 +81,23 @@ const messages: Record<Locale, Record<MessageKey, string>> = {
'nav.riskEnforcement': '风险拦截',
'nav.trajectoryViewer': '轨迹查看',
'nav.settings': '设置',
'latency.title': '延迟指标',
'latency.agent': 'Agent',
'latency.calls': '调用数',
'latency.streaming': '流式调用',
'latency.ttft': 'TTFT',
'latency.tps': 'TPS',
'latency.tpot': 'TPOT',
'latency.e2e': 'E2E',
'latency.p50': 'P50',
'latency.p95': 'P95',
'latency.p99': 'P99',
'latency.loading': '正在加载延迟指标...',
'latency.empty': '当前范围内暂无延迟数据',
'latency.error': '延迟指标加载失败',
'latency.range24h': '最近 24h',
'latency.range7d': '最近 7d',
'latency.range30d': '最近 30d',
'login.subtitle': '请输入 Dashboard 令牌以继续',
'login.tokenLabel': 'Dashboard 令牌',
'login.tokenPlaceholder': '在此粘贴令牌',
Expand Down Expand Up @@ -180,33 +216,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>
);
};
163 changes: 162 additions & 1 deletion src/agentsight/dashboard/src/pages/AgentHealthPage.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { useI18n } from '../i18n';
import {
fetchAgentHealth,
deleteAgentHealth,
restartAgentHealth,
fetchInterruptions,
resolveInterruption,
INTERRUPTION_TYPE_CN,
fetchLatencyMetrics,
} from '../utils/apiClient';
import type {
InterruptionRecord,
InterruptionSeverity,
LatencyMetricsSummary,
MetricPercentiles,
} from '../utils/apiClient';
import type { InterruptionRecord, InterruptionSeverity } from '../utils/apiClient';
import type { AgentHealthStatus } from '../types';

// ─── Agent status section ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -391,6 +398,159 @@ const AgentStatusSection: React.FC<{ addToast: (msg: string) => void }> = ({ add
);
};

// ─── Latency metrics section ─────────────────────────────────────────────────

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

interface PercentileLabels {
p50: string;
p95: string;
p99: string;
}

const MetricPercentileCell: React.FC<{
metric: MetricPercentiles | null;
unit: string;
labels: PercentileLabels;
}> = ({ metric, unit, labels }) => {
if (!metric) return <span className="text-gray-400">&mdash;</span>;

return (
<div className="space-y-0.5 whitespace-nowrap text-xs">
<div><span className="text-gray-400">{labels.p50}</span> {formatMetricValue(metric.p50)} {unit}</div>
<div><span className="text-gray-400">{labels.p95}</span> {formatMetricValue(metric.p95)} {unit}</div>
<div><span className="text-gray-400">{labels.p99}</span> {formatMetricValue(metric.p99)} {unit}</div>
</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 LatencyMetricsSection: React.FC = () => {
const { t } = useI18n();
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]);

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

const percentileLabels: PercentileLabels = {
p50: t('latency.p50'),
p95: t('latency.p95'),
p99: t('latency.p99'),
};

return (
<section className="mt-8">
<div className="flex items-center justify-between mb-3 flex-wrap gap-2">
<h2 className="text-lg font-semibold text-gray-800">{t('latency.title')}</h2>
<div className="flex gap-2">
{LATENCY_TIME_PRESETS.map(({ key, ms }) => (
<button
key={key}
onClick={() => setRangeMs(ms)}
className={rangeMs === ms
? 'px-3 py-1.5 text-xs rounded-lg transition-colors bg-blue-100 text-blue-700 font-medium'
: 'px-3 py-1.5 text-xs rounded-lg transition-colors bg-gray-100 hover:bg-gray-200 text-gray-600'}
>
{t(key)}
</button>
))}
</div>
</div>

<div
className="bg-white rounded-lg border border-gray-200 overflow-x-auto"
aria-busy={latencyLoading}
>
{latencyLoading && latencyMetrics.length > 0 && (
<div className="px-4 py-2 text-xs text-gray-400">{t('latency.loading')}</div>
)}
{latencyError !== null && latencyMetrics.length > 0 && (
<div className="px-4 py-2 text-xs text-red-400">{latencyError || t('latency.error')}</div>
)}
{latencyError !== null && latencyMetrics.length === 0 ? (
<div className="py-8 text-center text-sm text-red-400">{latencyError || t('latency.error')}</div>
) : latencyMetrics.length === 0 ? (
<div className="py-8 text-center text-sm text-gray-400">
{latencyLoading ? t('latency.loading') : t('latency.empty')}
</div>
) : (
<table className="w-full min-w-[820px] text-sm">
<thead>
<tr className="bg-gray-50 text-left text-xs text-gray-500 uppercase">
<th className="px-4 py-3">{t('latency.agent')}</th>
<th className="px-4 py-3">{t('latency.calls')}</th>
<th className="px-4 py-3">{t('latency.streaming')}</th>
<th className="px-4 py-3">{t('latency.ttft')} <span className="font-normal">(ms)</span></th>
<th className="px-4 py-3">{t('latency.tps')} <span className="font-normal">(tokens/s)</span></th>
<th className="px-4 py-3">{t('latency.tpot')} <span className="font-normal">(ms/token)</span></th>
<th className="px-4 py-3">{t('latency.e2e')} <span className="font-normal">(ms)</span></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{latencyMetrics.map((metric, index) => (
<tr key={(metric.agent_name ?? 'unknown') + '-' + index} className="align-top">
<td className="px-4 py-3 text-gray-800 font-medium">
{metric.agent_name ?? <span>&mdash;</span>}
</td>
<td className="px-4 py-3 text-gray-700">{metric.call_count.toLocaleString()}</td>
<td className="px-4 py-3 text-gray-700">{metric.streaming_call_count.toLocaleString()}</td>
<td className="px-4 py-3">
<MetricPercentileCell metric={metric.ttft_ms} unit="ms" labels={percentileLabels} />
</td>
<td className="px-4 py-3">
<MetricPercentileCell metric={metric.tps_tokens_per_second} unit="tokens/s" labels={percentileLabels} />
</td>
<td className="px-4 py-3">
<MetricPercentileCell metric={metric.tpot_ms_per_token} unit="ms/token" labels={percentileLabels} />
</td>
<td className="px-4 py-3">
<MetricPercentileCell metric={metric.e2e_latency_ms} unit="ms" labels={percentileLabels} />
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</section>
);
};

// ─── Interruption events section ──────────────────────────────────────────────

const SEVERITY_DOT: Record<InterruptionSeverity, string> = {
Expand Down Expand Up @@ -830,6 +990,7 @@ export const AgentHealthPage: React.FC = () => {
</div>

<AgentStatusSection addToast={addToast} />
<LatencyMetricsSection />
<InterruptionSection addToast={addToast} />
</div>
);
Expand Down
5 changes: 3 additions & 2 deletions src/agentsight/dashboard/src/pages/AgentSessionsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -224,7 +224,9 @@ export const AgentSessionsPage: React.FC = () => {
// Auto-refresh every 10s when enabled
useEffect(() => {
if (!autoRefresh) return;
const interval = setInterval(loadData, 10_000);
const interval = setInterval(() => {
void loadData();
}, 10_000);
return () => clearInterval(interval);
}, [autoRefresh, loadData]);

Expand Down Expand Up @@ -367,7 +369,6 @@ export const AgentSessionsPage: React.FC = () => {
</div>
)}

{/* ── Session table ── */}
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
{loading && merged.length === 0 ? (
<div className="p-10 text-center text-gray-500 text-sm">正在加载会话列表...</div>
Expand Down
Loading
Loading