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
@@ -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