feat: Agent采集uptime, 后端持久化kernel/uptime/diskIO, 前端磁盘BPS+IOPS展示

- Agent: system.go 添加 readUptime(), SystemStat 携带 Uptime
- Backend: Server 模型新增 Kernel 字段, ReceiveReport 持久化 kernel+uptime
- Backend: /agent/latest 返回 kernel/uptime/port_info/disk_io_info/disk_total
- Frontend: 磁盘监控改为 BPS+IOPS (4张卡片), 系统信息修复内核/运行时长/监听端口/磁盘容量
- 图表单位从 MB/s 改为 KB/s, 添加 formatBPS 工具函数
This commit is contained in:
attacker committed 2026-06-19 01:10:00 +00:00
1 parent 31c2e338a3
commit 5949ec5d0c
48 files changed
+2182 -956

No files matched your search

@@ -23,7 +23,7 @@ import {
ChevronLeft,
Loader2,
} from 'lucide-react';
import { regionApi, serverApi } from '@/services/api';
import { regionApi, serverApi, credentialApi } from '@/services/api';
interface RegionOption {
id: string;
@@ -94,12 +94,13 @@ function isValidIP(ip: string): boolean {
return /^(\d{1,3}\.){3}\d{1,3}$/.test(ip) && ip.split('.').every((o) => Number(o) >= 0 && Number(o) <= 255);
}
// 下载 CSV 导入模板 // Logan.Li
// 下载 CSV 导入模板 — 只需 hostname + ip,端口默认22 账号默认root,Agent 安装后自动补全 // Logan.Li
function downloadTemplate() {
const csv = [
'hostname,ip,port,username,os,cpu,memory,disk,tags',
'web-01,192.168.1.10,22,root,CentOS 7,4,8,100,"web,prod"',
'db-01,192.168.1.20,22,root,Ubuntu 22.04,8,16,200,"db,prod"',
'hostname,ip',
'web-01,192.168.1.10',
'db-01,192.168.1.20',
'cache-01,192.168.1.30',
].join('\n');
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
@@ -119,30 +120,36 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
const [previewData, setPreviewData] = useState<PreviewRow[]>([]);
const [regions, setRegions] = useState<RegionOption[]>([]);
const [isImporting, setIsImporting] = useState(false);
// 凭据选择 // Logan.Li
const [credentials, setCredentials] = useState<any[]>([]);
const [selectedCredentialId, setSelectedCredentialId] = useState<string>('');
// 对话框打开时拉取区域列表 // Logan.Li
// 对话框打开时拉取区域列表 + 凭据列表 // Logan.Li
useEffect(() => {
if (!open) return;
let cancelled = false;
(async () => {
try {
const resp = await regionApi.list();
const [regionResp, credResp] = await Promise.all([
regionApi.list(),
credentialApi.list(),
]);
if (cancelled) return;
const list = resp?.list ?? [];
setRegions(
list.map((r: any) => ({
id: String(r.id ?? ''),
name: r.name ?? '',
code: r.code ?? '',
}))
);
const list = regionResp?.list ?? [];
setRegions(list.map((r: any) => ({
id: String(r.id ?? ''),
name: r.name ?? '',
code: r.code ?? '',
})));
setCredentials(credResp?.list ?? []);
} catch {
if (!cancelled) setRegions([]);
if (!cancelled) {
setRegions([]);
setCredentials([]);
}
}
})();
return () => {
cancelled = true;
};
return () => { cancelled = true; };
}, [open]);
const handleNext = async () => {
@@ -239,6 +246,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
servers,
region_id: selectedRegion || undefined,
auth_type: connectionMethod,
credential_id: selectedCredentialId ? Number(selectedCredentialId) : undefined,
});
const r = resp ?? {};
const success = r.success ?? 0;
@@ -273,7 +281,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<DialogHeader>
<DialogTitle className="text-xl">Excel导入服务器</DialogTitle>
<DialogTitle className="text-xl">CSV 批量导入服务器</DialogTitle>
<DialogDescription className="sr-only">通过 CSV 文件批量导入服务器资产</DialogDescription>
</DialogHeader>
@@ -313,7 +321,7 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
</div>
<div>
<p className="font-medium text-[var(--dg-text-primary)]">主机导入模板</p>
<p className="text-xs text-[var(--dg-text-muted)]">下载Excel模板,填写服务器信息后上传</p>
<p className="text-xs text-[var(--dg-text-muted)]">下载 CSV 模板,只填主机名和 IP,端口默认22、账号默认root</p>
</div>
</div>
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={downloadTemplate}>
@@ -341,6 +349,32 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
</select>
</div>
{/* 凭据选择(SSH密钥/密码) — 选中后自动关联到导入的每台服务器 // Logan.Li */}
<div>
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">
选择凭据
<span className="text-xs text-[var(--dg-text-muted)] ml-1">(可选,用于SSH连接)</span>
</label>
{credentials.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)] py-2">
暂无凭据,先去「凭证配置」添加 SSH 密钥或密码
</p>
) : (
<select
value={selectedCredentialId}
onChange={(e) => setSelectedCredentialId(e.target.value)}
className="w-full px-3 py-2 bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm"
>
<option value="">不使用凭据(后续手动配置)</option>
{credentials.map((c: any) => (
<option key={c.id} value={String(c.id)}>
{c.type === 'ssh_key' ? '🔑' : c.type === 'api_token' ? '🌐' : '🔒'} {c.name} ({c.type === 'ssh_key' ? 'SSH密钥' : c.type === 'api_token' ? 'API Token' : '密码'})
</option>
))}
</select>
)}
</div>
{/* Connection Method */}
<div>
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-2">连接方式</label>
@@ -452,8 +486,6 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">行号</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">主机名</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">IP地址</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">端口</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">用户名</th>
<th className="px-3 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">状态</th>
</tr>
</thead>
@@ -463,8 +495,6 @@ export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
<td className="px-3 py-2 text-sm text-[var(--dg-text-muted)]">{row.row}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-primary)]">{row.hostname || '-'}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{row.ip}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{row.port}</td>
<td className="px-3 py-2 text-sm text-[var(--dg-text-secondary)]">{row.username}</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
{row.status === 'valid' && (
@@ -37,10 +37,8 @@ import {
Cloud,
FileSpreadsheet,
RefreshCw,
Rocket,
ChevronLeft,
ChevronRight,
Cpu,
} from 'lucide-react';
import { serverApi } from '@/services/api';
import { ServerStatsCards } from './ServerStatsCards';
@@ -53,7 +51,6 @@ import { ExcelServerImport } from './ExcelServerImport';
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
import { getTags } from './serverHelpers';
import { ServerTableRow } from './ServerTableRow';
import { AgentWizard } from '@/components/agent/AgentWizard';
export function ServerManager() {
const confirm = useConfirm();
@@ -74,60 +71,15 @@ export function ServerManager() {
// 终端 Dialog 状态
const [terminalOpen, setTerminalOpen] = useState(false);
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string } | null>(null);
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string; hasAuth?: boolean } | null>(null);
// 分页状态
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
// Agent 安装向导状态
const [agentWizardOpen, setAgentWizardOpen] = useState(false);
const [wizardServer, setWizardServer] = useState<any>(null);
// Agent 安装状态: { [serverId]: 'installing' | 'success' | 'failed' }
const [installingIds, setInstallingIds] = useState<Set<number>>(new Set());
const [installResults, setInstallResults] = useState<Map<number, { success: boolean; message: string }>>(new Map());
const openInstallAgent = (server: any) => {
setWizardServer(server);
setAgentWizardOpen(true);
};
const handleBatchInstallAgents = async () => {
const token = useUserStore.getState().token;
if (!token) { toast.error('请先登录'); return; }
const ids = selectedServers.map(id => Number(id));
setInstallingIds(prev => {
const next = new Set(prev);
ids.forEach(id => next.add(id));
return next;
});
try {
const res: any = await serverApi.batchInstallAgent(ids, token);
const results = res?.results || [];
setInstallResults(prev => {
const next = new Map(prev);
results.forEach((r: any) => {
next.set(r.server_id, { success: r.installed, message: r.message });
});
return next;
});
toast.success(`批量安装完成: ${res?.success || 0}/${ids.length} 成功`);
} catch (e: any) {
toast.error(`批量安装失败: ${e?.message || '未知错误'}`);
} finally {
setInstallingIds(prev => {
const next = new Set(prev);
ids.forEach(id => next.delete(id));
return next;
});
}
};
const openTerminal = (server: any) => {
setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip });
setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip, hasAuth: server.has_auth ?? true });
setTerminalOpen(true);
};
@@ -444,8 +396,8 @@ export function ServerManager() {
onClick={() => setExcelImportOpen(true)}
>
<FileSpreadsheet className="h-4 w-4 mr-2 text-emerald-400" />
Excel导入
<span className="ml-2 text-xs text-[var(--dg-text-muted)]">批量导入服务器</span>
CSV导入
<span className="ml-2 text-xs text-[var(--dg-text-muted)]">hostname + IP 即可</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
@@ -493,15 +445,6 @@ export function ServerManager() {
{selectedServers.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-[var(--dg-text-secondary)]">已选择 {selectedServers.length} 项</span>
<Button
variant="outline"
size="sm"
onClick={handleBatchInstallAgents}
className="border-[var(--dg-cyan)]/30 text-[var(--dg-cyan)] hover:bg-[var(--dg-cyan)]/10"
>
<Cpu className="h-4 w-4 mr-2" />
批量安装Agent
</Button>
<Button
variant="outline"
size="sm"
@@ -562,9 +505,6 @@ export function ServerManager() {
onOpenTerminal={openTerminal}
onOpenEdit={openEditForm}
onDelete={handleDelete}
onInstallAgent={openInstallAgent}
installing={installingIds.has(server.id)}
installResult={installResults.get(server.id) || null}
/>
))
)}
@@ -645,32 +585,6 @@ export function ServerManager() {
}}
/>
{/* Agent 安装向导 */}
<AgentWizard
open={agentWizardOpen}
onOpenChange={(open) => {
setAgentWizardOpen(open);
if (!open) setWizardServer(null);
}}
server={wizardServer}
onInstalled={(serverId) => {
setInstallResults(prev => {
const next = new Map(prev);
next.set(serverId, { success: true, message: 'Agent 已安装' });
return next;
});
// 刷新列表以更新 agent_status
loadData();
}}
onFailed={(serverId, msg) => {
setInstallResults(prev => {
const next = new Map(prev);
next.set(serverId, { success: false, message: msg || '安装失败' });
return next;
});
}}
/>
{/* 终端 Dialog */}
<TerminalDialog
open={terminalOpen}
@@ -678,6 +592,7 @@ export function ServerManager() {
serverId={terminalServer?.id ?? null}
serverName={terminalServer?.name ?? ''}
serverIp={terminalServer?.ip ?? ''}
hasAuth={terminalServer?.hasAuth ?? true}
/>
</div>
);
@@ -12,7 +12,7 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { TableCell, TableRow } from '@/components/ui/table';
import { Cpu, Edit, Loader2, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, CheckCircle, XCircle, Network, HardDrive, Rocket } from 'lucide-react';
import { Edit, MoreHorizontal, Server as ServerIcon, Terminal, Trash2, Network, HardDrive } from 'lucide-react';
import { getStatusBadge, getTags, getUsageColor } from './serverHelpers';
interface ServerTableRowProps {
@@ -22,9 +22,6 @@ interface ServerTableRowProps {
onOpenTerminal: (server: any) => void;
onOpenEdit: (server: any) => void;
onDelete: (id: string) => void;
onInstallAgent: (server: any) => void;
installing?: boolean;
installResult?: { success: boolean; message: string } | null;
}
export function ServerTableRow({
@@ -34,9 +31,6 @@ export function ServerTableRow({
onOpenTerminal,
onOpenEdit,
onDelete,
onInstallAgent,
installing = false,
installResult = null,
}: ServerTableRowProps) {
const cpuUsage = Number(server.cpu_usage || 0);
const memoryUsage = Number(server.memory_usage || 0);
@@ -151,29 +145,6 @@ export function ServerTableRow({
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{/* Agent 安装按钮 — agent_status=online 时隐藏 */}
{server.agent_status !== 'online' && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]"
onClick={() => onInstallAgent(server)}
disabled={installing}
title={installResult?.success ? 'Agent 已安装' : installResult ? '安装失败,点击重试' : '安装 Agent'}
>
{installing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : installResult ? (
installResult.success ? (
<CheckCircle className="h-4 w-4 text-emerald-400" />
) : (
<XCircle className="h-4 w-4 text-red-400" />
)
) : (
<Rocket className="h-4 w-4" />
)}
</Button>
)}
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => onOpenTerminal(server)}>
<Terminal className="h-4 w-4" />
</Button>
@@ -5,12 +5,15 @@ import {
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Terminal as XTerm } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
import 'xterm/css/xterm.css';
import { Maximize2, Minimize2, X } from 'lucide-react';
import { Maximize2, Minimize2, X, Key, Shield } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useUserStore } from '@/store';
import { credentialApi, serverCredentialApi } from '@/services/api';
import { toast } from 'sonner';
interface TerminalDialogProps {
open: boolean;
@@ -18,6 +21,8 @@ interface TerminalDialogProps {
serverId: number | null;
serverName: string;
serverIp: string;
/** 服务器是否有可用的认证凭据(密码或SSH密钥) */
hasAuth?: boolean;
}
/**
@@ -30,6 +35,7 @@ export function TerminalDialog({
serverId,
serverName,
serverIp,
hasAuth = true,
}: TerminalDialogProps) {
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
@@ -38,9 +44,44 @@ export function TerminalDialog({
const [connected, setConnected] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
// 连接终端
// 凭据选择状态(hasAuth=false 时显示) // Logan.Li
const [credentials, setCredentials] = useState<any[]>([]);
const [selectedCredId, setSelectedCredId] = useState<string>('');
const [assigning, setAssigning] = useState(false);
const [authReady, setAuthReady] = useState(hasAuth); // 认证就绪标志
// 加载凭据列表
useEffect(() => {
if (!open || !serverId) return;
if (!open || hasAuth) return;
credentialApi.list().then((res: any) => setCredentials(res?.list || [])).catch(() => {});
}, [open, hasAuth]);
// 关联凭据
const handleAssignCredential = async () => {
if (!selectedCredId || !serverId) return;
try {
setAssigning(true);
await serverCredentialApi.assign(serverId, Number(selectedCredId));
toast.success('凭据已关联');
setAuthReady(true);
} catch (e: any) {
toast.error(e?.message || '关联失败');
} finally {
setAssigning(false);
}
};
// 重置状态
useEffect(() => {
if (!open) {
setAuthReady(hasAuth);
setSelectedCredId('');
}
}, [open, hasAuth]);
// 连接终端(authReady=true 时才执行)
useEffect(() => {
if (!open || !serverId || !authReady) return;
// 创建终端
const term = new XTerm({
@@ -115,7 +156,7 @@ export function TerminalDialog({
wsRef.current = null;
setConnected(false);
};
}, [open, serverId]);
}, [open, serverId, authReady]);
// 用 ResizeObserver 监听容器实际尺寸变化,自动 fit 终端
// 这比 window resize 或 setTimeout 更可靠:
@@ -189,12 +230,47 @@ export function TerminalDialog({
</button>
</div>
</div>
{/* 凭据选择器(无认证凭据时显示) */}
{!authReady && (
<div className="p-6 flex flex-col items-center justify-center gap-4" style={{ height: '480px' }}>
<Shield className="h-12 w-12 text-amber-400" />
<div className="text-center">
<p className="text-[var(--dg-text-primary)] font-medium mb-1">服务器未配置认证凭据</p>
<p className="text-sm text-[var(--dg-text-muted)]">请选择一个凭据用于 SSH 连接</p>
</div>
{credentials.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)]">暂无可用凭据,请先去「凭证配置」添加</p>
) : (
<select
value={selectedCredId}
onChange={(e) => setSelectedCredId(e.target.value)}
className="w-64 px-3 py-2 bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md text-[var(--dg-text-primary)] text-sm"
>
<option value="">-- 选择凭据 --</option>
{credentials.map((c: any) => (
<option key={c.id} value={String(c.id)}>
{c.type === 'ssh_key' ? '🔑' : '🔒'} {c.name}
</option>
))}
</select>
)}
<Button
onClick={handleAssignCredential}
disabled={!selectedCredId || assigning}
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
>
{assigning ? '关联中...' : '确认连接'}
</Button>
</div>
)}
{/* 终端容器 */}
<div
ref={containerRef}
className="bg-[#0a0e14]"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
/>
{authReady && (
<div
ref={containerRef}
className="bg-[#0a0e14]"
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
/>
)}
</DialogContent>
</Dialog>
);