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
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行, 单文件版是活跃代码)
442 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|