refactor(quality): Q19 前端巨型组件拆分 — GrafanaDashboard 624L→78L (-87%), 提取 grafana-dashboard/ 5 个独立文件 (types + datasource-toolbar + BatchDeployDialog + AgentTab + ExternalDatasourceTab), DRY 合并 Zabbix/Prometheus Tab 为通用 ExternalDatasourceTab (render-prop 模式), 移除文件级 eslint-disable + 类型安全升级 (any→具体接口), 零逻辑变更, ESLint errors=0/warnings=65/any=0, tsc -b=0, npm build✓, 17/17 测试通过

This commit is contained in:
attacker committed 2026-07-08 09:45:39 +08:00
1 parent 69447e4d66
commit 42340edce2
8 files changed
+1018 -576

No files matched your search

@@ -1,592 +1,46 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// 监控大屏 — 标签页切换 Agent / Zabbix / Prometheus // Logan.Li
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Monitor,
RefreshCw,
Maximize2,
Minimize2,
BarChart3,
Server,
Activity,
Database,
Wifi,
WifiOff,
ArrowUpDown,
ArrowUp,
ArrowDown,
ExternalLink,
Rocket,
PackageOpen,
Loader2,
Info,
Trash2,
} from 'lucide-react';
import { Monitor, Server, Activity, Maximize2, Minimize2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { toast } from 'sonner';
import { useUserStore } from '@/store';
import { promDatasourceApi, zabbixDatasourceApi, agentApi, serverApi } from '@/api';
import { PageShell } from '@/components/layout/PageShell';
import { promDatasourceApi, zabbixDatasourceApi } from '@/api';
import { ZabbixDashboard } from './ZabbixDashboard';
import { PrometheusDashboard } from './PrometheusDashboard';
import { asList } from '@/lib/agent-utils';
import { AgentWizard } from '@/components/agent/AgentWizard';
import { Checkbox } from '@/components/ui/checkbox';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { useNavigate } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { PageShell } from '@/components/layout/PageShell';
import { PageLoading } from '@/components/layout/PageLoading';
import { PageEmpty } from '@/components/layout/PageEmpty';
import i18n from '@/i18n';
import type { TabId } from './grafana-dashboard/types';
import { AgentTab } from './grafana-dashboard/AgentTab';
import { ExternalDatasourceTab } from './grafana-dashboard/ExternalDatasourceTab';
import { normalizeDatasourceList } from './grafana-dashboard/datasource-toolbar';
// ═══════════ Tab 配置 ═══════════
type TabId = 'agent' | 'zabbix' | 'prometheus';
const getTabs = (): { id: TabId; label: string; icon: React.ReactNode }[] => [
{ id: 'agent', label: i18n.t('monitor.dashboard.tab.agent'), icon: <Server className="w-3.5 h-3.5" /> },
{ id: 'zabbix', label: 'Zabbix', icon: <Activity className="w-3.5 h-3.5" /> },
{ id: 'prometheus', label: 'Prometheus', icon: <Activity className="w-3.5 h-3.5" /> },
];
// ═══════════ Agent 主机监控表格 ═══════════
interface AgentHost { server_id: number; hostname: string; ip: string; cpu_usage: number; cpu_cores: number; mem_usage: number; mem_total: number; mem_used: number; disk_usage: number; agent_version: string; agent_status: string; os: string; region: string; has_auth?: boolean; }
type SortField = 'hostname' | 'agent_version' | 'agent_status' | 'cpu_usage' | 'mem_usage' | 'disk_usage' | 'region' | 'ip';
const AgentTab: React.FC = () => {
useTranslation();
const _confirm = useConfirm();
const navigate = useNavigate();
const [hosts, setHosts] = useState<AgentHost[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [sf, setSf] = useState<SortField>('cpu_usage');
const [sd, setSd] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [wizardOpen, setWizardOpen] = useState(false);
const [wizardServer, setWizardServer] = useState<any>(null);
// 批量部署对话框
const [batchOpen, setBatchOpen] = useState(false);
const [batchMode, setBatchMode] = useState<'install' | 'upgrade'>('install');
const [batchAck, setBatchAck] = useState(false);
const [batchSubmitting, setBatchSubmitting] = useState(false);
// 批量安装临时凭据(用于未保存凭据的主机)// Logan.Li
const [batchCreds, setBatchCreds] = useState<{ username: string; password: string; sshKey: string; authType: 'password' | 'key' }>({ username: 'root', password: '', sshKey: '', authType: 'password' });
// 正在执行操作的主机 ID 集合(版本列 loading)
const [operating, setOperating] = useState<Set<number>>(new Set());
// 批量卸载
const [batchUninstalling, setBatchUninstalling] = useState(false);
const handleBatchUninstall = async () => {
const ok = await _confirm({ title: i18n.t('monitor.dashboard.confirm.uninstallTitle'), description: i18n.t('monitor.dashboard.confirm.uninstallDesc', { count: selIds.length }), confirmText: i18n.t('monitor.dashboard.confirm.uninstall'), variant: 'destructive' });
if (!ok) return;
setBatchUninstalling(true);
setOperating(new Set(selIds));
try {
await agentApi.batchUninstall(selIds);
toast.success(i18n.t('monitor.dashboard.agent.toast.batchUninstall', { count: selIds.length }));
setSelected(new Set());
setTimeout(() => { fetch(); setOperating(new Set()); }, 30000);
} catch { toast.error(i18n.t('monitor.dashboard.agent.toast.submitFailed')); setOperating(new Set()); }
finally { setBatchUninstalling(false); }
};
const totalPages = Math.max(1, Math.ceil(hosts.length / pageSize));
const fetch = async () => {
setRefreshing(true);
try {
const list = asList(await agentApi.getLatest()) as typeof hosts;
setHosts(list);
// 操作中的主机一旦状态变为 online 即可移除 loading(部署中)状态 // Logan.Li
setOperating(prev => {
const next = new Set(prev);
for (const h of list) {
if (next.has(h.server_id) && h.agent_status === 'online') {
next.delete(h.server_id);
}
}
return next;
});
} catch (e: any) {
toast.error(i18n.t('monitor.dashboard.agent.toast.refreshFailed', { msg: (e?.message || i18n.t('monitor.dashboard.agent.toast.unknownError')) }));
} finally {
setLoading(false);
setRefreshing(false);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => { fetch(); const t = setInterval(fetch, 30000); return () => clearInterval(t); }, []);
const toggle = (f: SortField) => { if (sf === f) setSd(d => d === 'asc' ? 'desc' : 'asc'); else { setSf(f); setSd('desc'); } };
const sorted = [...hosts].sort((a, b) => {
let va: any = (a as any)[sf] ?? '', vb: any = (b as any)[sf] ?? '';
if (sf === 'agent_status') { va = (a as any).agent_status || ''; vb = (b as any).agent_status || ''; }
if (typeof va === 'number') return sd === 'asc' ? va - vb : vb - va;
return sd === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
});
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
const SI = ({ f }: { f: SortField }) => sf !== f ? <ArrowUpDown className="w-3 h-3 dg-text-muted" /> : sd === 'asc' ? <ArrowUp className="w-3 h-3 dg-text-green" /> : <ArrowDown className="w-3 h-3 dg-text-green" />;
const bar = (v: number, l: string) => (
<div className="flex items-center gap-2 min-w-[90px]"><div className="flex-1 h-2.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden"><div className={`h-full rounded-full transition-all duration-500 ${v >= 80 ? 'bg-orange-500' : 'bg-emerald-500'}`} style={{ width: `${Math.min(v, 100)}%` }} /></div><span className={`w-14 text-right text-xs font-mono tabular-nums ${v >= 80 ? 'dg-text-orange' : ''}`}>{l}</span></div>
);
const selAll = selected.size === hosts.length && hosts.length > 0;
const selAny = selected.size > 0;
const selIds = [...selected].map(Number).filter(n => !isNaN(n));
// 选中主机按安装/在线分组
const selectedHosts = useMemo(() => {
return hosts.filter(h => selected.has(String(h.server_id)));
}, [hosts, selected]);
const needInstall = useMemo(() => selectedHosts.filter(h => h.agent_status !== 'online'), [selectedHosts]);
const needUpgrade = useMemo(() => selectedHosts.filter(h => h.agent_status === 'online'), [selectedHosts]);
// 打开批量部署对话框
const openBatchDialog = () => {
setBatchAck(false);
// 自动推断模式: 全无Agent → install,全在线 → upgrade,混合 → install
if (needInstall.length > 0 && needUpgrade.length === 0) setBatchMode('install');
else if (needInstall.length === 0 && needUpgrade.length > 0) setBatchMode('upgrade');
else setBatchMode('install');
setBatchOpen(true);
};
// 执行批量部署
const handleBatchSubmit = async () => {
if (!batchAck) { toast.error(i18n.t('monitor.dashboard.agent.toast.ackRequired')); return; }
setBatchSubmitting(true);
const token = useUserStore.getState().token;
const ids = selectedHosts.map(h => h.server_id);
setOperating(new Set(ids));
try {
if (batchMode === 'install') {
if (!token) { toast.error(i18n.t('monitor.dashboard.agent.toast.notLogin')); return; }
// 安装:只安装无 Agent 的
const installIds = needInstall.map(h => h.server_id);
if (installIds.length === 0) { toast.warning(i18n.t('monitor.dashboard.agent.toast.allInstalled')); return; }
// 为未保存凭据的主机构造临时凭据 map;后端遇到空字段不会覆盖已有凭据 // Logan.Li
const credentials: Record<number, { username?: string; password?: string; ssh_key?: string; auth_type?: string }> = {};
const needCreds = batchCreds.username && (batchCreds.authType === 'key' ? batchCreds.sshKey : batchCreds.password);
if (needCreds) {
for (const sid of installIds) {
credentials[sid] = {
username: batchCreds.username,
auth_type: batchCreds.authType,
password: batchCreds.authType === 'password' ? batchCreds.password : undefined,
ssh_key: batchCreds.authType === 'key' ? batchCreds.sshKey : undefined,
};
}
}
const res = await serverApi.batchInstallAgent(installIds, token, Object.keys(credentials).length ? credentials : undefined);
toast.success(i18n.t('monitor.dashboard.agent.toast.batchInstall', { count: res?.total ?? installIds.length }));
setSelected(new Set());
setTimeout(() => { fetch(); setOperating(new Set()); }, 30000);
} else {
// 升级:只升级在线的
const upgradeIds = needUpgrade.map(h => h.server_id);
if (upgradeIds.length === 0) { toast.warning(i18n.t('monitor.dashboard.agent.toast.noOnline')); return; }
const res = await agentApi.batchUpgrade(upgradeIds);
toast.success(res?.message || i18n.t('monitor.dashboard.agent.toast.batchUpgrade', { count: upgradeIds.length }));
setSelected(new Set());
setTimeout(() => { fetch(); setOperating(new Set()); }, 30000);
}
setBatchOpen(false);
} catch { toast.error(i18n.t('monitor.dashboard.agent.toast.submitFailed')); setOperating(new Set()); }
finally { setBatchSubmitting(false); }
};
if (loading) return <PageLoading label={i18n.t('monitor.dashboard.agent.loadingHosts')} />;
if (!hosts.length) return (
<PageEmpty
icon={<Server className="h-8 w-8" />}
title={i18n.t('monitor.dashboard.agent.emptyTitle')}
description={i18n.t('monitor.dashboard.agent.emptyDesc')}
/>
);
return (
<div>
{/* 工具栏 */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
{selAny && <span className="text-xs text-[var(--dg-text-secondary)]">{i18n.t('monitor.dashboard.agent.selected', { count: selected.size })}</span>}
<div className="flex-1" />
<Button size="sm" variant="outline" className="h-7 text-xs gap-1" onClick={() => {
const picked = selAny ? hosts.find(h => selected.has(String(h.server_id))) : null;
setWizardServer(picked ? { id: picked.server_id, hostname: picked.hostname, ip: picked.ip, has_auth: picked.has_auth, os: picked.os } : null);
setWizardOpen(true);
}}>
<Rocket className="w-3 h-3" />{i18n.t('monitor.dashboard.agent.installAgent')}
</Button>
{selAny && (
<>
<Button size="sm" variant="outline" className="h-7 text-xs gap-1 dg-btn-secondary dg-text-blue" onClick={openBatchDialog}>
<PackageOpen className="w-3 h-3" />{i18n.t('monitor.dashboard.agent.batchDeploy')} ({selected.size})
</Button>
<Button size="sm" variant="outline" className="h-7 text-xs gap-1 border-red-500/30 dg-text-red hover:bg-red-500/10" onClick={handleBatchUninstall} disabled={batchUninstalling}>
<Trash2 className="w-3 h-3" />{i18n.t('monitor.dashboard.agent.batchUninstall')} ({selected.size})
</Button>
</>
)}
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" onClick={fetch} disabled={refreshing}>
<RefreshCw className={cn('w-3 h-3', refreshing && 'animate-spin')} />{i18n.t('monitor.dashboard.refresh')}
</Button>
</div>
<AgentWizard open={wizardOpen} onOpenChange={setWizardOpen} server={wizardServer}
onStart={(id) => { setOperating(prev => new Set(prev).add(id)); }}
onInstalled={() => { toast.success(i18n.t('monitor.dashboard.agent.toast.installed')); setWizardOpen(false); fetch(); }}
onFailed={(id) => { toast.error(i18n.t('monitor.dashboard.agent.toast.installFailed')); setOperating(prev => { const next = new Set(prev); next.delete(id); return next; }); }} />
{/* ────────── 批量部署 Agent 对话框 ────────── */}
<Dialog open={batchOpen} onOpenChange={(v) => { if (!v) { setBatchOpen(false); setBatchAck(false); setBatchCreds({ username: 'root', password: '', sshKey: '', authType: 'password' }); } }}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-auto dg-input">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<PackageOpen className="w-5 h-5 dg-text-blue" />
{i18n.t('monitor.dashboard.batch.title')}
</DialogTitle>
<DialogDescription className="dg-text-secondary text-xs">
{i18n.t('monitor.dashboard.batch.summary', { selected: selectedHosts.length, install: needInstall.length, upgrade: needUpgrade.length })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 模式选择 — 类似阿里云安装/升级切换 */}
<div className="flex items-center gap-2 p-1 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] w-fit">
{(['install', 'upgrade'] as const).map(m => (
<button
key={m}
disabled={m === 'install' ? needInstall.length === 0 : needUpgrade.length === 0}
onClick={() => setBatchMode(m)}
className={`px-4 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-30 disabled:cursor-not-allowed ${
batchMode === m ? 'dg-btn-primary text-white shadow-sm' : 'dg-text-secondary hover:text-[var(--dg-text-primary)]'
}`}
>
{m === 'install' ? i18n.t('monitor.dashboard.batch.install', { count: needInstall.length }) : i18n.t('monitor.dashboard.batch.upgrade', { count: needUpgrade.length })}
</button>
))}
</div>
{/* 主机列表 */}
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-[var(--dg-bg-tertiary)]">
<tr>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">{i18n.t('monitor.dashboard.col.hostname')}</th>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">IP</th>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">{i18n.t('monitor.dashboard.col.status')}</th>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">{i18n.t('monitor.dashboard.col.version')}</th>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">{i18n.t('monitor.dashboard.col.action')}</th>
</tr>
</thead>
<tbody>
{(batchMode === 'install' ? needInstall : needUpgrade).map(h => (
<tr key={h.server_id} className="border-t border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">{h.hostname}</td>
<td className="px-3 py-2 font-mono dg-text-secondary">{h.ip}</td>
<td className="px-3 py-2">
{h.agent_status === 'online'
? <Badge className="text-[10px] bg-emerald-500/20 dg-text-green">{i18n.t('monitor.dashboard.status.online')}</Badge>
: <Badge className="text-[10px] bg-[var(--dg-bg-active)]/20 dg-text-secondary">
{h.agent_status === 'not_installed' ? i18n.t('monitor.dashboard.status.notInstalled') : h.agent_status || i18n.t('monitor.dashboard.status.offline')}
</Badge>}
</td>
<td className="px-3 py-2 font-mono dg-text-secondary">{h.agent_version || '—'}</td>
<td className="px-3 py-2">
<span className="dg-text-blue text-[11px]">
{batchMode === 'install' ? i18n.t('monitor.dashboard.batch.willInstall') : i18n.t('monitor.dashboard.batch.willUpgrade')}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 安装凭据(安装模式且存在未保存凭据的主机时显示)// Logan.Li */}
{batchMode === 'install' && needInstall.some(h => !h.has_auth) && (
<div className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50 p-3 space-y-3">
<div className="flex items-center gap-2">
<Info className="w-4 h-4 dg-text-blue shrink-0" />
<span className="text-xs text-[var(--dg-text-secondary)]">{i18n.t('monitor.dashboard.creds.hint')}</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-[11px] dg-text-secondary mb-1 block">{i18n.t('monitor.dashboard.creds.authType')}</Label>
<Select value={batchCreds.authType} onValueChange={(v: 'password' | 'key') => setBatchCreds(c => ({ ...c, authType: v }))}>
<SelectTrigger className="h-8 text-xs dg-input text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent className="dg-input text-[var(--dg-text-primary)]">
<SelectItem value="password">{i18n.t('monitor.dashboard.creds.password')}</SelectItem>
<SelectItem value="key">{i18n.t('monitor.dashboard.creds.sshKey')}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[11px] dg-text-secondary mb-1 block">{i18n.t('monitor.dashboard.creds.username')}</Label>
<Input value={batchCreds.username} onChange={e => setBatchCreds(c => ({ ...c, username: e.target.value }))} placeholder="root" className="h-8 text-xs dg-input text-[var(--dg-text-primary)]" />
</div>
</div>
{batchCreds.authType === 'password' ? (
<div>
<Label className="text-[11px] dg-text-secondary mb-1 block">{i18n.t('monitor.dashboard.creds.password')}</Label>
<Input type="password" value={batchCreds.password} onChange={e => setBatchCreds(c => ({ ...c, password: e.target.value }))} placeholder={i18n.t('monitor.dashboard.creds.passwordPlaceholder')} className="h-8 text-xs dg-input text-[var(--dg-text-primary)]" />
</div>
) : (
<div>
<Label className="text-[11px] dg-text-secondary mb-1 block">{i18n.t('monitor.dashboard.creds.sshKey')}</Label>
<textarea value={batchCreds.sshKey} onChange={e => setBatchCreds(c => ({ ...c, sshKey: e.target.value }))} placeholder="-----BEGIN OPENSSH PRIVATE KEY-----" className="w-full h-24 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-[var(--dg-text-primary)] text-xs p-2 resize-none focus:outline-none focus:ring-1 focus:ring-[var(--dg-blue)]" />
</div>
)}
</div>
)}
{/* ⚠️ 警告文案 — 参考阿里云批量安装/升级风格 */}
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3 space-y-2">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 dg-text-yellow mt-0.5 shrink-0" />
<div>
<p className="text-xs font-medium text-amber-300 mb-1">
{(batchMode === 'install' ? i18n.t('monitor.dashboard.warn.thisInstall') : i18n.t('monitor.dashboard.warn.thisUpgrade')) + i18n.t('monitor.dashboard.warn.intro')}
</p>
<p className="text-[11px] dg-text-yellow/80 leading-relaxed">
{batchMode === 'upgrade' ? i18n.t('monitor.dashboard.warn.upgradeNotice') : i18n.t('monitor.dashboard.warn.installNotice')}
</p>
<ul className="mt-1 space-y-0.5 text-[11px] dg-text-secondary list-disc list-inside">
{batchMode === 'upgrade' && (
<>
<li>{i18n.t('monitor.dashboard.warn.u1')}</li>
<li>{i18n.t('monitor.dashboard.warn.u2')}</li>
<li>{i18n.t('monitor.dashboard.warn.u3')}</li>
<li>{i18n.t('monitor.dashboard.warn.u4')}</li>
</>
)}
{batchMode === 'install' && (
<>
<li>{i18n.t('monitor.dashboard.warn.i1')}</li>
<li>{i18n.t('monitor.dashboard.warn.i2')}</li>
<li>{i18n.t('monitor.dashboard.warn.i3')}</li>
<li>{i18n.t('monitor.dashboard.warn.i4')}</li>
</>
)}
<li>{i18n.t('monitor.dashboard.warn.c1')}</li>
<li>{i18n.t('monitor.dashboard.warn.c2')}</li>
</ul>
</div>
</div>
</div>
{/* 我已知悉 勾选 */}
<div className="flex items-center gap-2">
<Checkbox id="batch-ack" checked={batchAck} onCheckedChange={(c) => setBatchAck(!!c)} />
<label htmlFor="batch-ack" className="text-xs text-[var(--dg-text-secondary)] cursor-pointer select-none">{i18n.t('monitor.dashboard.batch.ack')}</label>
</div>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] h-8 text-xs" onClick={() => { setBatchOpen(false); setBatchAck(false); }}>
{i18n.t('monitor.dashboard.batch.cancel')}
</Button>
<Button
className="dg-btn-primary h-8 text-xs gap-1"
disabled={batchSubmitting || !batchAck || (batchMode === 'install' ? needInstall.length === 0 : needUpgrade.length === 0)}
onClick={handleBatchSubmit}
>
{batchSubmitting && <Loader2 className="w-3 h-3 animate-spin" />}
{batchMode === 'install' ? i18n.t('monitor.dashboard.batch.confirmInstall') : i18n.t('monitor.dashboard.batch.confirmUpgrade')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
<table className="w-full text-xs">
<thead className="bg-[var(--dg-bg-tertiary)] border-b"><tr>
<th className="px-2 py-2 w-8">
<Checkbox checked={selAll} onCheckedChange={(c) => setSelected(c ? new Set(hosts.map(h => String(h.server_id))) : new Set())} />
</th>
{['hostname','agent_version','agent_status','cpu_usage','mem_usage','disk_usage','region','ip'].map(f => (
<th key={f} className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] cursor-pointer hover:text-[var(--dg-text-primary)] select-none whitespace-nowrap" onClick={() => toggle(f as SortField)}>
<div className="flex items-center gap-1">{{hostname:i18n.t('monitor.dashboard.col.hostname'),agent_version:i18n.t('monitor.dashboard.col.agentVersion'),agent_status:i18n.t('monitor.dashboard.col.status'),cpu_usage:i18n.t('monitor.dashboard.col.cpu'),mem_usage:i18n.t('monitor.dashboard.col.memory'),disk_usage:i18n.t('monitor.dashboard.col.disk'),region:i18n.t('monitor.dashboard.col.region'),ip:'IP'}[f]}<SI f={f as SortField} /></div>
</th>
))}
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] whitespace-nowrap">{i18n.t('monitor.dashboard.col.action')}</th></tr></thead>
<tbody>
{paged.map((h, i) => (
<tr key={h.hostname + i} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-2 py-2.5">
<Checkbox checked={selected.has(String(h.server_id))} onCheckedChange={(c) => {
const next = new Set(selected);
if (c) {
next.add(String(h.server_id));
} else {
next.delete(String(h.server_id));
}
setSelected(next);
}} />
</td>
<td className="px-3 py-2.5 font-medium text-[var(--dg-text-primary)]">{h.hostname}</td>
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">
{operating.has(h.server_id) ? (
<span className="inline-flex items-center gap-1 dg-text-blue">
<Loader2 className="w-3 h-3 animate-spin" />
<span className="text-[10px]">{i18n.t('monitor.dashboard.agent.deploying')}</span>
</span>
) : (
h.agent_version || '—'
)}
</td>
<td className="px-3 py-2.5">{h.agent_status==='online'?<Badge className="text-[10px] bg-emerald-500/20 dg-text-green"><Wifi className="w-3 h-3 mr-1"/>{i18n.t('monitor.dashboard.status.online')}</Badge>:h.agent_status==='not_installed'?<Badge className="text-[10px] bg-amber-500/20 dg-text-yellow">{i18n.t('monitor.dashboard.status.notInstalled')}</Badge>:<Badge className="text-[10px] bg-[var(--dg-bg-active)]/10 dg-text-secondary"><WifiOff className="w-3 h-3 mr-1"/>{i18n.t('monitor.dashboard.status.offline')}</Badge>}</td>
<td className="px-3 py-2.5">{bar(Number(h.cpu_usage||0), `${Number(h.cpu_usage||0).toFixed(1)}%`)}</td>
<td className="px-3 py-2.5">{bar(Number(h.mem_usage||0), `${Number(h.mem_usage||0).toFixed(1)}%`)}</td>
<td className="px-3 py-2.5">{bar(Number(h.disk_usage||0), `${Number(h.disk_usage||0).toFixed(1)}%`)}</td>
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)]">{h.region||'—'}</td>
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">{h.ip||'—'}</td>
<td className="px-3 py-2.5">
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-blue hover:bg-[var(--dg-bg-hover)] px-2"
onClick={() => navigate(`/host-monitor?host=${encodeURIComponent(h.hostname)}`)}>
<BarChart3 className="w-3.5 h-3.5 mr-1" />{i18n.t('monitor.dashboard.agent.chart')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
{hosts.length > pageSize && (
<div className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs">
<div className="flex items-center gap-2 text-[var(--dg-text-muted)]">
<span>{i18n.t('monitor.dashboard.pagination.total', { count: hosts.length })}</span>
<select className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded px-2 py-0.5 text-xs" value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}>
<option value={10}>{`10/${i18n.t('monitor.dashboard.pagination.perPage')}`}</option>
<option value={20}>{`20/${i18n.t('monitor.dashboard.pagination.perPage')}`}</option>
<option value={30}>{`30/${i18n.t('monitor.dashboard.pagination.perPage')}`}</option>
</select>
</div>
<div className="flex items-center gap-1">
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>{i18n.t('monitor.dashboard.pagination.prev')}</button>
<span className="px-2 text-[var(--dg-text-primary)]">{page} / {totalPages}</span>
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>{i18n.t('monitor.dashboard.pagination.next')}</button>
</div>
</div>
)}
</div>
</div>
);
};
// ═══════════ 数据源 Wrapper ═══════════
interface MonitorDatasource {
id: number | string;
name?: string;
url: string;
username?: string;
password?: string;
is_default?: boolean;
}
const normalizeDatasourceList = (r: any): MonitorDatasource[] => {
const list = (r?.data || r || []) as MonitorDatasource[];
return [...list].sort((a, b) => {
if (!!a.is_default !== !!b.is_default) return a.is_default ? -1 : 1;
return String(a.name || a.url).localeCompare(String(b.name || b.url));
});
};
const DatasourceToolbar: React.FC<{
sources: MonitorDatasource[];
activeId: string;
onChange: (id: string) => void;
onRefresh: () => void;
refreshing?: boolean;
}> = ({ sources, activeId, onChange, onRefresh, refreshing }) => (
<div className="flex items-center gap-2 mb-3 flex-wrap">
<Select value={activeId} onValueChange={onChange}>
<SelectTrigger className="w-[280px] h-8 text-xs dg-input">
<SelectValue placeholder={i18n.t('monitor.datasourceToolbar.selectDatasource')} />
</SelectTrigger>
<SelectContent className="dg-input">
{sources.map(ds => (
<SelectItem key={String(ds.id)} value={String(ds.id)}>
{ds.name || ds.url}{ds.is_default ? i18n.t('monitor.datasourceToolbar.defaultSuffix') : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-8 text-xs gap-1" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={cn('w-3 h-3', refreshing && 'animate-spin')} />{i18n.t('monitor.datasourceToolbar.refreshDatasource')}
</Button>
<span className="text-[11px] text-[var(--dg-text-muted)]">{i18n.t('monitor.datasourceToolbar.datasourceSummary', { count: sources.length })}</span>
</div>
// ═══════════ Zabbix Tab(薄包装) ═══════════
const ZabbixTab: React.FC = () => (
<ExternalDatasourceTab
fetcher={async () => normalizeDatasourceList(await zabbixDatasourceApi.list())}
loadingLabel={i18n.t('monitor.dashboard.zabbix.loading')}
emptyTitle={i18n.t('monitor.dashboard.zabbix.emptyTitle')}
>
{(ds) => <ZabbixDashboard key={String(ds.id)} dsUrl={ds.url} dsUsername={ds.username} dsPassword={ds.password} />}
</ExternalDatasourceTab>
);
const ZabbixTab: React.FC = () => {
useTranslation();
const [sources, setSources] = useState<MonitorDatasource[]>([]);
const [ds, setDs] = useState<any>(null);
const [loading, setLoading] = useState(true);
const fetchSources = async () => {
setLoading(true);
try {
const list = normalizeDatasourceList(await zabbixDatasourceApi.list());
setSources(list);
setDs(current => list.find(d => String(d.id) === String(current?.id)) || list[0] || null);
} catch { setSources([]); setDs(null); }
finally { setLoading(false); }
};
useEffect(() => { fetchSources(); }, []);
if (loading) return <PageLoading label={i18n.t('monitor.dashboard.zabbix.loading')} />;
if (!ds) return (
<PageEmpty
icon={<Database className="h-8 w-8" />}
title={i18n.t('monitor.dashboard.zabbix.emptyTitle')}
action={<a href="/datasources" className="text-xs dg-text-blue hover:underline inline-flex items-center gap-1">{i18n.t('monitor.dashboard.zabbix.goConfig')} <ExternalLink className="w-3 h-3" /></a>}
/>
);
return (
<div>
<DatasourceToolbar sources={sources} activeId={String(ds.id)} onChange={(id) => setDs(sources.find(item => String(item.id) === id) || ds)} onRefresh={fetchSources} refreshing={loading} />
<ZabbixDashboard key={String(ds.id)} dsUrl={ds.url} dsUsername={ds.username} dsPassword={ds.password} />
</div>
);
};
const PrometheusTab: React.FC = () => {
useTranslation();
const [sources, setSources] = useState<MonitorDatasource[]>([]);
const [ds, setDs] = useState<any>(null);
const [loading, setLoading] = useState(true);
const fetchSources = async () => {
setLoading(true);
try {
const list = normalizeDatasourceList(await promDatasourceApi.list());
setSources(list);
setDs(current => list.find(d => String(d.id) === String(current?.id)) || list[0] || null);
} catch { setSources([]); setDs(null); }
finally { setLoading(false); }
};
useEffect(() => { fetchSources(); }, []);
if (loading) return <PageLoading label={i18n.t('monitor.dashboard.prometheus.loading')} />;
if (!ds) return (
<PageEmpty
icon={<Database className="h-8 w-8" />}
title={i18n.t('monitor.dashboard.prometheus.emptyTitle')}
action={<a href="/datasources" className="text-xs dg-text-blue hover:underline inline-flex items-center gap-1">{i18n.t('monitor.dashboard.zabbix.goConfig')} <ExternalLink className="w-3 h-3" /></a>}
/>
);
return (
<div>
<DatasourceToolbar sources={sources} activeId={String(ds.id)} onChange={(id) => setDs(sources.find(item => String(item.id) === id) || ds)} onRefresh={fetchSources} refreshing={loading} />
<PrometheusDashboard key={String(ds.id)} dsUrl={ds.url} />
</div>
);
};
// ═══════════ Prometheus Tab(薄包装) ═══════════
const PrometheusTab: React.FC = () => (
<ExternalDatasourceTab
fetcher={async () => normalizeDatasourceList(await promDatasourceApi.list())}
loadingLabel={i18n.t('monitor.dashboard.prometheus.loading')}
emptyTitle={i18n.t('monitor.dashboard.prometheus.emptyTitle')}
>
{(ds) => <PrometheusDashboard key={String(ds.id)} dsUrl={ds.url} />}
</ExternalDatasourceTab>
);
// ═══════════ 主组件 ═══════════
const GrafanaDashboard: React.FC = () => {
@@ -608,11 +62,11 @@ const GrafanaDashboard: React.FC = () => {
onTabChange={(id) => setTab(id as TabId)}
className={fullscreen ? 'fixed inset-0 z-50 bg-background p-4 overflow-auto' : undefined}
contentClassName="min-h-[50vh]"
actions={(
actions={
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setFullscreen(!fullscreen)}>
{fullscreen ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
</Button>
)}
}
>
{tab === 'agent' && <AgentTab />}
{tab === 'zabbix' && <ZabbixTab />}
@@ -0,0 +1,486 @@
// Agent 主机监控 Tab — 主机列表 + 排序 + 分页 + 选择 + 批量部署/卸载 + 安装向导
import React, { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
Server,
RefreshCw,
BarChart3,
Rocket,
PackageOpen,
Loader2,
ArrowUpDown,
ArrowUp,
ArrowDown,
Trash2,
Wifi,
WifiOff,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { toast } from 'sonner';
import { useUserStore } from '@/store';
import { agentApi, serverApi } from '@/api';
import { asList } from '@/lib/agent-utils';
import { AgentWizard } from '@/components/agent/AgentWizard';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { useNavigate } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { PageLoading } from '@/components/layout/PageLoading';
import { PageEmpty } from '@/components/layout/PageEmpty';
import i18n from '@/i18n';
import type { AgentHost, SortField, BatchCreds, WizardServer } from './types';
import { DEFAULT_BATCH_CREDS } from './types';
import { BatchDeployDialog } from './BatchDeployDialog';
export const AgentTab: React.FC = () => {
useTranslation();
const confirm = useConfirm();
const navigate = useNavigate();
const [hosts, setHosts] = useState<AgentHost[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [sf, setSf] = useState<SortField>('cpu_usage');
const [sd, setSd] = useState<'asc' | 'desc'>('desc');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [wizardOpen, setWizardOpen] = useState(false);
const [wizardServer, setWizardServer] = useState<WizardServer | null>(null);
// 批量部署对话框
const [batchOpen, setBatchOpen] = useState(false);
const [batchMode, setBatchMode] = useState<'install' | 'upgrade'>('install');
const [batchAck, setBatchAck] = useState(false);
const [batchSubmitting, setBatchSubmitting] = useState(false);
const [batchCreds, setBatchCreds] = useState<BatchCreds>(DEFAULT_BATCH_CREDS);
// 正在执行操作的主机 ID 集合(版本列 loading)
const [operating, setOperating] = useState<Set<number>>(new Set());
const [batchUninstalling, setBatchUninstalling] = useState(false);
const totalPages = Math.max(1, Math.ceil(hosts.length / pageSize));
const fetchHosts = async () => {
setRefreshing(true);
try {
const list = asList(await agentApi.getLatest()) as AgentHost[];
setHosts(list);
// 操作中的主机一旦状态变为 online 即可移除 loading(部署中)状态
setOperating((prev) => {
const next = new Set(prev);
for (const h of list) {
if (next.has(h.server_id) && h.agent_status === 'online') {
next.delete(h.server_id);
}
}
return next;
});
} catch (e) {
toast.error(
i18n.t('monitor.dashboard.agent.toast.refreshFailed', {
msg: e instanceof Error ? e.message : i18n.t('monitor.dashboard.agent.toast.unknownError'),
}),
);
} finally {
setLoading(false);
setRefreshing(false);
}
};
useEffect(() => {
fetchHosts();
const t = setInterval(fetchHosts, 30000);
return () => clearInterval(t);
}, []);
const toggle = (f: SortField) => {
if (sf === f) setSd((d) => (d === 'asc' ? 'desc' : 'asc'));
else {
setSf(f);
setSd('desc');
}
};
const sorted = [...hosts].sort((a, b) => {
let va: string | number = (a[sf] as string | number) ?? '';
let vb: string | number = (b[sf] as string | number) ?? '';
if (sf === 'agent_status') {
va = a.agent_status || '';
vb = b.agent_status || '';
}
if (typeof va === 'number' && typeof vb === 'number') return sd === 'asc' ? va - vb : vb - va;
return sd === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
});
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
const SI = ({ f }: { f: SortField }) =>
sf !== f ? (
<ArrowUpDown className="w-3 h-3 dg-text-muted" />
) : sd === 'asc' ? (
<ArrowUp className="w-3 h-3 dg-text-green" />
) : (
<ArrowDown className="w-3 h-3 dg-text-green" />
);
const bar = (v: number, l: string) => (
<div className="flex items-center gap-2 min-w-[90px]">
<div className="flex-1 h-2.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${v >= 80 ? 'bg-orange-500' : 'bg-emerald-500'}`}
style={{ width: `${Math.min(v, 100)}%` }}
/>
</div>
<span className={`w-14 text-right text-xs font-mono tabular-nums ${v >= 80 ? 'dg-text-orange' : ''}`}>{l}</span>
</div>
);
const selAll = selected.size === hosts.length && hosts.length > 0;
const selAny = selected.size > 0;
// 选中主机按安装/在线分组
const selectedHosts = useMemo(() => hosts.filter((h) => selected.has(String(h.server_id))), [hosts, selected]);
const needInstall = useMemo(() => selectedHosts.filter((h) => h.agent_status !== 'online'), [selectedHosts]);
const needUpgrade = useMemo(() => selectedHosts.filter((h) => h.agent_status === 'online'), [selectedHosts]);
const openBatchDialog = () => {
setBatchAck(false);
if (needInstall.length > 0 && needUpgrade.length === 0) setBatchMode('install');
else if (needInstall.length === 0 && needUpgrade.length > 0) setBatchMode('upgrade');
else setBatchMode('install');
setBatchOpen(true);
};
const handleBatchUninstall = async () => {
const selIds = [...selected].map(Number).filter((n) => !isNaN(n));
const ok = await confirm({
title: i18n.t('monitor.dashboard.confirm.uninstallTitle'),
description: i18n.t('monitor.dashboard.confirm.uninstallDesc', { count: selIds.length }),
confirmText: i18n.t('monitor.dashboard.confirm.uninstall'),
variant: 'destructive',
});
if (!ok) return;
setBatchUninstalling(true);
setOperating(new Set(selIds));
try {
await agentApi.batchUninstall(selIds);
toast.success(i18n.t('monitor.dashboard.agent.toast.batchUninstall', { count: selIds.length }));
setSelected(new Set());
setTimeout(() => {
fetchHosts();
setOperating(new Set());
}, 30000);
} catch {
toast.error(i18n.t('monitor.dashboard.agent.toast.submitFailed'));
setOperating(new Set());
} finally {
setBatchUninstalling(false);
}
};
const handleBatchSubmit = async () => {
if (!batchAck) {
toast.error(i18n.t('monitor.dashboard.agent.toast.ackRequired'));
return;
}
setBatchSubmitting(true);
const token = useUserStore.getState().token;
setOperating(new Set(selectedHosts.map((h) => h.server_id)));
try {
if (batchMode === 'install') {
if (!token) {
toast.error(i18n.t('monitor.dashboard.agent.toast.notLogin'));
return;
}
const installIds = needInstall.map((h) => h.server_id);
if (installIds.length === 0) {
toast.warning(i18n.t('monitor.dashboard.agent.toast.allInstalled'));
return;
}
const credentials: Record<number, { username?: string; password?: string; ssh_key?: string; auth_type?: string }> = {};
const needCreds = batchCreds.username && (batchCreds.authType === 'key' ? batchCreds.sshKey : batchCreds.password);
if (needCreds) {
for (const sid of installIds) {
credentials[sid] = {
username: batchCreds.username,
auth_type: batchCreds.authType,
password: batchCreds.authType === 'password' ? batchCreds.password : undefined,
ssh_key: batchCreds.authType === 'key' ? batchCreds.sshKey : undefined,
};
}
}
const res = await serverApi.batchInstallAgent(installIds, token, Object.keys(credentials).length ? credentials : undefined);
toast.success(i18n.t('monitor.dashboard.agent.toast.batchInstall', { count: res?.total ?? installIds.length }));
setSelected(new Set());
setTimeout(() => {
fetchHosts();
setOperating(new Set());
}, 30000);
} else {
const upgradeIds = needUpgrade.map((h) => h.server_id);
if (upgradeIds.length === 0) {
toast.warning(i18n.t('monitor.dashboard.agent.toast.noOnline'));
return;
}
const res = await agentApi.batchUpgrade(upgradeIds);
toast.success(res?.message || i18n.t('monitor.dashboard.agent.toast.batchUpgrade', { count: upgradeIds.length }));
setSelected(new Set());
setTimeout(() => {
fetchHosts();
setOperating(new Set());
}, 30000);
}
setBatchOpen(false);
} catch {
toast.error(i18n.t('monitor.dashboard.agent.toast.submitFailed'));
setOperating(new Set());
} finally {
setBatchSubmitting(false);
}
};
if (loading) return <PageLoading label={i18n.t('monitor.dashboard.agent.loadingHosts')} />;
if (!hosts.length)
return (
<PageEmpty
icon={<Server className="h-8 w-8" />}
title={i18n.t('monitor.dashboard.agent.emptyTitle')}
description={i18n.t('monitor.dashboard.agent.emptyDesc')}
/>
);
return (
<div>
{/* 工具栏 */}
<div className="flex items-center gap-2 mb-3 flex-wrap">
{selAny && (
<span className="text-xs text-[var(--dg-text-secondary)]">
{i18n.t('monitor.dashboard.agent.selected', { count: selected.size })}
</span>
)}
<div className="flex-1" />
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1"
onClick={() => {
const picked = selAny ? hosts.find((h) => selected.has(String(h.server_id))) : null;
setWizardServer(
picked ? { id: picked.server_id, hostname: picked.hostname, ip: picked.ip, has_auth: picked.has_auth, os: picked.os } : null,
);
setWizardOpen(true);
}}
>
<Rocket className="w-3 h-3" />
{i18n.t('monitor.dashboard.agent.installAgent')}
</Button>
{selAny && (
<>
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1 dg-btn-secondary dg-text-blue"
onClick={openBatchDialog}
>
<PackageOpen className="w-3 h-3" />
{i18n.t('monitor.dashboard.agent.batchDeploy')} ({selected.size})
</Button>
<Button
size="sm"
variant="outline"
className="h-7 text-xs gap-1 border-red-500/30 dg-text-red hover:bg-red-500/10"
onClick={handleBatchUninstall}
disabled={batchUninstalling}
>
<Trash2 className="w-3 h-3" />
{i18n.t('monitor.dashboard.agent.batchUninstall')} ({selected.size})
</Button>
</>
)}
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" onClick={fetchHosts} disabled={refreshing}>
<RefreshCw className={cn('w-3 h-3', refreshing && 'animate-spin')} />
{i18n.t('monitor.dashboard.refresh')}
</Button>
</div>
<AgentWizard
open={wizardOpen}
onOpenChange={setWizardOpen}
server={wizardServer}
onStart={(id) => {
setOperating((prev) => new Set(prev).add(id));
}}
onInstalled={() => {
toast.success(i18n.t('monitor.dashboard.agent.toast.installed'));
setWizardOpen(false);
fetchHosts();
}}
onFailed={(id) => {
toast.error(i18n.t('monitor.dashboard.agent.toast.installFailed'));
setOperating((prev) => {
const next = new Set(prev);
next.delete(id);
return next;
});
}}
/>
<BatchDeployDialog
open={batchOpen}
onOpenChange={setBatchOpen}
batchMode={batchMode}
setBatchMode={setBatchMode}
batchAck={batchAck}
setBatchAck={setBatchAck}
batchSubmitting={batchSubmitting}
batchCreds={batchCreds}
setBatchCreds={setBatchCreds}
needInstall={needInstall}
needUpgrade={needUpgrade}
onSubmit={handleBatchSubmit}
/>
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
<table className="w-full text-xs">
<thead className="bg-[var(--dg-bg-tertiary)] border-b">
<tr>
<th className="px-2 py-2 w-8">
<Checkbox
checked={selAll}
onCheckedChange={(c) =>
setSelected(c ? new Set(hosts.map((h) => String(h.server_id))) : new Set())
}
/>
</th>
{(['hostname', 'agent_version', 'agent_status', 'cpu_usage', 'mem_usage', 'disk_usage', 'region', 'ip'] as SortField[]).map(
(f) => (
<th
key={f}
className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] cursor-pointer hover:text-[var(--dg-text-primary)] select-none whitespace-nowrap"
onClick={() => toggle(f)}
>
<div className="flex items-center gap-1">
{
{
hostname: i18n.t('monitor.dashboard.col.hostname'),
agent_version: i18n.t('monitor.dashboard.col.agentVersion'),
agent_status: i18n.t('monitor.dashboard.col.status'),
cpu_usage: i18n.t('monitor.dashboard.col.cpu'),
mem_usage: i18n.t('monitor.dashboard.col.memory'),
disk_usage: i18n.t('monitor.dashboard.col.disk'),
region: i18n.t('monitor.dashboard.col.region'),
ip: 'IP',
}[f]
}
<SI f={f} />
</div>
</th>
),
)}
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] whitespace-nowrap">
{i18n.t('monitor.dashboard.col.action')}
</th>
</tr>
</thead>
<tbody>
{paged.map((h, i) => (
<tr key={h.hostname + i} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
<td className="px-2 py-2.5">
<Checkbox
checked={selected.has(String(h.server_id))}
onCheckedChange={(c) => {
const next = new Set(selected);
if (c) next.add(String(h.server_id));
else next.delete(String(h.server_id));
setSelected(next);
}}
/>
</td>
<td className="px-3 py-2.5 font-medium text-[var(--dg-text-primary)]">{h.hostname}</td>
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">
{operating.has(h.server_id) ? (
<span className="inline-flex items-center gap-1 dg-text-blue">
<Loader2 className="w-3 h-3 animate-spin" />
<span className="text-[10px]">{i18n.t('monitor.dashboard.agent.deploying')}</span>
</span>
) : (
h.agent_version || '—'
)}
</td>
<td className="px-3 py-2.5">
{h.agent_status === 'online' ? (
<Badge className="text-[10px] bg-emerald-500/20 dg-text-green">
<Wifi className="w-3 h-3 mr-1" />
{i18n.t('monitor.dashboard.status.online')}
</Badge>
) : h.agent_status === 'not_installed' ? (
<Badge className="text-[10px] bg-amber-500/20 dg-text-yellow">
{i18n.t('monitor.dashboard.status.notInstalled')}
</Badge>
) : (
<Badge className="text-[10px] bg-[var(--dg-bg-active)]/10 dg-text-secondary">
<WifiOff className="w-3 h-3 mr-1" />
{i18n.t('monitor.dashboard.status.offline')}
</Badge>
)}
</td>
<td className="px-3 py-2.5">{bar(Number(h.cpu_usage || 0), `${Number(h.cpu_usage || 0).toFixed(1)}%`)}</td>
<td className="px-3 py-2.5">{bar(Number(h.mem_usage || 0), `${Number(h.mem_usage || 0).toFixed(1)}%`)}</td>
<td className="px-3 py-2.5">{bar(Number(h.disk_usage || 0), `${Number(h.disk_usage || 0).toFixed(1)}%`)}</td>
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)]">{h.region || '—'}</td>
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">{h.ip || '—'}</td>
<td className="px-3 py-2.5">
<Button
variant="ghost"
size="sm"
className="h-7 text-xs dg-text-blue hover:bg-[var(--dg-bg-hover)] px-2"
onClick={() => navigate(`/host-monitor?host=${encodeURIComponent(h.hostname)}`)}
>
<BarChart3 className="w-3.5 h-3.5 mr-1" />
{i18n.t('monitor.dashboard.agent.chart')}
</Button>
</td>
</tr>
))}
</tbody>
</table>
{hosts.length > pageSize && (
<div className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs">
<div className="flex items-center gap-2 text-[var(--dg-text-muted)]">
<span>{i18n.t('monitor.dashboard.pagination.total', { count: hosts.length })}</span>
<select
className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded px-2 py-0.5 text-xs"
value={pageSize}
onChange={(e) => {
setPageSize(Number(e.target.value));
setPage(1);
}}
>
<option value={10}>{`10/${i18n.t('monitor.dashboard.pagination.perPage')}`}</option>
<option value={20}>{`20/${i18n.t('monitor.dashboard.pagination.perPage')}`}</option>
<option value={30}>{`30/${i18n.t('monitor.dashboard.pagination.perPage')}`}</option>
</select>
</div>
<div className="flex items-center gap-1">
<button
className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
{i18n.t('monitor.dashboard.pagination.prev')}
</button>
<span className="px-2 text-[var(--dg-text-primary)]">
{page} / {totalPages}
</span>
<button
className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
{i18n.t('monitor.dashboard.pagination.next')}
</button>
</div>
</div>
)}
</div>
</div>
);
};
@@ -0,0 +1,273 @@
// 批量部署 Agent 对话框 — 安装/升级模式切换 + 主机列表 + 凭据 + 警告确认
import React from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { PackageOpen, Info, Loader2 } from 'lucide-react';
import i18n from '@/i18n';
import type { AgentHost, BatchCreds } from './types';
export interface BatchDeployDialogProps {
open: boolean;
onOpenChange: (v: boolean) => void;
batchMode: 'install' | 'upgrade';
setBatchMode: (m: 'install' | 'upgrade') => void;
batchAck: boolean;
setBatchAck: (v: boolean) => void;
batchSubmitting: boolean;
batchCreds: BatchCreds;
setBatchCreds: React.Dispatch<React.SetStateAction<BatchCreds>>;
needInstall: AgentHost[];
needUpgrade: AgentHost[];
onSubmit: () => void;
}
export const BatchDeployDialog: React.FC<BatchDeployDialogProps> = ({
open,
onOpenChange,
batchMode,
setBatchMode,
batchAck,
setBatchAck,
batchSubmitting,
batchCreds,
setBatchCreds,
needInstall,
needUpgrade,
onSubmit,
}) => {
const hosts = batchMode === 'install' ? needInstall : needUpgrade;
return (
<Dialog
open={open}
onOpenChange={(v) => {
if (!v) {
onOpenChange(false);
setBatchAck(false);
}
}}
>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-auto dg-input">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<PackageOpen className="w-5 h-5 dg-text-blue" />
{i18n.t('monitor.dashboard.batch.title')}
</DialogTitle>
<DialogDescription className="dg-text-secondary text-xs">
{i18n.t('monitor.dashboard.batch.summary', {
selected: needInstall.length + needUpgrade.length,
install: needInstall.length,
upgrade: needUpgrade.length,
})}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* 模式选择 */}
<div className="flex items-center gap-2 p-1 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] w-fit">
{(['install', 'upgrade'] as const).map((m) => (
<button
key={m}
disabled={m === 'install' ? needInstall.length === 0 : needUpgrade.length === 0}
onClick={() => setBatchMode(m)}
className={`px-4 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-30 disabled:cursor-not-allowed ${
batchMode === m ? 'dg-btn-primary text-white shadow-sm' : 'dg-text-secondary hover:text-[var(--dg-text-primary)]'
}`}
>
{m === 'install'
? i18n.t('monitor.dashboard.batch.install', { count: needInstall.length })
: i18n.t('monitor.dashboard.batch.upgrade', { count: needUpgrade.length })}
</button>
))}
</div>
{/* 主机列表 */}
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-[var(--dg-bg-tertiary)]">
<tr>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">{i18n.t('monitor.dashboard.col.hostname')}</th>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">IP</th>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">{i18n.t('monitor.dashboard.col.status')}</th>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">{i18n.t('monitor.dashboard.col.version')}</th>
<th className="px-3 py-2 text-left font-medium dg-text-secondary">{i18n.t('monitor.dashboard.col.action')}</th>
</tr>
</thead>
<tbody>
{hosts.map((h) => (
<tr key={h.server_id} className="border-t border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">{h.hostname}</td>
<td className="px-3 py-2 font-mono dg-text-secondary">{h.ip}</td>
<td className="px-3 py-2">
{h.agent_status === 'online' ? (
<Badge className="text-[10px] bg-emerald-500/20 dg-text-green">{i18n.t('monitor.dashboard.status.online')}</Badge>
) : (
<Badge className="text-[10px] bg-[var(--dg-bg-active)]/20 dg-text-secondary">
{h.agent_status === 'not_installed'
? i18n.t('monitor.dashboard.status.notInstalled')
: h.agent_status || i18n.t('monitor.dashboard.status.offline')}
</Badge>
)}
</td>
<td className="px-3 py-2 font-mono dg-text-secondary">{h.agent_version || '—'}</td>
<td className="px-3 py-2">
<span className="dg-text-blue text-[11px]">
{batchMode === 'install'
? i18n.t('monitor.dashboard.batch.willInstall')
: i18n.t('monitor.dashboard.batch.willUpgrade')}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 安装凭据(安装模式且存在未保存凭据的主机时显示)*/}
{batchMode === 'install' && needInstall.some((h) => !h.has_auth) && (
<div className="rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/50 p-3 space-y-3">
<div className="flex items-center gap-2">
<Info className="w-4 h-4 dg-text-blue shrink-0" />
<span className="text-xs text-[var(--dg-text-secondary)]">{i18n.t('monitor.dashboard.creds.hint')}</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-[11px] dg-text-secondary mb-1 block">{i18n.t('monitor.dashboard.creds.authType')}</Label>
<Select
value={batchCreds.authType}
onValueChange={(v: 'password' | 'key') => setBatchCreds((c) => ({ ...c, authType: v }))}
>
<SelectTrigger className="h-8 text-xs dg-input text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent className="dg-input text-[var(--dg-text-primary)]">
<SelectItem value="password">{i18n.t('monitor.dashboard.creds.password')}</SelectItem>
<SelectItem value="key">{i18n.t('monitor.dashboard.creds.sshKey')}</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-[11px] dg-text-secondary mb-1 block">{i18n.t('monitor.dashboard.creds.username')}</Label>
<Input
value={batchCreds.username}
onChange={(e) => setBatchCreds((c) => ({ ...c, username: e.target.value }))}
placeholder="root"
className="h-8 text-xs dg-input text-[var(--dg-text-primary)]"
/>
</div>
</div>
{batchCreds.authType === 'password' ? (
<div>
<Label className="text-[11px] dg-text-secondary mb-1 block">{i18n.t('monitor.dashboard.creds.password')}</Label>
<Input
type="password"
value={batchCreds.password}
onChange={(e) => setBatchCreds((c) => ({ ...c, password: e.target.value }))}
placeholder={i18n.t('monitor.dashboard.creds.passwordPlaceholder')}
className="h-8 text-xs dg-input text-[var(--dg-text-primary)]"
/>
</div>
) : (
<div>
<Label className="text-[11px] dg-text-secondary mb-1 block">{i18n.t('monitor.dashboard.creds.sshKey')}</Label>
<textarea
value={batchCreds.sshKey}
onChange={(e) => setBatchCreds((c) => ({ ...c, sshKey: e.target.value }))}
placeholder="-----BEGIN OPENSSH PRIVATE KEY-----"
className="w-full h-24 rounded-md border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-[var(--dg-text-primary)] text-xs p-2 resize-none focus:outline-none focus:ring-1 focus:ring-[var(--dg-blue)]"
/>
</div>
)}
</div>
)}
{/* 警告文案 */}
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3 space-y-2">
<div className="flex items-start gap-2">
<Info className="w-4 h-4 dg-text-yellow mt-0.5 shrink-0" />
<div>
<p className="text-xs font-medium text-amber-300 mb-1">
{(batchMode === 'install'
? i18n.t('monitor.dashboard.warn.thisInstall')
: i18n.t('monitor.dashboard.warn.thisUpgrade')) + i18n.t('monitor.dashboard.warn.intro')}
</p>
<p className="text-[11px] dg-text-yellow/80 leading-relaxed">
{batchMode === 'upgrade'
? i18n.t('monitor.dashboard.warn.upgradeNotice')
: i18n.t('monitor.dashboard.warn.installNotice')}
</p>
<ul className="mt-1 space-y-0.5 text-[11px] dg-text-secondary list-disc list-inside">
{batchMode === 'upgrade' && (
<>
<li>{i18n.t('monitor.dashboard.warn.u1')}</li>
<li>{i18n.t('monitor.dashboard.warn.u2')}</li>
<li>{i18n.t('monitor.dashboard.warn.u3')}</li>
<li>{i18n.t('monitor.dashboard.warn.u4')}</li>
</>
)}
{batchMode === 'install' && (
<>
<li>{i18n.t('monitor.dashboard.warn.i1')}</li>
<li>{i18n.t('monitor.dashboard.warn.i2')}</li>
<li>{i18n.t('monitor.dashboard.warn.i3')}</li>
<li>{i18n.t('monitor.dashboard.warn.i4')}</li>
</>
)}
<li>{i18n.t('monitor.dashboard.warn.c1')}</li>
<li>{i18n.t('monitor.dashboard.warn.c2')}</li>
</ul>
</div>
</div>
</div>
{/* 我已知悉 勾选 */}
<div className="flex items-center gap-2">
<Checkbox id="batch-ack" checked={batchAck} onCheckedChange={(c) => setBatchAck(!!c)} />
<label htmlFor="batch-ack" className="text-xs text-[var(--dg-text-secondary)] cursor-pointer select-none">
{i18n.t('monitor.dashboard.batch.ack')}
</label>
</div>
</div>
<DialogFooter className="gap-2">
<Button
variant="outline"
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] h-8 text-xs"
onClick={() => {
onOpenChange(false);
setBatchAck(false);
}}
>
{i18n.t('monitor.dashboard.batch.cancel')}
</Button>
<Button
className="dg-btn-primary h-8 text-xs gap-1"
disabled={
batchSubmitting ||
!batchAck ||
(batchMode === 'install' ? needInstall.length === 0 : needUpgrade.length === 0)
}
onClick={onSubmit}
>
{batchSubmitting && <Loader2 className="w-3 h-3 animate-spin" />}
{batchMode === 'install'
? i18n.t('monitor.dashboard.batch.confirmInstall')
: i18n.t('monitor.dashboard.batch.confirmUpgrade')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,78 @@
// 外部数据源 Tab 通用组件 — Zabbix / Prometheus 共用(仅 API/标签/渲染组件不同)
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Database, ExternalLink } from 'lucide-react';
import { PageLoading } from '@/components/layout/PageLoading';
import { PageEmpty } from '@/components/layout/PageEmpty';
import i18n from '@/i18n';
import type { MonitorDatasource } from './types';
import { DatasourceToolbar } from './datasource-toolbar';
export interface ExternalDatasourceTabProps {
/** 数据源列表获取函数 */
fetcher: () => Promise<MonitorDatasource[]>;
/** 加载中文案 */
loadingLabel: string;
/** 空数据源标题 */
emptyTitle: string;
/** 选中数据源后渲染的 dashboard 组件 */
children: (ds: MonitorDatasource) => React.ReactNode;
}
export const ExternalDatasourceTab: React.FC<ExternalDatasourceTabProps> = ({
fetcher,
loadingLabel,
emptyTitle,
children,
}) => {
useTranslation();
const [sources, setSources] = useState<MonitorDatasource[]>([]);
const [ds, setDs] = useState<MonitorDatasource | null>(null);
const [loading, setLoading] = useState(true);
const fetchSources = async () => {
setLoading(true);
try {
const list = await fetcher();
setSources(list);
setDs((current) => list.find((d) => String(d.id) === String(current?.id)) || list[0] || null);
} catch {
setSources([]);
setDs(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchSources();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
if (loading) return <PageLoading label={loadingLabel} />;
if (!ds)
return (
<PageEmpty
icon={<Database className="h-8 w-8" />}
title={emptyTitle}
action={
<a href="/datasources" className="text-xs dg-text-blue hover:underline inline-flex items-center gap-1">
{i18n.t('monitor.dashboard.zabbix.goConfig')} <ExternalLink className="w-3 h-3" />
</a>
}
/>
);
return (
<div>
<DatasourceToolbar
sources={sources}
activeID={String(ds.id)}
onChange={(id) => setDs(sources.find((item) => String(item.id) === id) || ds)}
onRefresh={fetchSources}
refreshing={loading}
/>
{children(ds)}
</div>
);
};
@@ -0,0 +1,49 @@
// 数据源工具栏 + 列表归一化 — Zabbix/Prometheus 共用
import React from 'react';
import { RefreshCw } from 'lucide-react';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import i18n from '@/i18n';
import type { MonitorDatasource } from './types';
/** 后端响应归一化 + 按默认优先/名称排序 */
export const normalizeDatasourceList = (r: unknown): MonitorDatasource[] => {
const raw = r as { data?: MonitorDatasource[] } | MonitorDatasource[];
const list = (Array.isArray(raw) ? raw : raw?.data || []) as MonitorDatasource[];
return [...list].sort((a, b) => {
if (!!a.is_default !== !!b.is_default) return a.is_default ? -1 : 1;
return String(a.name || a.url).localeCompare(String(b.name || b.url));
});
};
export const DatasourceToolbar: React.FC<{
sources: MonitorDatasource[];
activeID: string;
onChange: (id: string) => void;
onRefresh: () => void;
refreshing?: boolean;
}> = ({ sources, activeID, onChange, onRefresh, refreshing }) => (
<div className="flex items-center gap-2 mb-3 flex-wrap">
<Select value={activeID} onValueChange={onChange}>
<SelectTrigger className="w-[280px] h-8 text-xs dg-input">
<SelectValue placeholder={i18n.t('monitor.datasourceToolbar.selectDatasource')} />
</SelectTrigger>
<SelectContent className="dg-input">
{sources.map((ds) => (
<SelectItem key={String(ds.id)} value={String(ds.id)}>
{ds.name || ds.url}
{ds.is_default ? i18n.t('monitor.datasourceToolbar.defaultSuffix') : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" size="sm" className="h-8 text-xs gap-1" onClick={onRefresh} disabled={refreshing}>
<RefreshCw className={cn('w-3 h-3', refreshing && 'animate-spin')} />
{i18n.t('monitor.datasourceToolbar.refreshDatasource')}
</Button>
<span className="text-[11px] text-[var(--dg-text-muted)]">
{i18n.t('monitor.datasourceToolbar.datasourceSummary', { count: sources.length })}
</span>
</div>
);
@@ -0,0 +1,60 @@
// GrafanaDashboard 共享类型定义 — 纯数据/类型,零 React 依赖
export type TabId = 'agent' | 'zabbix' | 'prometheus';
export interface AgentHost {
server_id: number;
hostname: string;
ip: string;
cpu_usage: number;
cpu_cores: number;
mem_usage: number;
mem_total: number;
mem_used: number;
disk_usage: number;
agent_version: string;
agent_status: string;
os: string;
region: string;
has_auth?: boolean;
}
export type SortField =
| 'hostname'
| 'agent_version'
| 'agent_status'
| 'cpu_usage'
| 'mem_usage'
| 'disk_usage'
| 'region'
| 'ip';
export interface MonitorDatasource {
id: number | string;
name?: string;
url: string;
username?: string;
password?: string;
is_default?: boolean;
}
export interface BatchCreds {
username: string;
password: string;
sshKey: string;
authType: 'password' | 'key';
}
export interface WizardServer {
id: number;
hostname: string;
ip: string;
has_auth?: boolean;
os: string;
}
export const DEFAULT_BATCH_CREDS: BatchCreds = {
username: 'root',
password: '',
sshKey: '',
authType: 'password',
};