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:
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',
|
||||
};
|
||||
Reference in new issue
Block a user