Files
zclaw_openfang/desktop/src/components/HealthPanel.tsx
iven 215c079d29
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(intelligence): Heartbeat 统一健康系统 — 6处断链修复 + 健康面板 + SaaS自动恢复
Rust 后端 (heartbeat.rs):
- 告警实时推送: OnceLock<AppHandle> + Tauri emit heartbeat:alert
- 动态间隔: tokio::select! + Notify 替代不可变 interval
- Config 持久化: update_config 写入 VikingStorage
- heartbeat_init 从 VikingStorage 恢复 config
- 移除 dead code (subscribe, HeartbeatCheckFn)
- Memory stats fallback 分层处理

新增 health_snapshot.rs:
- HealthSnapshot Tauri 命令 — 按需查询引擎/记忆状态
- 注册到 lib.rs invoke_handler

前端修复:
- HeartbeatConfig handleSave 同步到 Rust 后端
- App.tsx 读 localStorage 持久化配置 + heartbeat:alert 监听 + toast
- saasStore 降级后指数退避探测恢复 + saas-recovered 事件
- 新增 HealthPanel.tsx 只读健康面板 (4卡片 + 告警列表)
- SettingsLayout 添加 health 导航入口

清理:
- 删除 intelligence-client/ 目录版 (9文件 -1640行, 单文件版是活跃代码)
2026-04-15 23:19:24 +08:00

442 lines
16 KiB
TypeScript

/**
* HealthPanel — Read-only dashboard for all subsystem health status
*
* Displays:
* - Agent Heartbeat engine status (running, config, alerts)
* - Connection status (mode, SaaS reachability)
* - SaaS device heartbeat status
* - Memory pipeline status
* - Recent alerts history
*
* No config editing (that's HeartbeatConfig tab).
* Uses useState (not Zustand) — component-scoped state.
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import {
Activity,
RefreshCw,
Wifi,
WifiOff,
Cloud,
CloudOff,
Database,
AlertTriangle,
CheckCircle,
XCircle,
Clock,
} from 'lucide-react';
import { intelligenceClient, type HeartbeatResult } from '../lib/intelligence-client';
import { useConnectionStore } from '../store/connectionStore';
import { useSaaSStore } from '../store/saasStore';
import { isTauriRuntime } from '../lib/tauri-gateway';
import { safeListen } from '../lib/safe-tauri';
import { createLogger } from '../lib/logger';
const log = createLogger('HealthPanel');
// === Types ===
interface HealthSnapshotData {
timestamp: string;
intelligence: {
engineRunning: boolean;
config: {
enabled: boolean;
interval_minutes: number;
proactivity_level: string;
};
lastTick: string | null;
alertCount24h: number;
totalChecks: number;
};
memory: {
totalEntries: number;
storageSizeBytes: number;
lastExtraction: string | null;
};
}
interface HealthCardProps {
title: string;
icon: React.ReactNode;
status: 'green' | 'yellow' | 'gray' | 'red';
children: React.ReactNode;
}
const STATUS_COLORS = {
green: 'text-green-500',
yellow: 'text-yellow-500',
gray: 'text-gray-400',
red: 'text-red-500',
};
const STATUS_BG = {
green: 'bg-green-50 dark:bg-green-900/20',
yellow: 'bg-yellow-50 dark:bg-yellow-900/20',
gray: 'bg-gray-50 dark:bg-gray-800/50',
red: 'bg-red-50 dark:bg-red-900/20',
};
function HealthCard({ title, icon, status, children }: HealthCardProps) {
return (
<div className={`rounded-lg border border-gray-200 dark:border-gray-700 p-4 ${STATUS_BG[status]}`}>
<div className="flex items-center gap-2 mb-3">
<span className={STATUS_COLORS[status]}>{icon}</span>
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">{title}</h3>
<span className={`ml-auto text-xs ${STATUS_COLORS[status]}`}>
{status === 'green' ? '正常' : status === 'yellow' ? '降级' : status === 'red' ? '异常' : '未启用'}
</span>
</div>
<div className="space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
{children}
</div>
</div>
);
}
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
}
function formatTime(isoString: string | null): string {
if (!isoString) return '从未';
try {
const date = new Date(isoString);
return date.toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
} catch {
return isoString;
}
}
function formatUrgency(urgency: string): { label: string; color: string } {
switch (urgency) {
case 'high': return { label: '高', color: 'text-red-500' };
case 'medium': return { label: '中', color: 'text-yellow-500' };
case 'low': return { label: '低', color: 'text-blue-500' };
default: return { label: urgency, color: 'text-gray-500' };
}
}
// === Main Component ===
export function HealthPanel() {
const [snapshot, setSnapshot] = useState<HealthSnapshotData | null>(null);
const [alerts, setAlerts] = useState<HeartbeatResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const alertsEndRef = useRef<HTMLDivElement>(null);
// Get live connection and SaaS state
const connectionState = useConnectionStore((s) => s.connectionState);
const gatewayVersion = useConnectionStore((s) => s.gatewayVersion);
const connectionMode = useSaaSStore((s) => s.connectionMode);
const saasReachable = useSaaSStore((s) => s.saasReachable);
const consecutiveFailures = useSaaSStore((s) => s._consecutiveFailures);
const isLoggedIn = useSaaSStore((s) => s.isLoggedIn);
// Fetch health snapshot
const fetchSnapshot = useCallback(async () => {
if (!isTauriRuntime()) return;
setLoading(true);
setError(null);
try {
const { invoke } = await import('@tauri-apps/api/core');
const data = await invoke<HealthSnapshotData>('health_snapshot', {
agentId: 'zclaw-main',
});
setSnapshot(data);
} catch (err) {
log.warn('Failed to fetch health snapshot:', err);
setError(String(err));
} finally {
setLoading(false);
}
}, []);
// Fetch alert history
const fetchAlerts = useCallback(async () => {
if (!isTauriRuntime()) return;
try {
const history = await intelligenceClient.heartbeat.getHistory('zclaw-main', 100);
setAlerts(history);
} catch (err) {
log.warn('Failed to fetch alert history:', err);
}
}, []);
// Initial load
useEffect(() => {
fetchSnapshot();
fetchAlerts();
}, [fetchSnapshot, fetchAlerts]);
// Subscribe to real-time alerts
useEffect(() => {
if (!isTauriRuntime()) return;
let unlisten: (() => void) | null = null;
const subscribe = async () => {
unlisten = await safeListen<Array<{ title: string; content: string; urgency: string; source: string; timestamp: string }>>(
'heartbeat:alert',
(newAlerts) => {
// Prepend new alerts to history
setAlerts((prev) => {
const result: HeartbeatResult[] = [
{
status: 'alert',
alerts: newAlerts.map((a) => ({
title: a.title,
content: a.content,
urgency: a.urgency as 'low' | 'medium' | 'high',
source: a.source,
timestamp: a.timestamp,
})),
checked_items: 0,
timestamp: new Date().toISOString(),
},
...prev,
];
// Keep max 100
return result.slice(0, 100);
});
},
);
};
subscribe();
return () => {
if (unlisten) unlisten();
};
}, []);
// Auto-scroll alerts to show latest
useEffect(() => {
alertsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [alerts]);
// Determine SaaS card status
const saasStatus: 'green' | 'yellow' | 'gray' | 'red' = !isLoggedIn
? 'gray'
: saasReachable
? 'green'
: 'red';
// Determine connection card status
const isActuallyConnected = connectionState === 'connected';
const connectionStatus: 'green' | 'yellow' | 'gray' | 'red' = isActuallyConnected
? 'green'
: connectionState === 'connecting' || connectionState === 'reconnecting'
? 'yellow'
: 'red';
// Determine heartbeat card status
const heartbeatStatus: 'green' | 'yellow' | 'gray' | 'red' = !snapshot
? 'gray'
: snapshot.intelligence.engineRunning
? 'green'
: snapshot.intelligence.config.enabled
? 'yellow'
: 'gray';
// Determine memory card status
const memoryStatus: 'green' | 'yellow' | 'gray' | 'red' = !snapshot
? 'gray'
: snapshot.memory.totalEntries === 0
? 'gray'
: snapshot.memory.storageSizeBytes > 50 * 1024 * 1024
? 'yellow'
: 'green';
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2">
<Activity className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100"></h2>
</div>
<button
onClick={() => { fetchSnapshot(); fetchAlerts(); }}
disabled={loading}
className="flex items-center gap-1 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded-lg">
: {error}
</div>
)}
{/* Health Cards Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{/* Agent Heartbeat Card */}
<HealthCard
title="Agent 心跳"
icon={<Activity className="w-4 h-4" />}
status={heartbeatStatus}
>
<div className="flex justify-between">
<span></span>
<span className={snapshot?.intelligence.engineRunning ? 'text-green-600' : 'text-gray-400'}>
{snapshot?.intelligence.engineRunning ? '运行中' : '已停止'}
</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{snapshot?.intelligence.config.interval_minutes ?? '-'} </span>
</div>
<div className="flex justify-between">
<span></span>
<span>{formatTime(snapshot?.intelligence.lastTick ?? null)}</span>
</div>
<div className="flex justify-between">
<span>24h </span>
<span>{snapshot?.intelligence.alertCount24h ?? 0}</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{snapshot?.intelligence.config.proactivity_level ?? '-'}</span>
</div>
</HealthCard>
{/* Connection Card */}
<HealthCard
title="连接状态"
icon={isActuallyConnected ? <Wifi className="w-4 h-4" /> : <WifiOff className="w-4 h-4" />}
status={connectionStatus}
>
<div className="flex justify-between">
<span></span>
<span>{connectionMode === 'saas' ? 'SaaS 云端' : connectionMode === 'tauri' ? '本地模式' : connectionMode}</span>
</div>
<div className="flex justify-between">
<span></span>
<span className={isActuallyConnected ? 'text-green-600' : connectionState === 'connecting' ? 'text-yellow-500' : 'text-red-500'}>
{connectionState === 'connected' ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接'}
</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{gatewayVersion ?? '-'}</span>
</div>
<div className="flex justify-between">
<span>SaaS </span>
<span className={saasReachable ? 'text-green-600' : 'text-red-500'}>
{saasReachable ? '是' : '否'}
</span>
</div>
</HealthCard>
{/* SaaS Device Card */}
<HealthCard
title="SaaS 设备"
icon={saasReachable ? <Cloud className="w-4 h-4" /> : <CloudOff className="w-4 h-4" />}
status={saasStatus}
>
<div className="flex justify-between">
<span></span>
<span>{isLoggedIn ? '已注册' : '未注册'}</span>
</div>
<div className="flex justify-between">
<span></span>
<span className={consecutiveFailures > 0 ? 'text-yellow-500' : 'text-green-600'}>
{consecutiveFailures}
</span>
</div>
<div className="flex justify-between">
<span></span>
<span className={saasReachable ? 'text-green-600' : 'text-red-500'}>
{saasReachable ? '在线' : isLoggedIn ? '离线 (已降级)' : '未连接'}
</span>
</div>
</HealthCard>
{/* Memory Card */}
<HealthCard
title="记忆管道"
icon={<Database className="w-4 h-4" />}
status={memoryStatus}
>
<div className="flex justify-between">
<span></span>
<span>{snapshot?.memory.totalEntries ?? 0}</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{formatBytes(snapshot?.memory.storageSizeBytes ?? 0)}</span>
</div>
<div className="flex justify-between">
<span></span>
<span>{formatTime(snapshot?.memory.lastExtraction ?? null)}</span>
</div>
</HealthCard>
</div>
{/* Alerts History */}
<div className="rounded-lg border border-gray-200 dark:border-gray-700">
<div className="flex items-center gap-2 p-3 border-b border-gray-200 dark:border-gray-700">
<AlertTriangle className="w-4 h-4 text-yellow-500" />
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100"></h3>
<span className="ml-auto text-xs text-gray-400">
{alerts.reduce((sum, r) => sum + r.alerts.length, 0)}
</span>
</div>
<div className="max-h-64 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
{alerts.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-400"></div>
) : (
alerts.map((result, ri) =>
result.alerts.map((alert, ai) => (
<div key={`${ri}-${ai}`} className="flex items-start gap-2 p-3 hover:bg-gray-50 dark:hover:bg-gray-800/50">
<span className={`mt-0.5 ${formatUrgency(alert.urgency).color}`}>
{alert.urgency === 'high' ? (
<XCircle className="w-3.5 h-3.5" />
) : alert.urgency === 'medium' ? (
<AlertTriangle className="w-3.5 h-3.5" />
) : (
<CheckCircle className="w-3.5 h-3.5" />
)}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-xs font-medium text-gray-900 dark:text-gray-100 truncate">
{alert.title}
</span>
<span className={`text-xs px-1 rounded ${formatUrgency(alert.urgency).color} bg-opacity-10`}>
{formatUrgency(alert.urgency).label}
</span>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{alert.content}</p>
</div>
<span className="text-xs text-gray-400 whitespace-nowrap flex items-center gap-1">
<Clock className="w-3 h-3" />
{formatTime(alert.timestamp)}
</span>
</div>
))
)
)}
<div ref={alertsEndRef} />
</div>
</div>
</div>
</div>
);
}