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:
1 parent
31c2e338a3
commit
5949ec5d0c
48 files changed
+2182
-956
No files matched your search
@@ -9,7 +9,9 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, Check, Terminal, Download, Settings, Play, Cpu, Zap, Loader2, Server } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Copy, Check, Terminal, Download, Settings, Play, Cpu, Zap, Loader2, Server, Shield } from 'lucide-react';
|
||||
import { serverApi } from '@/services/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@@ -17,7 +19,7 @@ interface AgentWizardProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
/** 可选:从服务器列表传入的目标服务器信息 */
|
||||
server?: { id: number; hostname: string; ip: string } | null;
|
||||
server?: { id: number; hostname: string; ip: string; has_auth?: boolean; username?: string; os?: string } | null;
|
||||
/** SSH一键安装成功回调 */
|
||||
onInstalled?: (serverId: number) => void;
|
||||
/** SSH一键安装失败回调 */
|
||||
@@ -42,6 +44,13 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
||||
const [showManualInput, setShowManualInput] = useState(false);
|
||||
const [sshInstalling, setSshInstalling] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('auto');
|
||||
// 凭据输入(当服务器无认证凭据时显示)
|
||||
const [sshUsername, setSshUsername] = useState('root');
|
||||
const [sshPassword, setSshPassword] = useState('');
|
||||
const [sshKey, setSshKey] = useState('');
|
||||
const [authType, setAuthType] = useState<'password' | 'key'>('password');
|
||||
|
||||
const needsCreds = server && !server.has_auth;
|
||||
|
||||
// install.sh 通过 Nginx 代理下载(origin:80 → /agent/ → backend:8080)
|
||||
const installCommand = `curl -fsSL ${origin}/agent/install.sh | bash -s -- --server ${backend} --token ${token}`;
|
||||
@@ -142,11 +151,22 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
||||
];
|
||||
|
||||
// SSH 一键安装(当从服务器列表传入 server 上下文时可用)
|
||||
// 如果服务器未配置凭据,将输入框中的凭据传给后端,首次连接成功后自动保存
|
||||
const handleSshInstall = async () => {
|
||||
if (!server || !token) return;
|
||||
if (needsCreds && !sshPassword && !sshKey) {
|
||||
toast.error('请填写 SSH 登录凭据(密码或密钥)');
|
||||
return;
|
||||
}
|
||||
setSshInstalling(true);
|
||||
try {
|
||||
const res: any = await serverApi.installAgent(server.id, token);
|
||||
const creds = needsCreds ? {
|
||||
username: sshUsername,
|
||||
password: sshPassword,
|
||||
ssh_key: sshKey,
|
||||
auth_type: authType,
|
||||
} : undefined;
|
||||
const res: any = await serverApi.installAgent(server.id, token, creds);
|
||||
const installed = res?.installed || false;
|
||||
if (installed) {
|
||||
toast.success(`${server.hostname} Agent 安装成功`);
|
||||
@@ -193,6 +213,12 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
||||
SSH一键安装
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{!server && (
|
||||
<TabsTrigger value="ssh" className="data-[state=active]:bg-slate-700 opacity-50 cursor-not-allowed" disabled>
|
||||
<Zap className="h-4 w-4 mr-1.5" />
|
||||
SSH一键安装
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger value="manual" className="data-[state=active]:bg-slate-700">
|
||||
手动安装
|
||||
</TabsTrigger>
|
||||
@@ -240,7 +266,17 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* SSH 一键安装 — 仅当从服务器列表传入时显示 */}
|
||||
{/* SSH 一键安装 — 无服务器时提示先选择 */}
|
||||
{!server && (
|
||||
<TabsContent value="ssh" className="mt-4 space-y-4">
|
||||
<div className="text-center py-12 text-slate-400">
|
||||
<Server className="h-10 w-10 mx-auto mb-3 opacity-50" />
|
||||
<p className="text-sm font-medium mb-1">未选择目标主机</p>
|
||||
<p className="text-xs">请先在主机列表中勾选一台主机,再点击「安装 Agent」打开此页面</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
{/* SSH 一键安装 — 有服务器时显示 */}
|
||||
{server && (
|
||||
<TabsContent value="ssh" className="mt-4 space-y-4">
|
||||
<div className="rounded-lg bg-slate-950 border border-slate-800 p-4">
|
||||
@@ -251,6 +287,58 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
||||
<code className="text-xs text-slate-400">{server.ip}</code>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 未配置凭据时显示输入框 */}
|
||||
{needsCreds && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-amber-950/30 border border-amber-800/40 space-y-3">
|
||||
<div className="flex items-center gap-2 text-xs text-amber-300/80">
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
<span>该服务器未配置 SSH 凭据,请填写以下信息(首次连接成功后自动保存)</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<Label className="text-xs text-slate-400">用户名</Label>
|
||||
<Input
|
||||
value={sshUsername}
|
||||
onChange={e => setSshUsername(e.target.value)}
|
||||
placeholder="root"
|
||||
className="h-8 text-sm bg-slate-900 border-slate-700 text-slate-200 mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end gap-1">
|
||||
<div className="flex-1">
|
||||
<Label className="text-xs text-slate-400">密码</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={sshPassword}
|
||||
onChange={e => { setSshPassword(e.target.value); setAuthType('password'); }}
|
||||
placeholder="******"
|
||||
className="h-8 text-sm bg-slate-900 border-slate-700 text-slate-200 mt-1"
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs text-slate-500 px-1 pb-1">或</span>
|
||||
<Button variant="ghost" size="sm"
|
||||
onClick={() => { setAuthType('key'); setSshPassword(''); }}
|
||||
className={`text-xs h-7 px-2 ${authType==='key'?'text-blue-400':'text-slate-500'}`}>
|
||||
密钥
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{authType === 'key' && (
|
||||
<div>
|
||||
<Label className="text-xs text-slate-400">SSH 私钥(PEM 格式)</Label>
|
||||
<textarea
|
||||
value={sshKey}
|
||||
onChange={e => setSshKey(e.target.value)}
|
||||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ... -----END OPENSSH PRIVATE KEY-----"
|
||||
rows={3}
|
||||
className="w-full mt-1 bg-slate-900 border border-slate-700 text-slate-200 font-mono text-xs p-2 rounded-md resize-none"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-sm text-slate-400 mb-4">
|
||||
通过 SSH 远程连接到目标主机,自动下载并安装 Agent,完成后立即启动运行。
|
||||
</p>
|
||||
@@ -267,7 +355,7 @@ export function AgentWizard({ open, onOpenChange, server, onInstalled, onFailed
|
||||
) : (
|
||||
<>
|
||||
<Zap className="h-4 w-4 mr-2" />
|
||||
一键安装 Agent
|
||||
{needsCreds ? '连接并安装 Agent(凭据将自动保存)' : '一键安装 Agent'}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
@@ -123,6 +123,7 @@ const menuItems: MenuItem[] = [
|
||||
{ id: 'roles', name: '角色权限', icon: Shield, path: '/roles' },
|
||||
{ id: 'logs-audit', name: '操作审计', icon: FileCode, path: '/logs-audit' },
|
||||
{ id: 'terminal-audit', name: '终端审计', icon: Terminal, path: '/terminal-audit' },
|
||||
{ id: 'credentials', name: '凭证配置', icon: Shield, path: '/credentials' },
|
||||
{ id: 'settings', name: '系统设置', icon: Settings, path: '/settings' },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -40,25 +40,36 @@ const AlertCenter: React.FC = () => {
|
||||
const [ruleDialogOpen, setRuleDialogOpen] = useState(false);
|
||||
const [editingRule, setEditingRule] = useState<any>(null);
|
||||
const [quickSetupLoading, setQuickSetupLoading] = useState(false);
|
||||
// 分页
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize] = useState(10);
|
||||
const [total, setTotal] = useState(0);
|
||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
const interval = setInterval(fetchData, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
}, [page, severityFilter, statusFilter]);
|
||||
|
||||
const fetchData = async () => {
|
||||
await Promise.all([fetchAlerts(), fetchRules()]);
|
||||
};
|
||||
|
||||
// Logan.Li — 获取告警列表
|
||||
// Logan.Li — 获取告警列表(分页)
|
||||
const fetchAlerts = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await alertCenterApi.getAlerts();
|
||||
const raw = (res.list || []) as any[];
|
||||
const res: any = await alertCenterApi.getAlerts({
|
||||
page,
|
||||
page_size: pageSize,
|
||||
severity: severityFilter !== 'all' ? severityFilter : undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
});
|
||||
const raw = (res.list || res.data?.list || []) as any[];
|
||||
const data: Alert[] = raw.map(mapAlert);
|
||||
setAlerts(data);
|
||||
setTotal(res.total || res.data?.total || data.length);
|
||||
setStats({
|
||||
firing: data.filter(a => a.status === 'firing').length,
|
||||
critical: data.filter(a => a.severity === 'critical' && a.status === 'firing').length,
|
||||
@@ -160,67 +171,64 @@ const AlertCenter: React.FC = () => {
|
||||
try { await agentApi.deleteRule(Number(rule.id)); toast.success('已删除'); fetchRules(); } catch { toast.error('删除失败'); }
|
||||
};
|
||||
|
||||
const filteredAlerts = alerts.filter(alert => {
|
||||
const matchSearch = alert.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
alert.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
alert.source.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
const matchSeverity = severityFilter === 'all' || alert.severity === severityFilter;
|
||||
const matchStatus = statusFilter === 'all' || alert.status === statusFilter;
|
||||
return matchSearch && matchSeverity && matchStatus;
|
||||
});
|
||||
const handleFilterChange = (type: 'severity' | 'status', value: string) => {
|
||||
if (type === 'severity') setSeverityFilter(value);
|
||||
else setStatusFilter(value);
|
||||
setPage(1); // 过滤条件变化时重置到第一页
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="space-y-3">
|
||||
{/* 统计卡片 — 紧凑 */}
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Card className={stats.firing > 0 ? 'border-red-500 border-2' : ''}>
|
||||
<CardContent className="p-4">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">触发中</p>
|
||||
<p className={`text-2xl font-bold ${stats.firing > 0 ? 'text-red-600' : ''}`}>{stats.firing}</p>
|
||||
<p className="text-xs text-muted-foreground">触发中</p>
|
||||
<p className={`text-xl font-bold ${stats.firing > 0 ? 'text-red-600' : ''}`}>{stats.firing}</p>
|
||||
</div>
|
||||
<div className={`p-3 rounded-lg ${stats.firing > 0 ? 'bg-red-100' : 'bg-gray-100'}`}>
|
||||
<Zap className={`w-5 h-5 ${stats.firing > 0 ? 'text-red-600' : 'text-gray-600'}`} />
|
||||
<div className={`p-2 rounded-md ${stats.firing > 0 ? 'bg-red-100' : 'bg-gray-100'}`}>
|
||||
<Zap className={`w-4 h-4 ${stats.firing > 0 ? 'text-red-600' : 'text-gray-600'}`} />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">严重告警</p>
|
||||
<p className="text-2xl font-bold text-red-600">{stats.critical}</p>
|
||||
<p className="text-xs text-muted-foreground">严重告警</p>
|
||||
<p className="text-xl font-bold text-red-600">{stats.critical}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-red-100 rounded-lg">
|
||||
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||
<div className="p-2 bg-red-100 rounded-md">
|
||||
<AlertCircle className="w-4 h-4 text-red-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">警告告警</p>
|
||||
<p className="text-2xl font-bold text-orange-600">{stats.warning}</p>
|
||||
<p className="text-xs text-muted-foreground">警告告警</p>
|
||||
<p className="text-xl font-bold text-orange-600">{stats.warning}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-orange-100 rounded-lg">
|
||||
<AlertTriangle className="w-5 h-5 text-orange-600" />
|
||||
<div className="p-2 bg-orange-100 rounded-md">
|
||||
<AlertTriangle className="w-4 h-4 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">今日已解决</p>
|
||||
<p className="text-2xl font-bold text-green-600">{stats.resolved}</p>
|
||||
<p className="text-xs text-muted-foreground">今日已解决</p>
|
||||
<p className="text-xl font-bold text-green-600">{stats.resolved}</p>
|
||||
</div>
|
||||
<div className="p-3 bg-green-100 rounded-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
<div className="p-2 bg-green-100 rounded-md">
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -228,27 +236,27 @@ const AlertCenter: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList className="grid w-full grid-cols-3 lg:w-[400px]">
|
||||
<TabsTrigger value="alerts">告警列表</TabsTrigger>
|
||||
<TabsTrigger value="rules">告警规则</TabsTrigger>
|
||||
<TabsTrigger value="channels">通知渠道</TabsTrigger>
|
||||
<TabsList className="grid w-full grid-cols-3 lg:w-[360px] h-9">
|
||||
<TabsTrigger value="alerts" className="text-xs">告警列表</TabsTrigger>
|
||||
<TabsTrigger value="rules" className="text-xs">告警规则</TabsTrigger>
|
||||
<TabsTrigger value="channels" className="text-xs">通知渠道</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 告警列表 Tab */}
|
||||
<TabsContent value="alerts" className="space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
|
||||
<TabsContent value="alerts" className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[280px]">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索告警标题、描述、来源..."
|
||||
placeholder="搜索告警..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
className="pl-8 h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<Select value={severityFilter} onValueChange={setSeverityFilter}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<Select value={severityFilter} onValueChange={(v) => handleFilterChange('severity', v)}>
|
||||
<SelectTrigger className="w-[100px] h-8 text-xs">
|
||||
<SelectValue placeholder="级别" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -258,8 +266,8 @@ const AlertCenter: React.FC = () => {
|
||||
<SelectItem value="info">信息</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<Select value={statusFilter} onValueChange={(v) => handleFilterChange('status', v)}>
|
||||
<SelectTrigger className="w-[100px] h-8 text-xs">
|
||||
<SelectValue placeholder="状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -270,76 +278,85 @@ const AlertCenter: React.FC = () => {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button variant="outline" onClick={fetchAlerts}>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<span>共 {total} 条</span>
|
||||
<Button variant="outline" size="sm" onClick={fetchAlerts} className="h-7 text-xs px-2">
|
||||
<RefreshCw className="w-3 h-3 mr-1" />刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 告警列表 */}
|
||||
{/* 告警表格 — 紧凑 */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">告警信息</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">级别</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">状态</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">来源</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">持续时间</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium">操作</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium">告警信息</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium w-16">级别</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium w-16">状态</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium w-24">来源</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium w-20">持续</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium w-28">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{loading ? (
|
||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">加载中...</td></tr>
|
||||
) : filteredAlerts.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">暂无告警</td></tr>
|
||||
<tr><td colSpan={6} className="px-3 py-6 text-center text-xs text-muted-foreground">加载中...</td></tr>
|
||||
) : alerts.length === 0 ? (
|
||||
<tr><td colSpan={6} className="px-3 py-6 text-center text-xs text-muted-foreground">暂无告警</td></tr>
|
||||
) : (
|
||||
filteredAlerts.map((alert) => (
|
||||
alerts.filter(alert => {
|
||||
// 客户端也保留关键词搜索过滤
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return alert.title.toLowerCase().includes(q) ||
|
||||
alert.description.toLowerCase().includes(q) ||
|
||||
alert.source.toLowerCase().includes(q);
|
||||
}).map((alert) => (
|
||||
<tr key={alert.id} className="hover:bg-muted/50">
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium">{alert.title}</div>
|
||||
<div className="text-sm text-muted-foreground line-clamp-1">{alert.description}</div>
|
||||
<div className="flex gap-1 mt-1">
|
||||
{Object.entries(alert.labels).slice(0, 3).map(([k, v]) => (
|
||||
<Badge key={k} variant="outline" className="text-xs">{k}={v}</Badge>
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium text-xs">{alert.title}</div>
|
||||
<div className="text-xs text-muted-foreground line-clamp-1">{alert.description}</div>
|
||||
<div className="flex gap-1 mt-0.5">
|
||||
{Object.entries(alert.labels).slice(0, 2).map(([k, v]) => (
|
||||
<Badge key={k} variant="outline" className="text-[10px] px-1 py-0">{k}={v}</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{getSeverityBadge(alert.severity)}</td>
|
||||
<td className="px-4 py-3">{getStatusBadge(alert.status)}</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<td className="px-3 py-2">{getSeverityBadge(alert.severity)}</td>
|
||||
<td className="px-3 py-2">{getStatusBadge(alert.status)}</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<div>{alert.source}</div>
|
||||
<div className="text-muted-foreground">{alert.service}</div>
|
||||
<div className="text-muted-foreground text-[10px]">{alert.service}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-muted-foreground">
|
||||
<td className="px-3 py-2 text-xs text-muted-foreground">
|
||||
{alert.status === 'resolved' && alert.resolvedAt
|
||||
? `${Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000)}分钟`
|
||||
: `${Math.floor((Date.now() - new Date(alert.startedAt).getTime()) / 60000)}分钟`}
|
||||
? `${Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000)}分`
|
||||
: `${Math.floor((Date.now() - new Date(alert.startedAt).getTime()) / 60000)}分`}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm"
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
|
||||
onClick={() => { setSelectedAlert(alert); setDetailOpen(true); }}>
|
||||
<Eye className="w-4 h-4" />
|
||||
<Eye className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
{alert.status === 'firing' && (
|
||||
<Button variant="ghost" size="sm"
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
|
||||
onClick={() => handleAcknowledge(alert.id)}>
|
||||
<Check className="w-4 h-4 text-blue-600" />
|
||||
<Check className="w-3.5 h-3.5 text-blue-600" />
|
||||
</Button>
|
||||
)}
|
||||
{(alert.status === 'firing' || alert.status === 'acknowledged') && (
|
||||
<Button variant="ghost" size="sm"
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
|
||||
onClick={() => handleResolve(alert.id)}>
|
||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
||||
<CheckCircle className="w-3.5 h-3.5 text-green-600" />
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="sm"
|
||||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0"
|
||||
onClick={() => { setSelectedAlert(alert); setSilenceOpen(true); }}>
|
||||
<VolumeX className="w-4 h-4 text-orange-600" />
|
||||
<VolumeX className="w-3.5 h-3.5 text-orange-600" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
@@ -349,6 +366,34 @@ const AlertCenter: React.FC = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/* 分页控件 */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-3 py-2 border-t text-xs">
|
||||
<span className="text-muted-foreground">第 {page}/{totalPages} 页,共 {total} 条</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs px-2"
|
||||
disabled={page <= 1} onClick={() => setPage(p => p - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
const start = Math.max(1, Math.min(page - 2, totalPages - 4));
|
||||
const pn = start + i;
|
||||
if (pn > totalPages) return null;
|
||||
return (
|
||||
<Button key={pn} variant={pn === page ? 'default' : 'outline'} size="sm"
|
||||
className="h-7 min-w-[28px] text-xs px-1"
|
||||
onClick={() => setPage(pn)}>
|
||||
{pn}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs px-2"
|
||||
disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
@@ -3,7 +3,7 @@ import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Monitor, Plus, RefreshCw, Maximize2, Minimize2, BarChart3, Clock,
|
||||
Server, Activity, Database, Wifi, WifiOff, ArrowUpDown, ArrowUp, ArrowDown,
|
||||
ExternalLink
|
||||
ExternalLink, Download, Trash2, Rocket
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -15,12 +15,17 @@ import {
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from 'sonner';
|
||||
import { dashboardApi, promDatasourceApi, zabbixDatasourceApi } from '@/services/api';
|
||||
import { useUserStore } from '@/store';
|
||||
import { dashboardApi, promDatasourceApi, zabbixDatasourceApi, agentApi, serverApi } from '@/services/api';
|
||||
import { GrafanaPanelContent } from './GrafanaPanelContent';
|
||||
import { ZabbixDashboard } from './ZabbixDashboard';
|
||||
import { PrometheusDashboard } from './PrometheusDashboard';
|
||||
import { http } from '@/services/http';
|
||||
import { asList } from '@/components/agent/agentUtils';
|
||||
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';
|
||||
|
||||
// ═══════════ Tab 配置 ═══════════
|
||||
type TabId = 'agent' | 'zabbix' | 'prometheus' | 'dashboard';
|
||||
@@ -32,16 +37,21 @@ const TABS: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
||||
];
|
||||
|
||||
// ═══════════ Agent 主机监控表格 ═══════════
|
||||
interface AgentHost { 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; }
|
||||
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; }
|
||||
type SortField = 'hostname' | 'agent_version' | 'agent_status' | 'cpu_usage' | 'mem_usage' | 'disk_usage' | 'region' | 'ip';
|
||||
|
||||
const AgentTab: React.FC = () => {
|
||||
const _confirm = useConfirm();
|
||||
const navigate = useNavigate();
|
||||
const [hosts, setHosts] = useState<AgentHost[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 totalPages = Math.max(1, Math.ceil(hosts.length / pageSize));
|
||||
const fetch = async () => { try { setHosts(asList(await http.get('/agent/latest'))); } catch {} finally { setLoading(false); } };
|
||||
useEffect(() => { fetch(); const t = setInterval(fetch, 30000); return () => clearInterval(t); }, []);
|
||||
@@ -57,49 +67,133 @@ const AgentTab: React.FC = () => {
|
||||
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 ? 'text-orange-400' : ''}`}>{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));
|
||||
|
||||
// 批量安装 Agent // Logan.Li
|
||||
const [batchInstalling, setBatchInstalling] = useState(false);
|
||||
const handleBatchInstall = async () => {
|
||||
const token = useUserStore.getState().token;
|
||||
if (!token) { toast.error('未登录'); return; }
|
||||
const ok = await _confirm({ title: '批量安装 Agent', description: `确定对选中的 ${selIds.length} 台主机远程安装 Agent?安装将在后台异步执行。`, confirmText: '安装', variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
setBatchInstalling(true);
|
||||
try {
|
||||
const res = await serverApi.batchInstallAgent(selIds, token);
|
||||
toast.success(`已提交:批量安装 ${res?.total ?? selIds.length} 台主机的 Agent,后台执行中,30秒后自动刷新`);
|
||||
setSelected(new Set());
|
||||
setTimeout(() => fetch(), 30000);
|
||||
} catch { toast.error('提交失败'); }
|
||||
finally { setBatchInstalling(false); }
|
||||
};
|
||||
|
||||
// 批量卸载 Agent // Logan.Li
|
||||
const [batchUninstalling, setBatchUninstalling] = useState(false);
|
||||
const handleBatchUninstall = async () => {
|
||||
const ok = await _confirm({ title: '批量卸载 Agent', description: `确定卸载选中的 ${selIds.length} 台主机的 AIOps Agent?卸载将在后台异步执行。`, confirmText: '卸载', variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
setBatchUninstalling(true);
|
||||
try {
|
||||
await agentApi.batchUninstall(selIds);
|
||||
toast.success(`已提交:批量卸载 ${selIds.length} 台主机的 Agent,后台执行中,30秒后自动刷新`);
|
||||
setSelected(new Set());
|
||||
setTimeout(() => fetch(), 30000);
|
||||
} catch { toast.error('提交失败'); }
|
||||
finally { setBatchUninstalling(false); }
|
||||
};
|
||||
|
||||
if (loading) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Activity className="h-6 w-6 mx-auto mb-2 animate-spin" /><p className="text-xs">加载中…</p></div>;
|
||||
if (!hosts.length) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Server className="h-8 w-8 mx-auto mb-2" /><p className="text-sm">暂无 Agent 上报数据</p><p className="text-xs mt-1">请确保至少一台主机安装了 AIOps Agent</p></div>;
|
||||
if (!hosts.length) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Server className="h-8 w-8 mx-auto mb-2" /><p className="text-sm">暂无服务器资产</p><p className="text-xs mt-1">请先在「服务器管理」中添加主机,然后在此安装 Agent</p></div>;
|
||||
return (
|
||||
<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>
|
||||
{['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:'主机名',agent_version:'Agent版本',agent_status:'状态',cpu_usage:'CPU',mem_usage:'内存',disk_usage:'磁盘',region:'区域',ip:'IP'}[f]}<SI f={f as SortField} /></div>
|
||||
<div>
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
{selAny && <span className="text-xs text-[var(--dg-text-secondary)]">已选 {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 as any).has_auth } : null);
|
||||
setWizardOpen(true);
|
||||
}}>
|
||||
<Rocket className="w-3 h-3" />安装 Agent
|
||||
</Button>
|
||||
{selAny && (
|
||||
<>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs gap-1 border-blue-500/30 text-blue-400 hover:bg-blue-500/10" onClick={handleBatchInstall} disabled={batchInstalling}>
|
||||
<Rocket className="w-3 h-3" />批量安装 ({selected.size})
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" className="h-7 text-xs gap-1 border-red-500/30 text-red-400 hover:bg-red-500/10" onClick={handleBatchUninstall} disabled={batchUninstalling}>
|
||||
<Trash2 className="w-3 h-3" />批量卸载 ({selected.size})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" onClick={fetch}><RefreshCw className="w-3 h-3" />刷新</Button>
|
||||
</div>
|
||||
|
||||
<AgentWizard open={wizardOpen} onOpenChange={setWizardOpen} server={wizardServer}
|
||||
onInstalled={() => { toast.success('Agent 安装成功'); setWizardOpen(false); fetch(); }}
|
||||
onFailed={() => toast.error('安装失败')} />
|
||||
|
||||
<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>
|
||||
))}</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-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)]">{h.agent_version || '—'}</td>
|
||||
<td className="px-3 py-2.5"><Badge className={`text-[10px] ${h.agent_status==='online'?'bg-emerald-500/20 text-emerald-400':'bg-slate-500/10 text-slate-400'}`}>{h.agent_status==='online'?<><Wifi className="w-3 h-3 mr-1"/>在线</>:<><WifiOff className="w-3 h-3 mr-1"/>离线</>}</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>
|
||||
</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>共 {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/页</option>
|
||||
<option value={20}>20/页</option>
|
||||
<option value={30}>30/页</option>
|
||||
</select>
|
||||
{['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:'主机名',agent_version:'Agent版本',agent_status:'状态',cpu_usage:'CPU',mem_usage:'内存',disk_usage:'磁盘',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">操作</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);
|
||||
c ? next.add(String(h.server_id)) : 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)]">{h.agent_version || '—'}</td>
|
||||
<td className="px-3 py-2.5">{h.agent_status==='online'?<Badge className="text-[10px] bg-emerald-500/20 text-emerald-400"><Wifi className="w-3 h-3 mr-1"/>在线</Badge>:h.agent_status==='not_installed'?<Badge className="text-[10px] bg-amber-500/20 text-amber-400">未安装</Badge>:<Badge className="text-[10px] bg-slate-500/10 text-slate-400"><WifiOff className="w-3 h-3 mr-1"/>离线</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 text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 px-2"
|
||||
onClick={() => navigate(`/host-monitor?host=${encodeURIComponent(h.hostname)}`)}>
|
||||
<BarChart3 className="w-3.5 h-3.5 mr-1" />图表
|
||||
</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>共 {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/页</option>
|
||||
<option value={20}>20/页</option>
|
||||
<option value={30}>30/页</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)}>上一页</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)}>下一页</button>
|
||||
</div>
|
||||
</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)}>上一页</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)}>下一页</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,385 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Plus, Trash2, Edit3, Key, Globe, Shield, Eye, EyeOff, RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { credentialApi } from '@/services/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
type CredentialType = 'ssh_key' | 'api_token' | 'password' | 'access_key';
|
||||
|
||||
const TYPE_CONFIG: Record<CredentialType, { label: string; icon: typeof Key; color: string }> = {
|
||||
ssh_key: { label: 'SSH 密钥', icon: Key, color: 'text-amber-400' },
|
||||
api_token: { label: 'API Token', icon: Globe, color: 'text-blue-400' },
|
||||
password: { label: '密码', icon: Shield, color: 'text-emerald-400' },
|
||||
access_key: { label: 'AK密钥对', icon: Key, color: 'text-purple-400' },
|
||||
};
|
||||
|
||||
interface CredentialItem {
|
||||
id: number;
|
||||
name: string;
|
||||
type: CredentialType;
|
||||
preview: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const EMPTY_FORM = { name: '', type: 'password' as CredentialType, content: '', description: '' };
|
||||
|
||||
export function CredentialManager() {
|
||||
const confirm = useConfirm();
|
||||
const [credentials, setCredentials] = useState<CredentialItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 表单状态
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [formData, setFormData] = useState(EMPTY_FORM);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showContent, setShowContent] = useState(false);
|
||||
|
||||
const fetchList = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res: any = await credentialApi.list();
|
||||
setCredentials(res?.list || []);
|
||||
} catch (e) {
|
||||
console.error('加载凭据列表失败:', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => { fetchList(); }, [fetchList]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
setFormData(EMPTY_FORM);
|
||||
setShowContent(false);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (item: CredentialItem) => {
|
||||
setEditingId(item.id);
|
||||
setFormData({
|
||||
name: item.name,
|
||||
type: item.type,
|
||||
content: '', // 编辑时空内容表示不更新
|
||||
description: item.description || '',
|
||||
});
|
||||
setShowContent(false);
|
||||
setFormOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formData.name || (!editingId && !formData.content)) return;
|
||||
try {
|
||||
setSaving(true);
|
||||
if (editingId) {
|
||||
await credentialApi.update(editingId, {
|
||||
name: formData.name,
|
||||
type: formData.type,
|
||||
...(formData.content ? { content: formData.content } : {}),
|
||||
description: formData.description,
|
||||
});
|
||||
toast.success('凭据已更新');
|
||||
} else {
|
||||
await credentialApi.create(formData);
|
||||
toast.success('凭据已创建');
|
||||
}
|
||||
setFormOpen(false);
|
||||
fetchList();
|
||||
} catch (e: any) {
|
||||
toast.error(e?.message || '操作失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
const item = credentials.find(c => c.id === id);
|
||||
const ok = await confirm({
|
||||
title: '删除凭据',
|
||||
description: `确定删除「${item?.name || id}」吗?删除后无法恢复,关联的服务器将无法使用此凭据连接。`,
|
||||
confirmText: '删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await credentialApi.delete(id);
|
||||
toast.success('凭据已删除');
|
||||
fetchList();
|
||||
} catch (e) {
|
||||
toast.error('删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const typeIcon = (type: CredentialType) => {
|
||||
const cfg = TYPE_CONFIG[type];
|
||||
const Icon = cfg.icon;
|
||||
return <Icon className={`h-4 w-4 ${cfg.color}`} />;
|
||||
};
|
||||
|
||||
const typeBadge = (type: CredentialType) => {
|
||||
const cfg = TYPE_CONFIG[type];
|
||||
const bgMap: Record<string, string> = {
|
||||
ssh_key: 'bg-amber-500/20 text-amber-400',
|
||||
api_token: 'bg-blue-500/20 text-blue-400',
|
||||
password: 'bg-emerald-500/20 text-emerald-400',
|
||||
access_key: 'bg-purple-500/20 text-purple-400',
|
||||
};
|
||||
return (
|
||||
<Badge className={`border-0 text-xs ${bgMap[type] || ''}`}>
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">凭证配置</h1>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-1">
|
||||
SSH 密钥、API Token、密码、AK密钥对等敏感信息统一加密管理
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]"
|
||||
onClick={fetchList}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
刷新
|
||||
</Button>
|
||||
<Button className="bg-[var(--dg-blue)] hover:opacity-90 text-white" onClick={openCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加凭据
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="border-[var(--dg-border)] hover:bg-transparent">
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">名称</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">类型</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">内容预览</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">描述</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)]">更新时间</TableHead>
|
||||
<TableHead className="text-[var(--dg-text-secondary)] text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={6} className="text-center text-[var(--dg-text-muted)] py-12">
|
||||
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-2" />
|
||||
加载中...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : credentials.length === 0 ? (
|
||||
<TableRow className="border-[var(--dg-border)]">
|
||||
<TableCell colSpan={6} className="text-center text-[var(--dg-text-muted)] py-12">
|
||||
暂无凭据,点击「添加凭据」创建
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
credentials.map((cred) => (
|
||||
<TableRow key={cred.id} className="border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]">
|
||||
<TableCell className="font-medium text-[var(--dg-text-primary)]">
|
||||
<div className="flex items-center gap-2">
|
||||
{typeIcon(cred.type)}
|
||||
{cred.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{typeBadge(cred.type)}</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-xs text-[var(--dg-text-muted)] bg-[var(--dg-bg-tertiary)] px-2 py-0.5 rounded">
|
||||
{cred.preview || '****'}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-secondary)] max-w-[200px] truncate" title={cred.description}>
|
||||
{cred.description || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-[var(--dg-text-muted)]">
|
||||
{cred.updated_at ? new Date(cred.updated_at).toLocaleDateString('zh-CN') : '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => openEdit(cred)}
|
||||
>
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-[var(--dg-text-muted)] hover:text-red-400"
|
||||
onClick={() => handleDelete(cred.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Form Dialog */}
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent className="max-w-lg bg-[var(--dg-bg-primary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? '编辑凭据' : '添加凭据'}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{editingId ? '编辑凭据信息,留空内容不更新' : '添加新的凭据'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 名称 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-1">
|
||||
名称 <span className="text-red-400">*</span>
|
||||
</label>
|
||||
<Input
|
||||
placeholder="如: 生产环境SSH密钥"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 类型 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-1">类型</label>
|
||||
<Select
|
||||
value={formData.type}
|
||||
onValueChange={(v) => setFormData({ ...formData, type: v as CredentialType })}
|
||||
>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]">
|
||||
<SelectItem value="ssh_key">
|
||||
<span className="flex items-center gap-2">
|
||||
<Key className="h-4 w-4 text-amber-400" /> SSH 密钥
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="api_token">
|
||||
<span className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-blue-400" /> API Token
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="password">
|
||||
<span className="flex items-center gap-2">
|
||||
<Shield className="h-4 w-4 text-emerald-400" /> 密码
|
||||
</span>
|
||||
</SelectItem>
|
||||
<SelectItem value="access_key">
|
||||
<span className="flex items-center gap-2">
|
||||
<Key className="h-4 w-4 text-purple-400" /> AK密钥对
|
||||
</span>
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-1">
|
||||
{formData.type === 'ssh_key' ? 'SSH 私钥' : formData.type === 'api_token' ? 'Token' : formData.type === 'access_key' ? 'AK/SK (每行一对,用空格或逗号分隔)' : '密码'}
|
||||
{!editingId && <span className="text-red-400"> *</span>}
|
||||
{editingId && <span className="text-xs text-[var(--dg-text-muted)] ml-1">(留空不更新)</span>}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
placeholder={
|
||||
formData.type === 'ssh_key'
|
||||
? '-----BEGIN OPENSSH PRIVATE KEY-----\n...'
|
||||
: formData.type === 'api_token'
|
||||
? '***'
|
||||
: formData.type === 'access_key'
|
||||
? 'AKID*** SK***\nAKID*** SK***'
|
||||
: '输入密码'
|
||||
}
|
||||
value={formData.content}
|
||||
onChange={(e) => setFormData({ ...formData, content: e.target.value })}
|
||||
className={`bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] min-h-[100px] ${formData.type === 'ssh_key' ? 'font-mono text-xs' : showContent ? '' : 'text-security-disc'}`}
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute top-2 right-2 h-7 w-7 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
|
||||
onClick={() => setShowContent(!showContent)}
|
||||
title={showContent ? '隐藏' : '显示'}
|
||||
>
|
||||
{showContent ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 描述 */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-[var(--dg-text-secondary)] mb-1">描述</label>
|
||||
<Input
|
||||
placeholder="可选备注"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 安全提示 */}
|
||||
<div className="p-3 rounded-lg bg-amber-500/10 border border-amber-500/20">
|
||||
<p className="text-xs text-amber-400 flex items-center gap-2">
|
||||
<Shield className="h-3.5 w-3.5" />
|
||||
凭据内容使用 AES-256-GCM 加密存储,仅在服务器端解密使用
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" className="border-[var(--dg-border)]" onClick={() => setFormOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-[var(--dg-blue)] hover:opacity-90 text-white"
|
||||
onClick={handleSave}
|
||||
disabled={saving || !formData.name}
|
||||
>
|
||||
{saving ? '保存中...' : editingId ? '保存' : '创建'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user