Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { fetchAgentHealth } from '../utils/apiClient';
import { useI18n } from '../i18n';

interface Toast {
id: number;
Expand All @@ -14,6 +15,7 @@ interface Toast {
* keeps the cross-page alerting behavior of the former sidebar.
*/
export const AgentHealthNotifier: React.FC = () => {
const { t } = useI18n();
const [toasts, setToasts] = useState<Toast[]>([]);
const toastIdRef = useRef(0);
// Track which PIDs we've already notified about (negative PID = hung notice)
Expand All @@ -34,11 +36,11 @@ export const AgentHealthNotifier: React.FC = () => {
agents.forEach(a => {
if (a.status === 'offline' && a.has_crash && !notifiedRef.current.has(a.pid)) {
notifiedRef.current.add(a.pid);
addToast(`⚠️ Agent "${a.agent_name}" (PID ${a.pid}) 异常退出,影响了进行中的对话`);
addToast(t('comp.agentHealth.crashToast', { name: a.agent_name, pid: a.pid }));
}
if (a.status === 'hung' && !notifiedRef.current.has(-a.pid)) {
notifiedRef.current.add(-a.pid);
addToast(`⏳ Agent "${a.agent_name}" (PID ${a.pid}) 响应超时,可能卡顿`);
addToast(t('comp.agentHealth.hungToast', { name: a.agent_name, pid: a.pid }));
}
});
// 清理不再存在的 PID
Expand All @@ -53,7 +55,7 @@ export const AgentHealthNotifier: React.FC = () => {
} catch {
// 通知是尽力而为的能力,接口失败时静默跳过本轮
}
}, [addToast]);
}, [addToast, t]);

useEffect(() => {
void poll();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,9 @@ interface CausalAttributionPanelProps {
sessionId: string;
roundIndex?: number;
roundLabel?: string;
/** Whether the selected round only carries the system prompt. Supplied as a
* flag rather than inferred from `roundLabel`, which is localized. */
isPreambleRound?: boolean;
/** "conversation" when the parent page is viewing a conversation_id; unset otherwise. */
idKind?: 'session' | 'conversation';
/** Called when the user clicks a causal node — parent scrolls the trajectory to that step. */
Expand Down Expand Up @@ -483,6 +486,7 @@ export const CausalAttributionPanel: React.FC<CausalAttributionPanelProps> = ({
sessionId,
roundIndex,
roundLabel,
isPreambleRound = false,
idKind,
onScrollToStep,
}) => {
Expand Down Expand Up @@ -608,7 +612,7 @@ export const CausalAttributionPanel: React.FC<CausalAttributionPanelProps> = ({
</span>
</div>

{roundLabel === '前置' && (
{isPreambleRound && (
<div className="mt-3 px-3 py-2 rounded-lg bg-amber-50 border border-amber-200 text-xs text-amber-800 leading-snug">
<b>提示:</b>“前置”轮只包含系统 prompt,没有 agent 决策可分析。建议切到左侧某个“第 N 轮”再发起归因,结果会更有意义。
</div>
Expand Down
235 changes: 132 additions & 103 deletions src/agentsight/dashboard/src/components/ContainmentDialog.tsx

Large diffs are not rendered by default.

110 changes: 74 additions & 36 deletions src/agentsight/dashboard/src/components/ContainmentLifecycleCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import React, { useEffect, useMemo, useState } from 'react';
import type { SecurityContainmentAction } from '../utils/apiClient';
import { containmentLifecyclePresentation } from '../utils/containmentLifecycle';
import { useI18n, useLocaleTag } from '../i18n';
import type { MessageKey } from '../i18n';
import { formatNsCompact } from '../utils/datetime';

interface ContainmentLifecycleCardProps {
action: SecurityContainmentAction | null;
Expand All @@ -12,26 +15,22 @@ interface ContainmentLifecycleCardProps {
onResolve: () => void;
}

const failureStageLabel: Record<NonNullable<SecurityContainmentAction['failure_stage']>, string> = {
attach: '策略挂载',
detach: '策略解除',
reconcile: '状态恢复',
const failureStageLabel: Record<
NonNullable<SecurityContainmentAction['failure_stage']>,
MessageKey
> = {
attach: 'cont.failureStage.attach',
detach: 'cont.failureStage.detach',
reconcile: 'cont.failureStage.reconcile',
};

function formatNs(timestampNs: number | null): string {
if (!timestampNs) return '—';
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}).format(timestampNs / 1_000_000);
}

function formatRemaining(expiresAtNs: number, nowMs: number): string {
function formatRemaining(
expiresAtNs: number,
nowMs: number,
waitingLabel: string,
): string {
const seconds = Math.max(0, Math.ceil(expiresAtNs / 1_000_000 - nowMs) / 1_000);
if (seconds === 0) return '等待状态刷新';
if (seconds === 0) return waitingLabel;
const total = Math.ceil(seconds);
const hours = Math.floor(total / 3600);
const minutes = Math.floor((total % 3600) / 60);
Expand All @@ -48,6 +47,9 @@ export const ContainmentLifecycleCard: React.FC<ContainmentLifecycleCardProps> =
onUpgrade,
onResolve,
}) => {
const { t } = useI18n();
const localeTag = useLocaleTag();

const [nowMs, setNowMs] = useState(Date.now());
const expiryMs = action?.expires_at_ns ? action.expires_at_ns / 1_000_000 : null;

Expand All @@ -65,29 +67,37 @@ export const ContainmentLifecycleCard: React.FC<ContainmentLifecycleCardProps> =
};
}, [expiryMs]);

const presentation = useMemo(() => (
action ? containmentLifecyclePresentation(action) : null
), [action]);
const presentation = useMemo(
() => (action ? containmentLifecyclePresentation(action) : null),
[action],
);
const mayRetry = action?.lifecycle_state === 'failed' || action?.lifecycle_state === 'expired';

return (
<section className="mt-5 rounded-xl border border-gray-200 bg-slate-50 p-4" aria-label="风险拦截状态">
<section
className="mt-5 rounded-xl border border-gray-200 bg-slate-50 p-4"
aria-label={t('cont.lifecycle.title')}
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-medium text-gray-500">风险拦截</p>
<p className="text-xs font-medium text-gray-500">{t('cont.lifecycle.sectionTitle')}</p>
{loading ? (
<p role="status" className="mt-1 text-sm text-gray-600">正在加载拦截状态...</p>
<p role="status" className="mt-1 text-sm text-gray-600">
{t('cont.lifecycle.loading')}
</p>
) : error ? (
<p role="alert" className="mt-1 text-sm text-red-700">拦截状态暂时不可用,请刷新后重试。</p>
<p role="alert" className="mt-1 text-sm text-red-700">
{t('cont.lifecycle.error')}
</p>
) : presentation ? (
<div className="mt-1 flex items-center gap-2">
<span className={`rounded-full px-2.5 py-1 text-xs font-semibold ${presentation.style}`}>
{presentation.label}
{t(presentation.labelKey)}
</span>
<span className="text-sm text-gray-600">{presentation.detail}</span>
<span className="text-sm text-gray-600">{t(presentation.detailKey)}</span>
</div>
) : (
<p className="mt-1 text-sm text-gray-600">待升级:当前仅审计,不阻断系统行为。</p>
<p className="mt-1 text-sm text-gray-600">{t('cont.lifecycle.empty')}</p>
)}
</div>
{canUpgrade && (!action || mayRetry) && !loading && !error && (
Expand All @@ -96,22 +106,50 @@ export const ContainmentLifecycleCard: React.FC<ContainmentLifecycleCardProps> =
onClick={onUpgrade}
className="rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white hover:bg-red-700"
>
{mayRetry ? '重新下发拦截' : '升级为拦截'}
{mayRetry ? t('cont.lifecycle.upgrade.retry') : t('cont.lifecycle.upgrade')}
</button>
)}
</div>

{action && (
<dl className="mt-4 grid gap-3 border-t border-gray-200 pt-4 text-sm sm:grid-cols-2 lg:grid-cols-3">
<div><dt className="text-xs text-gray-500">目标进程</dt><dd className="mt-1 text-gray-900">PID {action.root_pid}</dd></div>
<div><dt className="text-xs text-gray-500">策略绑定</dt><dd className="mt-1 break-all font-mono text-xs text-gray-900">{action.binding_id}</dd></div>
<div><dt className="text-xs text-gray-500">到期时间</dt><dd className="mt-1 text-gray-900">{action.expires_at_ns ? formatNs(action.expires_at_ns) : '持续生效'}</dd></div>
<div><dt className="text-xs text-gray-500">剩余时间</dt><dd className="mt-1 text-gray-900">{action.expires_at_ns ? formatRemaining(action.expires_at_ns, nowMs) : '需手动解除'}</dd></div>
<div><dt className="text-xs text-gray-500">首次阻断</dt><dd className="mt-1 text-gray-900">{formatNs(action.blocked_at_ns)}</dd></div>
<div><dt className="text-xs text-gray-500">失败阶段</dt><dd className="mt-1 text-gray-900">{action.failure_stage ? failureStageLabel[action.failure_stage] : '—'}</dd></div>
<div>
<dt className="text-xs text-gray-500">{t('cont.lifecycle.field.targetProcess')}</dt>
<dd className="mt-1 text-gray-900">PID {action.root_pid}</dd>
</div>
<div>
<dt className="text-xs text-gray-500">{t('cont.lifecycle.field.binding')}</dt>
<dd className="mt-1 break-all font-mono text-xs text-gray-900">{action.binding_id}</dd>
</div>
<div>
<dt className="text-xs text-gray-500">{t('cont.lifecycle.field.expiresAt')}</dt>
<dd className="mt-1 text-gray-900">
{action.expires_at_ns
? formatNsCompact(action.expires_at_ns, localeTag)
: t('cont.lifecycle.expires.persistent')}
</dd>
</div>
<div>
<dt className="text-xs text-gray-500">{t('cont.lifecycle.field.remaining')}</dt>
<dd className="mt-1 text-gray-900">
{action.expires_at_ns
? formatRemaining(action.expires_at_ns, nowMs, t('cont.remaining.waitRefresh'))
: t('cont.lifecycle.remaining.persistent')}
</dd>
</div>
<div>
<dt className="text-xs text-gray-500">{t('cont.lifecycle.field.firstBlocked')}</dt>
<dd className="mt-1 text-gray-900">{formatNsCompact(action.blocked_at_ns, localeTag)}</dd>
</div>
<div>
<dt className="text-xs text-gray-500">{t('cont.lifecycle.field.failureStage')}</dt>
<dd className="mt-1 text-gray-900">
{action.failure_stage ? t(failureStageLabel[action.failure_stage]) : '—'}
</dd>
</div>
{action.failure_summary && (
<div className="sm:col-span-2 lg:col-span-3">
<dt className="text-xs text-gray-500">失败说明</dt>
<dt className="text-xs text-gray-500">{t('cont.lifecycle.field.failureSummary')}</dt>
<dd className="mt-1 text-gray-900">{action.failure_summary}</dd>
</div>
)}
Expand All @@ -126,7 +164,7 @@ export const ContainmentLifecycleCard: React.FC<ContainmentLifecycleCardProps> =
disabled={reviewing}
className="rounded border border-gray-300 bg-white px-3 py-1.5 text-xs text-gray-600 disabled:opacity-40"
>
标记已处置
{t('cont.lifecycle.markResolved')}
</button>
</div>
)}
Expand Down
9 changes: 6 additions & 3 deletions src/agentsight/dashboard/src/components/CopyButton.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React, { useState, useRef } from 'react';
import { useI18n } from '../i18n';

function fallbackCopy(text: string, done: () => void) {
const el = document.createElement('textarea');
Expand All @@ -25,8 +26,9 @@ export function copyText(text: string, done: () => void) {
/** 复制按钮组件,点击后短暂显示「已复制」反馈 */
export const CopyButton: React.FC<{ text: string; title?: string }> = ({
text,
title = '复制完整 ID',
title,
}) => {
const { t } = useI18n();
const [copied, setCopied] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleCopy = (e: React.MouseEvent) => {
Expand All @@ -39,6 +41,7 @@ export const CopyButton: React.FC<{ text: string; title?: string }> = ({
// HTTP 环境下 clipboard API 可能不可用,使用 execCommand fallback
copyText(text, done);
};
const resolvedTitle = title ?? t('common.copyFullId');
return (
<button
onClick={handleCopy}
Expand All @@ -47,9 +50,9 @@ export const CopyButton: React.FC<{ text: string; title?: string }> = ({
? 'bg-green-100 text-green-600'
: 'bg-gray-100 hover:bg-gray-200 text-gray-500 hover:text-gray-700'
}`}
title={title}
title={resolvedTitle}
>
{copied ? '✓ 已复制' : '复制'}
{copied ? t('common.copied') : `⧉ ${t('common.copy')}`}
</button>
);
};
20 changes: 11 additions & 9 deletions src/agentsight/dashboard/src/components/EvaluationBadge.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import React from 'react';
import { EvaluationResult } from '../utils/apiClient';
import { useI18n } from '../i18n';
import type { MessageKey } from '../i18n';

interface EvaluationBadgeProps {
result: Pick<EvaluationResult, 'verdict' | 'score'> | null;
Expand All @@ -11,26 +13,26 @@ const STYLE_BY_VERDICT = {
fail: 'bg-red-50 text-red-700 border-red-200',
} as const;

const LABEL_BY_VERDICT = {
pass: '通过',
warn: '需复核',
fail: '未通过',
} as const;
const VERDICT_LABEL_KEY: Record<string, MessageKey> = {
pass: 'comp.eval.pass',
warn: 'comp.eval.review',
fail: 'comp.eval.fail',
};

export const EvaluationBadge: React.FC<EvaluationBadgeProps> = ({ result }) => {
const { t } = useI18n();
if (!result) return null;

const style =
STYLE_BY_VERDICT[result.verdict as keyof typeof STYLE_BY_VERDICT] ??
'bg-gray-50 text-gray-700 border-gray-200';
const label =
LABEL_BY_VERDICT[result.verdict as keyof typeof LABEL_BY_VERDICT] ??
result.verdict;
const labelKey = VERDICT_LABEL_KEY[result.verdict];
const label = labelKey ? t(labelKey) : result.verdict;

return (
<span
className={`inline-flex items-center gap-1 rounded border px-2 py-0.5 text-xs font-semibold ${style}`}
title={`质量分 ${Math.round(result.score * 100)}`}
title={t('comp.eval.qualityScore', { n: Math.round(result.score * 100) })}
>
<span>{label}</span>
<span className="font-mono">{Math.round(result.score * 100)}</span>
Expand Down
Loading
Loading