webui layout: add dedicated EKG menu/page and declutter task-audit layout

This commit is contained in:
DBT
2026-03-01 08:35:11 +00:00
parent 13f0b1905f
commit be2381f128
5 changed files with 121 additions and 112 deletions

View File

@@ -12,6 +12,7 @@ import Logs from './pages/Logs';
import Skills from './pages/Skills';
import Memory from './pages/Memory';
import TaskAudit from './pages/TaskAudit';
import EKG from './pages/EKG';
import Tasks from './pages/Tasks';
export default function App() {
@@ -30,6 +31,7 @@ export default function App() {
<Route path="nodes" element={<Nodes />} />
<Route path="memory" element={<Memory />} />
<Route path="task-audit" element={<TaskAudit />} />
<Route path="ekg" element={<EKG />} />
<Route path="tasks" element={<Tasks />} />
</Route>
</Routes>

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { LayoutDashboard, MessageSquare, Settings, Clock, Server, Terminal, Zap, FolderOpen, ClipboardList, ListTodo } from 'lucide-react';
import { LayoutDashboard, MessageSquare, Settings, Clock, Server, Terminal, Zap, FolderOpen, ClipboardList, ListTodo, BrainCircuit } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
import NavItem from './NavItem';
@@ -34,6 +34,12 @@ const Sidebar: React.FC = () => {
{ icon: <ListTodo className="w-5 h-5" />, label: t('tasks'), to: '/tasks' },
],
},
{
title: t('sidebarInsights'),
items: [
{ icon: <BrainCircuit className="w-5 h-5" />, label: t('ekg'), to: '/ekg' },
],
},
];
return (

View File

@@ -18,6 +18,8 @@ const resources = {
sidebarCore: 'Core',
sidebarSystem: 'System',
sidebarOps: 'Operations',
sidebarInsights: 'Insights',
ekg: 'EKG',
taskList: 'Task List',
taskDetail: 'Task Detail',
taskQueue: 'Task Queue',
@@ -191,6 +193,8 @@ const resources = {
sidebarCore: '核心',
sidebarSystem: '系统',
sidebarOps: '运维',
sidebarInsights: '洞察',
ekg: 'EKG',
taskList: '任务列表',
taskDetail: '任务详情',
taskQueue: '任务队列',

107
webui/src/pages/EKG.tsx Normal file
View File

@@ -0,0 +1,107 @@
import React, { useEffect, useState } from 'react';
import { useAppContext } from '../context/AppContext';
type EKGKV = { key?: string; score?: number; count?: number };
const EKG: React.FC = () => {
const { q } = useAppContext();
const [loading, setLoading] = useState(false);
const [ekgWindow, setEkgWindow] = useState<'6h' | '24h' | '7d'>(() => {
const saved = typeof window !== 'undefined' ? window.localStorage.getItem('taskAudit.ekgWindow') : null;
return saved === '6h' || saved === '24h' || saved === '7d' ? saved : '24h';
});
const [providerTop, setProviderTop] = useState<EKGKV[]>([]);
const [providerTopWorkload, setProviderTopWorkload] = useState<EKGKV[]>([]);
const [errsigTop, setErrsigTop] = useState<EKGKV[]>([]);
const [errsigTopHeartbeat, setErrsigTopHeartbeat] = useState<EKGKV[]>([]);
const [errsigTopWorkload, setErrsigTopWorkload] = useState<EKGKV[]>([]);
const [sourceStats, setSourceStats] = useState<Record<string, number>>({});
const [channelStats, setChannelStats] = useState<Record<string, number>>({});
const [escalationCount, setEscalationCount] = useState(0);
const fetchData = async () => {
setLoading(true);
try {
const ekgJoin = q ? `${q}&window=${encodeURIComponent(ekgWindow)}` : `?window=${encodeURIComponent(ekgWindow)}`;
const er = await fetch(`/webui/api/ekg_stats${ekgJoin}`);
if (!er.ok) throw new Error(await er.text());
const ej = await er.json();
setProviderTop(Array.isArray(ej.provider_top) ? ej.provider_top : []);
setProviderTopWorkload(Array.isArray(ej.provider_top_workload) ? ej.provider_top_workload : []);
setErrsigTop(Array.isArray(ej.errsig_top) ? ej.errsig_top : []);
setErrsigTopHeartbeat(Array.isArray(ej.errsig_top_heartbeat) ? ej.errsig_top_heartbeat : []);
setErrsigTopWorkload(Array.isArray(ej.errsig_top_workload) ? ej.errsig_top_workload : []);
setSourceStats(ej.source_stats && typeof ej.source_stats === 'object' ? ej.source_stats : {});
setChannelStats(ej.channel_stats && typeof ej.channel_stats === 'object' ? ej.channel_stats : {});
setEscalationCount(Number(ej.escalation_count || 0));
} catch (e) {
console.error(e);
} finally {
setLoading(false);
}
};
useEffect(() => { fetchData(); }, [q, ekgWindow]);
useEffect(() => {
if (typeof window !== 'undefined') window.localStorage.setItem('taskAudit.ekgWindow', ekgWindow);
}, [ekgWindow]);
return (
<div className="h-full p-4 md:p-6 flex flex-col gap-4">
<div className="flex items-center justify-between gap-2 flex-wrap">
<h1 className="text-xl md:text-2xl font-semibold">EKG</h1>
<div className="flex items-center gap-2">
<select value={ekgWindow} onChange={(e)=>setEkgWindow(e.target.value as '6h' | '24h' | '7d')} className="bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-xs">
<option value="6h">6h</option>
<option value="24h">24h</option>
<option value="7d">7d</option>
</select>
<button onClick={fetchData} className="px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-sm">{loading ? 'Loading...' : 'Refresh'}</button>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-3 text-xs">
<div className="rounded-xl border border-zinc-800 bg-zinc-900/40 p-3">
<div className="text-zinc-500 mb-1">Escalations</div>
<div className="text-zinc-100 text-2xl font-semibold">{escalationCount}</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900/40 p-3">
<div className="text-zinc-500 mb-1">Source Stats</div>
<div className="space-y-1">{Object.keys(sourceStats).length === 0 ? <div className="text-zinc-500">-</div> : Object.entries(sourceStats).map(([k,v]) => <div key={k} className="text-zinc-200">{k}: <span className="text-zinc-400">{v}</span></div>)}</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900/40 p-3">
<div className="text-zinc-500 mb-1">Channel Stats</div>
<div className="space-y-1">{Object.keys(channelStats).length === 0 ? <div className="text-zinc-500">-</div> : Object.entries(channelStats).map(([k,v]) => <div key={k} className="text-zinc-200">{k}: <span className="text-zinc-400">{v}</span></div>)}</div>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-3 text-xs">
<div className="rounded-xl border border-zinc-800 bg-zinc-900/40 p-3">
<div className="text-zinc-500 mb-1">Top Providers (workload)</div>
<div className="space-y-1">{providerTopWorkload.length === 0 ? <div className="text-zinc-500">-</div> : providerTopWorkload.map((x,i)=><div key={i} className="text-zinc-200">{x.key} <span className="text-zinc-500">({Number(x.score||0).toFixed(2)})</span></div>)}</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900/40 p-3">
<div className="text-zinc-500 mb-1">Top Providers (all)</div>
<div className="space-y-1">{providerTop.length === 0 ? <div className="text-zinc-500">-</div> : providerTop.map((x,i)=><div key={i} className="text-zinc-200">{x.key} <span className="text-zinc-500">({Number(x.score||0).toFixed(2)})</span></div>)}</div>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-3 gap-3 text-xs flex-1 min-h-0">
<div className="rounded-xl border border-zinc-800 bg-zinc-900/40 p-3 overflow-y-auto">
<div className="text-zinc-500 mb-1">Top Error Signatures (workload)</div>
<div className="space-y-1">{errsigTopWorkload.length === 0 ? <div className="text-zinc-500">-</div> : errsigTopWorkload.map((x,i)=><div key={i} className="text-zinc-200 truncate">{x.key} <span className="text-zinc-500">(x{x.count||0})</span></div>)}</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900/40 p-3 overflow-y-auto">
<div className="text-zinc-500 mb-1">Top Error Signatures (heartbeat)</div>
<div className="space-y-1">{errsigTopHeartbeat.length === 0 ? <div className="text-zinc-500">-</div> : errsigTopHeartbeat.map((x,i)=><div key={i} className="text-zinc-200 truncate">{x.key} <span className="text-zinc-500">(x{x.count||0})</span></div>)}</div>
</div>
<div className="rounded-xl border border-zinc-800 bg-zinc-900/40 p-3 overflow-y-auto">
<div className="text-zinc-500 mb-1">Top Error Signatures (all)</div>
<div className="space-y-1">{errsigTop.length === 0 ? <div className="text-zinc-500">-</div> : errsigTop.map((x,i)=><div key={i} className="text-zinc-200 truncate">{x.key} <span className="text-zinc-500">(x{x.count||0})</span></div>)}</div>
</div>
</div>
</div>
);
};
export default EKG;

View File

@@ -2,8 +2,6 @@ import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppContext } from '../context/AppContext';
type EKGKV = { key?: string; score?: number; count?: number };
type TaskAuditItem = {
task_id?: string;
time?: string;
@@ -39,18 +37,6 @@ const TaskAudit: React.FC = () => {
const [dailyReport, setDailyReport] = useState<string>('');
const [reportDate, setReportDate] = useState<string>(new Date().toISOString().slice(0,10));
const [showDailyReport, setShowDailyReport] = useState(false);
const [ekgProviderTop, setEkgProviderTop] = useState<EKGKV[]>([]);
const [ekgProviderTopWorkload, setEkgProviderTopWorkload] = useState<EKGKV[]>([]);
const [ekgErrsigTop, setEkgErrsigTop] = useState<EKGKV[]>([]);
const [ekgErrsigTopHeartbeat, setEkgErrsigTopHeartbeat] = useState<EKGKV[]>([]);
const [ekgErrsigTopWorkload, setEkgErrsigTopWorkload] = useState<EKGKV[]>([]);
const [ekgSourceStats, setEkgSourceStats] = useState<Record<string, number>>({});
const [ekgChannelStats, setEkgChannelStats] = useState<Record<string, number>>({});
const [ekgEscalationCount, setEkgEscalationCount] = useState<number>(0);
const [ekgWindow, setEkgWindow] = useState<'6h' | '24h' | '7d'>(() => {
const saved = typeof window !== 'undefined' ? window.localStorage.getItem('taskAudit.ekgWindow') : null;
return saved === '6h' || saved === '24h' || saved === '7d' ? saved : '24h';
});
const fetchData = async () => {
setLoading(true);
@@ -71,20 +57,6 @@ const TaskAudit: React.FC = () => {
} else {
setDailyReport('');
}
const ekgJoin = q ? `${q}&window=${encodeURIComponent(ekgWindow)}` : `?window=${encodeURIComponent(ekgWindow)}`;
const ekgUrl = `/webui/api/ekg_stats${ekgJoin}`;
const er = await fetch(ekgUrl);
if (er.ok) {
const ej = await er.json();
setEkgProviderTop(Array.isArray(ej.provider_top) ? ej.provider_top : []);
setEkgProviderTopWorkload(Array.isArray(ej.provider_top_workload) ? ej.provider_top_workload : []);
setEkgErrsigTop(Array.isArray(ej.errsig_top) ? ej.errsig_top : []);
setEkgErrsigTopHeartbeat(Array.isArray(ej.errsig_top_heartbeat) ? ej.errsig_top_heartbeat : []);
setEkgErrsigTopWorkload(Array.isArray(ej.errsig_top_workload) ? ej.errsig_top_workload : []);
setEkgSourceStats(ej.source_stats && typeof ej.source_stats === 'object' ? ej.source_stats : {});
setEkgChannelStats(ej.channel_stats && typeof ej.channel_stats === 'object' ? ej.channel_stats : {});
setEkgEscalationCount(Number(ej.escalation_count || 0));
}
} catch (e) {
console.error(e);
setItems([]);
@@ -94,13 +66,8 @@ const TaskAudit: React.FC = () => {
}
};
useEffect(() => { fetchData(); }, [q, reportDate, ekgWindow]);
useEffect(() => { fetchData(); }, [q, reportDate]);
useEffect(() => {
if (typeof window !== 'undefined') {
window.localStorage.setItem('taskAudit.ekgWindow', ekgWindow);
}
}, [ekgWindow]);
@@ -159,83 +126,6 @@ const TaskAudit: React.FC = () => {
</div>
</div>
<div className="border border-zinc-800 rounded-xl bg-zinc-900/40 p-3 text-sm">
<div className="flex items-center justify-between gap-2 mb-2">
<div className="text-xs text-zinc-400 uppercase tracking-wider">EKG Insights</div>
<select value={ekgWindow} onChange={(e)=>setEkgWindow(e.target.value as '6h' | '24h' | '7d')} className="bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-xs">
<option value="6h">6h</option>
<option value="24h">24h</option>
<option value="7d">7d</option>
</select>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-xs">
<div className="rounded-lg border border-zinc-800 bg-zinc-950/40 p-2">
<div className="text-zinc-500 mb-1">Escalations</div>
<div className="text-zinc-100 text-base font-semibold">{ekgEscalationCount}</div>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/40 p-2">
<div className="text-zinc-500 mb-1">Source Stats</div>
<div className="space-y-1">
{Object.keys(ekgSourceStats).length === 0 ? <div className="text-zinc-500">-</div> : Object.entries(ekgSourceStats).map(([k, v]) => (
<div key={k} className="text-zinc-200">{k}: <span className="text-zinc-400">{v}</span></div>
))}
</div>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/40 p-2">
<div className="text-zinc-500 mb-1">Channel Stats</div>
<div className="space-y-1">
{Object.keys(ekgChannelStats).length === 0 ? <div className="text-zinc-500">-</div> : Object.entries(ekgChannelStats).map(([k, v]) => (
<div key={k} className="text-zinc-200">{k}: <span className="text-zinc-400">{v}</span></div>
))}
</div>
</div>
</div>
<div className="mt-2 grid grid-cols-1 md:grid-cols-2 gap-2 text-xs">
<div className="rounded-lg border border-zinc-800 bg-zinc-950/40 p-2">
<div className="text-zinc-500 mb-1">Top Providers (workload)</div>
<div className="space-y-1">
{ekgProviderTopWorkload.length === 0 ? <div className="text-zinc-500">-</div> : ekgProviderTopWorkload.map((x, i) => (
<div key={`${x.key}-${i}`} className="text-zinc-200">{x.key} <span className="text-zinc-500">({Number(x.score || 0).toFixed(2)})</span></div>
))}
</div>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/40 p-2">
<div className="text-zinc-500 mb-1">Top Providers (all)</div>
<div className="space-y-1">
{ekgProviderTop.length === 0 ? <div className="text-zinc-500">-</div> : ekgProviderTop.map((x, i) => (
<div key={`${x.key}-${i}`} className="text-zinc-200">{x.key} <span className="text-zinc-500">({Number(x.score || 0).toFixed(2)})</span></div>
))}
</div>
</div>
</div>
<div className="mt-2 grid grid-cols-1 md:grid-cols-3 gap-2 text-xs">
<div className="rounded-lg border border-zinc-800 bg-zinc-950/40 p-2">
<div className="text-zinc-500 mb-1">Top Error Signatures (workload)</div>
<div className="space-y-1 max-h-24 overflow-y-auto">
{ekgErrsigTopWorkload.length === 0 ? <div className="text-zinc-500">-</div> : ekgErrsigTopWorkload.map((x, i) => (
<div key={`${x.key}-${i}`} className="text-zinc-200 truncate">{x.key} <span className="text-zinc-500">(x{x.count || 0})</span></div>
))}
</div>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/40 p-2">
<div className="text-zinc-500 mb-1">Top Error Signatures (heartbeat)</div>
<div className="space-y-1 max-h-24 overflow-y-auto">
{ekgErrsigTopHeartbeat.length === 0 ? <div className="text-zinc-500">-</div> : ekgErrsigTopHeartbeat.map((x, i) => (
<div key={`${x.key}-${i}`} className="text-zinc-200 truncate">{x.key} <span className="text-zinc-500">(x{x.count || 0})</span></div>
))}
</div>
</div>
<div className="rounded-lg border border-zinc-800 bg-zinc-950/40 p-2">
<div className="text-zinc-500 mb-1">Top Error Signatures (all)</div>
<div className="space-y-1 max-h-24 overflow-y-auto">
{ekgErrsigTop.length === 0 ? <div className="text-zinc-500">-</div> : ekgErrsigTop.map((x, i) => (
<div key={`${x.key}-${i}`} className="text-zinc-200 truncate">{x.key} <span className="text-zinc-500">(x{x.count || 0})</span></div>
))}
</div>
</div>
</div>
</div>
<div className="border border-zinc-800 rounded-xl bg-zinc-900/40 p-3 text-sm">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div className="text-xs text-zinc-400 uppercase tracking-wider">{t('dailySummary')}</div>