mirror of
https://github.com/YspCoder/clawgo.git
synced 2026-04-14 19:37:31 +08:00
webui layout: add dedicated EKG menu/page and declutter task-audit layout
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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
107
webui/src/pages/EKG.tsx
Normal 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;
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user