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
+1017
-575
No files matched your search
@@ -129,7 +129,7 @@
|
|||||||
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
|
| ~~`api.ts` 1324 行/352 方法~~ → 已拆分为 12 个域文件 (api/kubernetes.ts, deploy.ts, monitor.ts 等) | ✅ 已解决 | 前端 | ~~P0~~ done |
|
||||||
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
|
| 前端 **1026 处** `any` 类型 | 类型安全缺失,重构风险高 | 前端全局 | ~~P0~~ **done** (any 1076→40, unused 25→6, console 93→1, tsc --noEmit=0) |
|
||||||
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
|
| ~~K8s 双路由体系(集群域+Kuboard 域)~~ → code 路由统一为主,ID 路由标记 deprecated 向后兼容 | ✅ 已解决 | 后端 | ~~P1~~ done |
|
||||||
| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q18 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%) + RdpDialog 642L→442L (-31%) + DeploymentEditService 627L→394L (-37%) + **TraceManager 605L→331L (-45%,修复 SpanTreeNode 内联定义反模式)**,累计提取 58 个独立组件文件** |
|
| 25 个 ≥500 行巨型组件 | 拆分困难 | 前端 | P1 → **Q6-Q19 done: 最大组件 ResourceVisualEditor 882L→282L (-68%) + AgentCLI 814L→521L (-36%) + SystemSettingsTab 754L→173L (-77%) + NamespaceResourcePage 719L→300L (-58%) + ApprovalWorkflowPage 705L→167L (-76%) + PipelineRunDetail 678L→273L (-60%) + CloudServerImport 699L→404L (-42%,any[]→具体接口) + UnifiedQueryPanel 682L→529L (-22%) + ServerManager 652L→442L (-32%) + PipelineEditor 676L→406L (-40%) + RdpDialog 642L→442L (-31%) + DeploymentEditService 627L→394L (-37%) + TraceManager 605L→331L (-45%,修复 SpanTreeNode 内联定义反模式) + **GrafanaDashboard 624L→78L (-87%,DRY 合并 Zabbix/Prometheus Tab + 移除文件级 eslint-disable + 类型安全升级)**,累计提取 63 个独立组件文件** |
|
||||||
|
|
||||||
### 3.2 🟡 中优先级
|
### 3.2 🟡 中优先级
|
||||||
|
|
||||||
|
|||||||
+42
@@ -490,6 +490,48 @@ services/api.ts (1324L)
|
|||||||
| Q16 | 前端巨型组件拆分(RdpDialog 642L→442L,提取 types + rdpp-utils + RdpDialogHeader) | P0 | **done** |
|
| Q16 | 前端巨型组件拆分(RdpDialog 642L→442L,提取 types + rdpp-utils + RdpDialogHeader) | P0 | **done** |
|
||||||
| Q17 | 前端巨型组件拆分(DeploymentEditService 627L→394L,提取 types + ServiceCreateForm + IngressCreateForm + ServiceListItem + IngressListItem) | P0 | **done** |
|
| Q17 | 前端巨型组件拆分(DeploymentEditService 627L→394L,提取 types + ServiceCreateForm + IngressCreateForm + ServiceListItem + IngressListItem) | P0 | **done** |
|
||||||
| Q18 | 前端巨型组件拆分(TraceManager 605L→331L,提取 types + trace-helpers + SpanTreeNode + TraceDetailDialog + SpanDetailDialog,修复 SpanTreeNode 内联定义反模式) | P0 | **done** |
|
| Q18 | 前端巨型组件拆分(TraceManager 605L→331L,提取 types + trace-helpers + SpanTreeNode + TraceDetailDialog + SpanDetailDialog,修复 SpanTreeNode 内联定义反模式) | P0 | **done** |
|
||||||
|
| Q19 | 前端巨型组件拆分(GrafanaDashboard 624L→78L,提取 types + datasource-toolbar + BatchDeployDialog + AgentTab + ExternalDatasourceTab,DRY 合并 Zabbix/Prometheus Tab,移除文件级 eslint-disable,类型安全升级 any→具体接口) | P0 | **done** |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Q19: 前端巨型组件拆分 — GrafanaDashboard [done]
|
||||||
|
|
||||||
|
**问题**: `GrafanaDashboard.tsx`(624 行)是 Q18 拆分后项目最大的核心业务组件之一(排除 UI 库 sidebar/data-table),包含监控大屏页面的全部逻辑:Tab 配置、Agent 主机监控表格(状态管理 + 排序 + 分页 + 选择 + 批量部署/卸载 + Agent 安装向导)、批量部署对话框(模式切换 + 主机列表 + 凭据输入 + 警告确认)、数据源工具栏、Zabbix Tab、Prometheus Tab(两者逻辑近乎重复)、以及主组件编排。文件顶部有 `eslint-disable @typescript-eslint/no-explicit-any`,多处 `any` 类型。单文件过大导致维护困难。
|
||||||
|
|
||||||
|
**已完成 (2026-07-08)**:
|
||||||
|
|
||||||
|
将类型/常量、数据源工具栏、批量部署对话框、Agent Tab、外部数据源通用 Tab 提取为 `grafana-dashboard/` 子目录下的独立文件:
|
||||||
|
|
||||||
|
| 新文件 | 行数 | 职责 |
|
||||||
|
|--------|------|------|
|
||||||
|
| `types.ts` | 60 | AgentHost/SortField/MonitorDatasource/BatchCreds/WizardServer/TabId 接口 + DEFAULT_BATCH_CREDS 常量 |
|
||||||
|
| `datasource-toolbar.tsx` | 49 | normalizeDatasourceList(后端响应归一化+排序)+ DatasourceToolbar 组件(数据源下拉+刷新) |
|
||||||
|
| `BatchDeployDialog.tsx` | 273 | 批量部署 Agent 对话框(安装/升级模式切换 + 主机列表表格 + 凭据输入 + 警告确认 + 我已知悉勾选),12 个 props 的受控组件 |
|
||||||
|
| `AgentTab.tsx` | 486 | Agent 主机监控 Tab(状态管理 + fetch + 排序/分页/选择 + 批量卸载 + 安装向卫集成 + BatchDeployDialog 集成 + 主机表格渲染) |
|
||||||
|
| `ExternalDatasourceTab.tsx` | 78 | 外部数据源通用 Tab(fetcher + loading + empty + children render-prop),DRY 合并 Zabbix/Prometheus 两个近乎重复的组件 |
|
||||||
|
|
||||||
|
主文件 `GrafanaDashboard.tsx` 从 **624 行降至 78 行**(-87%),仅保留:getTabs 配置、ZabbixTab/PrometheusTab 薄包装(用 ExternalDatasourceTab + render-prop)、GrafanaDashboard 主组件编排(PageShell + 全屏切换 + Tab 分发)。
|
||||||
|
|
||||||
|
**关键改进**:
|
||||||
|
- **DRY 合并**: 原 ZabbixTab 和 PrometheusTab 各 30 行,逻辑近乎完全重复(仅 API/标签/渲染组件不同)。合并为 ExternalDatasourceTab 通用组件 + render-prop 模式,消除重复代码。
|
||||||
|
- **类型安全升级**: 移除文件顶部的 `/* eslint-disable @typescript-eslint/no-explicit-any */`。`wizardServer: any` → `WizardServer | null`;`ds: any` → `MonitorDatasource | null`;`catch (e: any)` → `catch (e)` + `e instanceof Error` 类型守卫;排序函数 `let va: any` → `string | number`。全局 ESLint any 保持 **0**。
|
||||||
|
- **命名规范化**: 数据源工具栏 prop `activeId` → `activeID`(避免与 HTML `id` 混淆),`normalizeDatasourceList` 参数 `r: any` → `r: unknown` + 类型断言。
|
||||||
|
|
||||||
|
**提取原则**: types 为纯数据/类型(零 React 依赖);datasource-toolbar 为共享工具+组件(被 ExternalDatasourceTab 和主文件引用);BatchDeployDialog 为受控展示组件(接收 12 个 props,零独立状态);AgentTab 为自包含业务组件(内部管理全部 Agent 监控状态 + 集成 AgentWizard + BatchDeployDialog);ExternalDatasourceTab 为通用容器组件(fetcher render-prop 模式)。零逻辑变更。
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- [x] GrafanaDashboard.tsx 从 624 行降至 **78 行**(-87%)
|
||||||
|
- [x] 5 个提取的文件全部 < 490 行(最大 AgentTab 486 行,聚焦单一职责)
|
||||||
|
- [x] `tsc -b` 零错误
|
||||||
|
- [x] `npm run build` 零错误(✓ built in 17.56s)
|
||||||
|
- [x] ESLint errors 保持 **0**(grafana-dashboard/ 目录零 errors,仅 1 个 react-refresh 架构性 warning)
|
||||||
|
- [x] ESLint warnings 从 64 增至 **65**(+1 datasource-toolbar react-refresh/only-export-components,符合既有模式)
|
||||||
|
- [x] 全局 ESLint any 保持 **0**(移除文件级 eslint-disable)
|
||||||
|
- [x] `go build` 零错误
|
||||||
|
- [x] 17/17 集成回归测试通过(零回归)
|
||||||
|
- [x] Docker 部署 healthy,前端页面正常加载(HTTP 200)
|
||||||
|
- [x] 监控大屏页功能不变(Agent/Zabbix/Prometheus 三 Tab + 主机表格 + 排序/分页/选择 + 批量部署/卸载 + 安装向导全部正常)
|
||||||
|
- [x] DRY 合并 ZabbixTab/PrometheusTab 为 ExternalDatasourceTab 通用组件
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -1,592 +1,46 @@
|
|||||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
|
||||||
// 监控大屏 — 标签页切换 Agent / Zabbix / Prometheus // Logan.Li
|
// 监控大屏 — 标签页切换 Agent / Zabbix / Prometheus // Logan.Li
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import { Monitor, Server, Activity, Maximize2, Minimize2 } from 'lucide-react';
|
||||||
|
|
||||||
Monitor,
|
|
||||||
RefreshCw,
|
|
||||||
Maximize2,
|
|
||||||
Minimize2,
|
|
||||||
BarChart3,
|
|
||||||
Server,
|
|
||||||
Activity,
|
|
||||||
Database,
|
|
||||||
Wifi,
|
|
||||||
WifiOff,
|
|
||||||
ArrowUpDown,
|
|
||||||
ArrowUp,
|
|
||||||
ArrowDown,
|
|
||||||
ExternalLink,
|
|
||||||
Rocket,
|
|
||||||
PackageOpen,
|
|
||||||
Loader2,
|
|
||||||
Info,
|
|
||||||
Trash2,
|
|
||||||
} from 'lucide-react';
|
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { PageShell } from '@/components/layout/PageShell';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { promDatasourceApi, zabbixDatasourceApi } from '@/api';
|
||||||
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 { ZabbixDashboard } from './ZabbixDashboard';
|
import { ZabbixDashboard } from './ZabbixDashboard';
|
||||||
import { PrometheusDashboard } from './PrometheusDashboard';
|
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 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 配置 ═══════════
|
// ═══════════ Tab 配置 ═══════════
|
||||||
type TabId = 'agent' | 'zabbix' | 'prometheus';
|
|
||||||
const getTabs = (): { id: TabId; label: string; icon: React.ReactNode }[] => [
|
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: '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: '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" /> },
|
{ id: 'prometheus', label: 'Prometheus', icon: <Activity className="w-3.5 h-3.5" /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
// ═══════════ Agent 主机监控表格 ═══════════
|
// ═══════════ Zabbix Tab(薄包装) ═══════════
|
||||||
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; }
|
const ZabbixTab: React.FC = () => (
|
||||||
type SortField = 'hostname' | 'agent_version' | 'agent_status' | 'cpu_usage' | 'mem_usage' | 'disk_usage' | 'region' | 'ip';
|
<ExternalDatasourceTab
|
||||||
|
fetcher={async () => normalizeDatasourceList(await zabbixDatasourceApi.list())}
|
||||||
const AgentTab: React.FC = () => {
|
loadingLabel={i18n.t('monitor.dashboard.zabbix.loading')}
|
||||||
useTranslation();
|
emptyTitle={i18n.t('monitor.dashboard.zabbix.emptyTitle')}
|
||||||
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 })}
|
{(ds) => <ZabbixDashboard key={String(ds.id)} dsUrl={ds.url} dsUsername={ds.username} dsPassword={ds.password} />}
|
||||||
</button>
|
</ExternalDatasourceTab>
|
||||||
))}
|
|
||||||
</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>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const ZabbixTab: React.FC = () => {
|
// ═══════════ Prometheus Tab(薄包装) ═══════════
|
||||||
useTranslation();
|
const PrometheusTab: React.FC = () => (
|
||||||
const [sources, setSources] = useState<MonitorDatasource[]>([]);
|
<ExternalDatasourceTab
|
||||||
const [ds, setDs] = useState<any>(null);
|
fetcher={async () => normalizeDatasourceList(await promDatasourceApi.list())}
|
||||||
const [loading, setLoading] = useState(true);
|
loadingLabel={i18n.t('monitor.dashboard.prometheus.loading')}
|
||||||
const fetchSources = async () => {
|
emptyTitle={i18n.t('monitor.dashboard.prometheus.emptyTitle')}
|
||||||
setLoading(true);
|
>
|
||||||
try {
|
{(ds) => <PrometheusDashboard key={String(ds.id)} dsUrl={ds.url} />}
|
||||||
const list = normalizeDatasourceList(await zabbixDatasourceApi.list());
|
</ExternalDatasourceTab>
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ═══════════ 主组件 ═══════════
|
// ═══════════ 主组件 ═══════════
|
||||||
const GrafanaDashboard: React.FC = () => {
|
const GrafanaDashboard: React.FC = () => {
|
||||||
@@ -608,11 +62,11 @@ const GrafanaDashboard: React.FC = () => {
|
|||||||
onTabChange={(id) => setTab(id as TabId)}
|
onTabChange={(id) => setTab(id as TabId)}
|
||||||
className={fullscreen ? 'fixed inset-0 z-50 bg-background p-4 overflow-auto' : undefined}
|
className={fullscreen ? 'fixed inset-0 z-50 bg-background p-4 overflow-auto' : undefined}
|
||||||
contentClassName="min-h-[50vh]"
|
contentClassName="min-h-[50vh]"
|
||||||
actions={(
|
actions={
|
||||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setFullscreen(!fullscreen)}>
|
<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" />}
|
{fullscreen ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
}
|
||||||
>
|
>
|
||||||
{tab === 'agent' && <AgentTab />}
|
{tab === 'agent' && <AgentTab />}
|
||||||
{tab === 'zabbix' && <ZabbixTab />}
|
{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