refactor(frontend): P1-P2 续 — API 子模块化、大组件拆分、Query 铺开

- api/server/ 与 api/cloud/ 子目录拆分,保持 @/api 导入不变
- hooks/api 新增 useCredentials、usePromDatasources,增强 useServers
- 拆分 CredentialManager、DeploymentWorkload、PrometheusDatasourceManager
- ServerManager、CredentialManager、PromDs 接入 TanStack Query
This commit is contained in:
Logan.Li committed 2026-06-26 00:22:50 +08:00
1 parent f45e203e21
commit 480e677042
32 files changed
+2277 -1937

No files matched your search

+2 -105
View File
@@ -1,106 +1,3 @@
// 云账号/平台/账单 // 云资源 API — 子模块 re-export(保持 @/api 导入路径不变) // Logan.Li
// 从 services/api.ts 拆分 — Q3 api.ts 模块化
import { http, type ListResponse } from '@/utils/request'; export * from './cloud/index';
export const cloudApi = {
getAccounts: async () => {
return http.get<ListResponse>('/cloud-accounts');
},
createAccount: async ( data: any) => {
return http.post('/cloud-accounts', data);
},
updateAccount: async (id: number | string, data: any) => {
return http.put(`/cloud-accounts/${id}`, data);
},
deleteAccount: async (id: number | string) => {
return http.delete(`/cloud-accounts/${id}`);
},
// ==================== 云同步 API ====================
// 同步云账号的区域列表到本地
syncRegions: async (id: number | string) => {
return http.post(`/cloud-accounts/${id}/sync-regions`);
},
// 获取云账号的区域列表
getAccountRegions: async (id: number | string) => {
return http.get<ListResponse>(`/cloud-accounts/${id}/regions`);
},
// 发现指定区域的云主机实例
discoverInstances: async (id: number | string, regions: string[]) => {
return http.post<{ list: any[]; total: number; errors: string[] }>(`/cloud-accounts/${id}/discover-instances`, { regions });
},
// 导入选中的云主机实例为本地服务器
importInstances: async (id: number | string, instances: any[], opts?: { default_port?: number; credential_id?: number }) => {
return http.post<{ imported: number; skipped: number }>(`/cloud-accounts/${id}/import-instances`, { instances, ...opts });
},
getDomains: async (params?: { page?: number; page_size?: number; keyword?: string }) => {
return http.get<ListResponse>('/domains', params);
},
createDomain: async ( data: any) => {
return http.post('/domains', data);
},
updateDomain: async (id: number | string, data: any) => {
return http.put(`/domains/${id}`, data);
},
deleteDomain: async (id: number | string) => {
return http.delete(`/domains/${id}`);
},
};
// ==================== 云平台 API ====================
export const platformApi = {
list: async () => {
return http.get<ListResponse>('/platforms');
},
create: async ( data: any) => {
return http.post('/platforms', data);
},
update: async (id: number | string, data: any) => {
return http.put(`/platforms/${id}`, data);
},
delete: async (id: number | string) => {
return http.delete(`/platforms/${id}`);
},
};
// ==================== 云账单 API ====================
export const billingApi = {
getStats: async () => {
return http.get('/cloud-billings/stats');
},
list: async (params?: { provider?: string; start_date?: string; end_date?: string; page?: number; page_size?: number }) => {
return http.get<ListResponse>('/cloud-billings', params);
},
create: async ( data: any) => {
return http.post('/cloud-billings', data);
},
batchCreate: async ( records: any[]) => {
return http.post('/cloud-billings/batch', { records });
},
remove: async (id: number | string) => {
return http.delete(`/cloud-billings/${id}`);
},
getProviders: async () => {
return http.get('/cloud-billings/providers');
},
// 预警规则
listAlertRules: async () => {
return http.get<ListResponse>('/billing-alerts');
},
createAlertRule: async ( data: any) => {
return http.post('/billing-alerts', data);
},
updateAlertRule: async (id: number | string, data: any) => {
return http.put(`/billing-alerts/${id}`, data);
},
deleteAlertRule: async (id: number | string) => {
return http.delete(`/billing-alerts/${id}`);
},
toggleAlertRule: async (id: number | string) => {
return http.post(`/billing-alerts/${id}/toggle`);
},
};
// ==================== Agent 监控 API ====================
+42
View File
@@ -0,0 +1,42 @@
// 云账号 / 域名 API // Logan.Li
import { http, type ListResponse } from '@/utils/request';
export const cloudApi = {
getAccounts: async () => {
return http.get<ListResponse>('/cloud-accounts');
},
createAccount: async (data: Record<string, unknown>) => {
return http.post('/cloud-accounts', data);
},
updateAccount: async (id: number | string, data: Record<string, unknown>) => {
return http.put(`/cloud-accounts/${id}`, data);
},
deleteAccount: async (id: number | string) => {
return http.delete(`/cloud-accounts/${id}`);
},
syncRegions: async (id: number | string) => {
return http.post(`/cloud-accounts/${id}/sync-regions`);
},
getAccountRegions: async (id: number | string) => {
return http.get<ListResponse>(`/cloud-accounts/${id}/regions`);
},
discoverInstances: async (id: number | string, regions: string[]) => {
return http.post<{ list: Record<string, unknown>[]; total: number; errors: string[] }>(`/cloud-accounts/${id}/discover-instances`, { regions });
},
importInstances: async (id: number | string, instances: Record<string, unknown>[], opts?: { default_port?: number; credential_id?: number }) => {
return http.post<{ imported: number; skipped: number }>(`/cloud-accounts/${id}/import-instances`, { instances, ...opts });
},
getDomains: async (params?: { page?: number; page_size?: number; keyword?: string }) => {
return http.get<ListResponse>('/domains', params);
},
createDomain: async (data: Record<string, unknown>) => {
return http.post('/domains', data);
},
updateDomain: async (id: number | string, data: Record<string, unknown>) => {
return http.put(`/domains/${id}`, data);
},
deleteDomain: async (id: number | string) => {
return http.delete(`/domains/${id}`);
},
};
+39
View File
@@ -0,0 +1,39 @@
// 云账单 / 预警 API // Logan.Li
import { http, type ListResponse } from '@/utils/request';
export const billingApi = {
getStats: async () => {
return http.get('/cloud-billings/stats');
},
list: async (params?: { provider?: string; start_date?: string; end_date?: string; page?: number; page_size?: number }) => {
return http.get<ListResponse>('/cloud-billings', params);
},
create: async (data: Record<string, unknown>) => {
return http.post('/cloud-billings', data);
},
batchCreate: async (records: Record<string, unknown>[]) => {
return http.post('/cloud-billings/batch', { records });
},
remove: async (id: number | string) => {
return http.delete(`/cloud-billings/${id}`);
},
getProviders: async () => {
return http.get('/cloud-billings/providers');
},
listAlertRules: async () => {
return http.get<ListResponse>('/billing-alerts');
},
createAlertRule: async (data: Record<string, unknown>) => {
return http.post('/billing-alerts', data);
},
updateAlertRule: async (id: number | string, data: Record<string, unknown>) => {
return http.put(`/billing-alerts/${id}`, data);
},
deleteAlertRule: async (id: number | string) => {
return http.delete(`/billing-alerts/${id}`);
},
toggleAlertRule: async (id: number | string) => {
return http.post(`/billing-alerts/${id}/toggle`);
},
};
+5
View File
@@ -0,0 +1,5 @@
// 云资源 API 聚合出口 // Logan.Li
export { cloudApi } from './account';
export { platformApi } from './platform';
export { billingApi } from './billing';
+18
View File
@@ -0,0 +1,18 @@
// 云平台 API // Logan.Li
import { http, type ListResponse } from '@/utils/request';
export const platformApi = {
list: async () => {
return http.get<ListResponse>('/platforms');
},
create: async (data: Record<string, unknown>) => {
return http.post('/platforms', data);
},
update: async (id: number | string, data: Record<string, unknown>) => {
return http.put(`/platforms/${id}`, data);
},
delete: async (id: number | string) => {
return http.delete(`/platforms/${id}`);
},
};
+2 -103
View File
@@ -1,104 +1,3 @@
// 服务器/资产/区域/资源型号 // 服务器域 API — 子模块 re-export(保持 @/api 导入路径不变) // Logan.Li
// 从 services/api.ts 拆分 — Q3 api.ts 模块化
import { http, type ListResponse } from '@/utils/request'; export * from './server/index';
export const serverApi = {
getServers: async (params?: { page?: number; page_size?: number; keyword?: string; status?: string }) => {
return http.get<ListResponse>('/servers', params);
},
getServerDetail: async (id: number | string) => {
return http.get(`/servers/${id}`);
},
getServerStats: async () => {
return http.get('/servers/stats');
},
createServer: async ( data: any) => {
return http.post('/servers', data);
},
updateServer: async (id: number | string, data: any) => {
return http.put(`/servers/${id}`, data);
},
deleteServer: async (id: number | string) => {
return http.delete(`/servers/${id}`);
},
batchDelete: async (ids: number[]) => {
return http.post('/servers/batch-delete', { ids });
},
batchCreate: async (data: { servers: any[]; region_id?: string; auth_type?: string; credential_id?: number; default_port?: number }) => {
return http.post('/servers/batch', data);
},
getMetrics: async (_id: number | string) => {
// 后端暂无单机 metrics,用 dashboard metrics
return http.get('/dashboard/metrics');
},
// 远程安装 Agent(通过 SSH 执行安装脚本),可选传入凭据
installAgent: async (id: number | string, token: string, creds?: { username?: string; password?: string; ssh_key?: string; auth_type?: string }) => {
const body: Record<string, any> = { token };
if (creds) Object.assign(body, creds);
return http.post<{ installed: boolean; arch: string; output: string }>(`/servers/${id}/install-agent`, body);
},
// 批量远程安装 Agent
batchInstallAgent: async (ids: number[], token: string) => {
return http.post<{ total: number; success: number; results: any[] }>(`/servers/install-agent?token=${encodeURIComponent(token)}`, { server_ids: ids });
},
// SFTP 文件浏览
listFiles: async (serverId: number, path: string) => {
return http.get(`/servers/${serverId}/files?path=${encodeURIComponent(path)}`);
},
downloadFile: async (serverId: number, filePath: string) => {
return http.download(`/servers/${serverId}/files/download?path=${encodeURIComponent(filePath)}`);
},
uploadFile: async (serverId: number, dirPath: string, file: File) => {
const formData = new FormData();
formData.append('file', file);
return http.upload(`/servers/${serverId}/files/upload?path=${encodeURIComponent(dirPath)}`, formData);
},
deleteFile: async (serverId: number, filePath: string) => {
return http.delete(`/servers/${serverId}/files?path=${encodeURIComponent(filePath)}`);
},
mkdir: async (serverId: number, dirPath: string) => {
return http.post(`/servers/${serverId}/files/mkdir`, { path: dirPath });
},
};
// ==================== Kubernetes 集群 API ====================
export const regionApi = {
list: async () => {
return http.get<ListResponse>('/regions');
},
create: async ( data: any) => {
return http.post('/regions', data);
},
update: async (id: number | string, data: any) => {
return http.put(`/regions/${id}`, data);
},
delete: async (id: number | string) => {
return http.delete(`/regions/${id}`);
},
// 批量删除 // Logan.Li
batchDelete: async (ids: number[]) => {
return http.delete('/regions/batch-delete', { ids });
},
};
// ==================== 资源模型 API ====================
export const resourceModelApi = {
list: async () => {
return http.get<ListResponse>('/resource-models');
},
create: async ( data: any) => {
return http.post('/resource-models', data);
},
update: async (id: number | string, data: any) => {
return http.put(`/resource-models/${id}`, data);
},
delete: async (id: number | string) => {
return http.delete(`/resource-models/${id}`);
},
};
// ==================== 审计日志 API ====================
+5
View File
@@ -0,0 +1,5 @@
// 服务器域 API 聚合出口 // Logan.Li
export { serverApi } from './server';
export { regionApi } from './region';
export { resourceModelApi } from './resource-model';
+21
View File
@@ -0,0 +1,21 @@
// 区域 API // Logan.Li
import { http, type ListResponse } from '@/utils/request';
export const regionApi = {
list: async () => {
return http.get<ListResponse>('/regions');
},
create: async (data: Record<string, unknown>) => {
return http.post('/regions', data);
},
update: async (id: number | string, data: Record<string, unknown>) => {
return http.put(`/regions/${id}`, data);
},
delete: async (id: number | string) => {
return http.delete(`/regions/${id}`);
},
batchDelete: async (ids: number[]) => {
return http.delete('/regions/batch-delete', { ids });
},
};
+18
View File
@@ -0,0 +1,18 @@
// 资源型号 API // Logan.Li
import { http, type ListResponse } from '@/utils/request';
export const resourceModelApi = {
list: async () => {
return http.get<ListResponse>('/resource-models');
},
create: async (data: Record<string, unknown>) => {
return http.post('/resource-models', data);
},
update: async (id: number | string, data: Record<string, unknown>) => {
return http.put(`/resource-models/${id}`, data);
},
delete: async (id: number | string) => {
return http.delete(`/resource-models/${id}`);
},
};
+58
View File
@@ -0,0 +1,58 @@
// 服务器资产 API // Logan.Li
import { http, type ListResponse } from '@/utils/request';
export const serverApi = {
getServers: async (params?: { page?: number; page_size?: number; keyword?: string; status?: string }) => {
return http.get<ListResponse>('/servers', params);
},
getServerDetail: async (id: number | string) => {
return http.get(`/servers/${id}`);
},
getServerStats: async () => {
return http.get('/servers/stats');
},
createServer: async (data: Record<string, unknown>) => {
return http.post('/servers', data);
},
updateServer: async (id: number | string, data: Record<string, unknown>) => {
return http.put(`/servers/${id}`, data);
},
deleteServer: async (id: number | string) => {
return http.delete(`/servers/${id}`);
},
batchDelete: async (ids: number[]) => {
return http.post('/servers/batch-delete', { ids });
},
batchCreate: async (data: { servers: Record<string, unknown>[]; region_id?: string; auth_type?: string; credential_id?: number; default_port?: number }) => {
return http.post('/servers/batch', data);
},
getMetrics: async (_id: number | string) => {
return http.get('/dashboard/metrics');
},
installAgent: async (id: number | string, token: string, creds?: { username?: string; password?: string; ssh_key?: string; auth_type?: string }) => {
const body: Record<string, unknown> = { token };
if (creds) Object.assign(body, creds);
return http.post<{ installed: boolean; arch: string; output: string }>(`/servers/${id}/install-agent`, body);
},
batchInstallAgent: async (ids: number[], token: string) => {
return http.post<{ total: number; success: number; results: Record<string, unknown>[] }>(`/servers/install-agent?token=${encodeURIComponent(token)}`, { server_ids: ids });
},
listFiles: async (serverId: number, path: string) => {
return http.get(`/servers/${serverId}/files?path=${encodeURIComponent(path)}`);
},
downloadFile: async (serverId: number, filePath: string) => {
return http.download(`/servers/${serverId}/files/download?path=${encodeURIComponent(filePath)}`);
},
uploadFile: async (serverId: number, dirPath: string, file: File) => {
const formData = new FormData();
formData.append('file', file);
return http.upload(`/servers/${serverId}/files/upload?path=${encodeURIComponent(dirPath)}`, formData);
},
deleteFile: async (serverId: number, filePath: string) => {
return http.delete(`/servers/${serverId}/files?path=${encodeURIComponent(filePath)}`);
},
mkdir: async (serverId: number, dirPath: string) => {
return http.post(`/servers/${serverId}/files/mkdir`, { path: dirPath });
},
};
@@ -1,659 +1,40 @@
import { useState, useEffect, useCallback } from 'react'; import { useState } from 'react';
import { import {
Plus, Pencil, Trash2, RefreshCw, Key, Shield, Search,
Plus, Clock, CheckCircle2, XCircle, Zap,
Pencil,
Trash2,
RefreshCw,
Key,
Shield,
Globe,
Search,
Lock,
Eye,
EyeOff,
Copy,
Server,
Container,
Database,
FileText,
Cloud,
Clock,
AlertTriangle,
CheckCircle2,
XCircle,
Zap,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import { credentialApi } from '@/api'; import { useCredentials, useCredentialMutations } from '@/hooks/api';
import type { Credential, SecretType } from './credential-manager/types';
// ────── Types ────── import { TYPE_CONFIG, TYPE_BADGE_CLASS } from './credential-manager/constants';
import { CredDetailDialog } from './credential-manager/CredDetailDialog';
interface Credential { import { CredFormDialog } from './credential-manager/CredFormDialog';
id: number;
name: string;
variable_name: string;
secret_type: SecretType;
summary: string;
tags: string[];
description: string;
expires_at: string | null;
is_active: boolean;
env_prefix: string;
created_at: string;
updated_at: string;
}
type SecretType = 'ssh' | 'docker' | 'tls' | 'aksk' | 'database' | 'api' | 'generic';
const TYPE_CONFIG: Record<SecretType, { label: string; icon: typeof Key; color: string; desc: string }> = {
ssh: { label: 'SSH 密钥', icon: Server, color: 'text-amber-400', desc: 'SSH 私钥/公钥/密码' },
docker: { label: 'Docker 凭证', icon: Container, color: 'text-cyan-400', desc: 'Docker Registry 登录凭据' },
tls: { label: 'TLS 证书', icon: Shield, color: 'text-green-400', desc: 'TLS/SSL 证书与私钥' },
aksk: { label: 'AKSK 密钥', icon: Cloud, color: 'text-purple-400', desc: '云服务 AccessKey/SecretKey' },
database: { label: '数据库凭证', icon: Database, color: 'text-blue-400', desc: '数据库连接信息' },
api: { label: 'API 密钥', icon: Globe, color: 'text-orange-400', desc: 'API Token/Key + 端点' },
generic: { label: '通用凭证', icon: FileText, color: 'text-gray-400', desc: '自定义 Key/Value 键值对' },
};
const TYPE_BADGE_CLASS: Record<SecretType, string> = {
ssh: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
docker: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
tls: 'bg-green-500/20 text-green-400 border-green-500/30',
aksk: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
database: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
api: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
generic: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
};
// ────── Detail Dialog ──────
function CredDetailDialog({ open, onClose, credId }: { open: boolean; onClose: () => void; credId: number | null }) {
const [detail, setDetail] = useState<any>(null);
const [loading, setLoading] = useState(false);
const [showMap, setShowMap] = useState<Record<string, boolean>>({});
useEffect(() => {
if (!credId || !open) return;
setLoading(true);
credentialApi.get(credId).then(( res: any) => {
setDetail(res);
}).catch(() => toast.error('加载详情失败')).finally(() => setLoading(false));
}, [credId, open]);
if (!open || !credId) return null;
const toggleShow = (key: string) => setShowMap(prev => ({ ...prev, [key]: !prev[key] }));
const copyValue = (val: string) => { navigator.clipboard.writeText(val); toast.success('已复制'); };
const st = detail?.secret_type as SecretType;
const cfg = TYPE_CONFIG[st];
const content = detail?.content || {};
const Field = ({ label, value, sensitive, fieldKey }: { label: string; value: any; sensitive?: boolean; fieldKey?: string }) => {
if (value === undefined || value === null || value === '') return null;
const isLong = typeof value === 'string' && value.length > 80;
const show = fieldKey ? showMap[fieldKey] : false;
return (
<div className="mb-3">
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)] mb-1">
<Lock className="h-3 w-3" />{label}
</div>
<div className="flex gap-1">
{isLong ? (
<textarea readOnly value={sensitive && !show ? '••••••••' : value}
className="flex-1 rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] p-2 text-xs font-mono resize-none"
rows={4} />
) : (
<Input readOnly value={sensitive && !show ? '••••••••' : String(value)}
className="flex-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono text-xs" />
)}
<div className="flex flex-col gap-0.5 shrink-0">
{sensitive && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => toggleShow(fieldKey!)}>
{show ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => copyValue(String(value))}>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
</div>
);
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[680px] max-h-[85vh] overflow-y-auto bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
{cfg ? <cfg.icon className={`h-5 w-5 ${cfg.color}`} /> : <Key className="h-5 w-5 text-[var(--dg-text-muted)]" />}
凭据详情
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="text-center py-8 text-[var(--dg-text-muted)]">加载中...</div>
) : detail ? (
<div className="space-y-4">
{/* Basic info */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div><span className="text-[var(--dg-text-muted)]">名称:</span><span className="text-[var(--dg-text-primary)] font-medium">{detail.name}</span></div>
<div><span className="text-[var(--dg-text-muted)]">类型:</span>
<Badge variant="outline" className={`text-xs ml-1 ${TYPE_BADGE_CLASS[st] || ''}`}>{cfg?.label || st}</Badge>
</div>
<div><span className="text-[var(--dg-text-muted)]">状态:</span>
{detail.is_active
? <Badge className="bg-green-500/20 text-green-400 text-xs ml-1"><CheckCircle2 className="h-3 w-3 mr-0.5" />启用</Badge>
: <Badge className="bg-red-500/20 text-red-400 text-xs ml-1"><XCircle className="h-3 w-3 mr-0.5" />禁用</Badge>}
{detail.expires_at && new Date(detail.expires_at) < new Date() && (
<Badge className="bg-yellow-500/20 text-yellow-400 text-xs ml-1"><AlertTriangle className="h-3 w-3 mr-0.5" />已过期</Badge>
)}
</div>
<div className="col-span-2 cursor-pointer" onClick={() => copyValue(detail.env_prefix || '')}>
<span className="text-[var(--dg-text-muted)]">引用ID:</span>
<code className="text-[var(--dg-blue)] font-mono text-xs">{detail.env_prefix || '无'}</code>
<Copy className="h-3 w-3 inline ml-1 text-[var(--dg-text-muted)]" />
</div>
{detail.tags?.length > 0 && (
<div className="col-span-2">
<span className="text-[var(--dg-text-muted)]">标签:</span>
{detail.tags.map((t: string) => (
<Badge key={t} variant="outline" className="text-xs ml-1 border-[var(--dg-border)]">{t}</Badge>
))}
</div>
)}
<div className="col-span-2"><span className="text-[var(--dg-text-muted)]">描述:</span><span className="text-[var(--dg-text-secondary)]">{detail.description || '无'}</span></div>
<div><span className="text-[var(--dg-text-muted)]">创建:</span><span className="text-[var(--dg-text-secondary)] text-xs">{new Date(detail.created_at).toLocaleString()}</span></div>
<div><span className="text-[var(--dg-text-muted)]">更新:</span><span className="text-[var(--dg-text-secondary)] text-xs">{new Date(detail.updated_at).toLocaleString()}</span></div>
</div>
{/* Secret data */}
<div className="border-t border-[var(--dg-border)] pt-3">
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-primary)] font-medium mb-3">
<Key className="h-4 w-4 text-yellow-500" />密钥数据
</div>
<div className="p-3 rounded-md bg-yellow-500/5 border border-yellow-500/20 mb-3">
<p className="text-xs text-yellow-400/80">以下内容包含敏感信息,请妥善保管,不要泄露给他人。</p>
</div>
{st === 'ssh' && <>
<Field label="SSH 用户" value={content.ssh_user} />
<Field label="SSH 端口" value={content.ssh_port} />
<Field label="SSH 密码" value={content.password} sensitive fieldKey="ssh_password" />
<Field label="私钥" value={content.private_key} sensitive fieldKey="ssh_private_key" />
<Field label="公钥" value={content.public_key} fieldKey="ssh_public_key" />
</>}
{st === 'docker' && <>
<Field label="Registry 地址" value={content.server} />
<Field label="用户名" value={content.username} />
<Field label="密码" value={content.password} sensitive fieldKey="docker_password" />
</>}
{st === 'tls' && <>
<Field label="证书" value={content.cert} sensitive fieldKey="tls_cert" />
<Field label="私钥" value={content.key} sensitive fieldKey="tls_key" />
<Field label="CA 证书" value={content.ca} sensitive fieldKey="tls_ca" />
</>}
{st === 'aksk' && <>
<Field label="Access Key ID" value={content.access_key} />
<Field label="Secret Access Key" value={content.secret_key} sensitive fieldKey="aksk_secret_key" />
</>}
{st === 'database' && <>
<Field label="数据库类型" value={(content.db_type || '').toUpperCase()} />
<Field label="主机" value={content.host} />
<Field label="端口" value={content.port} />
<Field label="数据库名" value={content.db_name} />
<Field label="用户名" value={content.username} />
<Field label="密码" value={content.password} sensitive fieldKey="db_password" />
</>}
{st === 'api' && <>
<Field label="API Key" value={content.api_key} sensitive fieldKey="api_key" />
<Field label="API 端点" value={content.endpoint} />
</>}
{st === 'generic' && <>
<Field label="Key" value={content.generic_key} />
<Field label="Value" value={content.generic_value} sensitive fieldKey="generic_value" />
</>}
</div>
{/* Usage hint */}
<div className="border-t border-[var(--dg-border)] pt-3">
<div className="text-xs text-[var(--dg-text-muted)] mb-2">在流水线/脚本中引用此凭据:</div>
<code className="text-xs bg-[var(--dg-bg-tertiary)] px-3 py-1.5 rounded block text-[var(--dg-blue)]">
{'${'}{detail.variable_name || detail.name.toUpperCase().replace(/[^A-Z0-9]/g, '_')}{'}'}
</code>
</div>
</div>
) : (
<div className="text-center py-8 text-[var(--dg-text-muted)]">未找到凭据数据</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>关闭</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ────── Form Dialog ──────
function CredFormDialog({ open, onClose, onSaved, editData }: {
open: boolean; onClose: () => void; onSaved: () => void; editData: Credential | null;
}) {
const isEdit = !!editData;
const [name, setName] = useState('');
const [varName, setVarName] = useState('');
const [secretType, setSecretType] = useState<SecretType>('ssh');
const [description, setDescription] = useState('');
const [tags, setTags] = useState('');
const [isActive, setIsActive] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [showMap, setShowMap] = useState<Record<string, boolean>>({});
// Type-specific fields
// SSH
const [sshUser, setSshUser] = useState('root');
const [sshPort, setSshPort] = useState('22');
const [sshPassword, setSshPassword] = useState('');
const [sshPrivateKey, setSshPrivateKey] = useState('');
const [sshPublicKey, setSshPublicKey] = useState('');
// Docker
const [dockerServer, setDockerServer] = useState('');
const [dockerUsername, setDockerUsername] = useState('');
const [dockerPassword, setDockerPassword] = useState('');
// TLS
const [tlsCert, setTlsCert] = useState('');
const [tlsKey, setTlsKey] = useState('');
const [tlsCa, setTlsCa] = useState('');
// AKSK
const [, setAkskProvider] = useState('');
const [akskAccessKey, setAkskAccessKey] = useState('');
const [akskSecretKey, setAkskSecretKey] = useState('');
const [, setAkskRegion] = useState('');
// Database
const [dbType, setDbType] = useState('mysql');
const [dbHost, setDbHost] = useState('');
const [dbPort, setDbPort] = useState('3306');
const [dbName, setDbName] = useState('');
const [dbUsername, setDbUsername] = useState('');
const [dbPassword, setDbPassword] = useState('');
// API
const [apiKey, setApiKey] = useState('');
const [apiEndpoint, setApiEndpoint] = useState('');
// Generic
const [genericKey, setGenericKey] = useState('');
const [genericValue, setGenericValue] = useState('');
useEffect(() => {
if (!open) return;
if (editData) {
setName(editData.name);
setVarName(editData.variable_name || '');
setSecretType(editData.secret_type);
setDescription(editData.description || '');
setTags((editData.tags || []).join(', '));
setIsActive(editData.is_active);
// Don't prefill type-specific fields — user can edit selectively
setSshUser(''); setSshPort('22'); setSshPassword(''); setSshPrivateKey(''); setSshPublicKey('');
setDockerServer(''); setDockerUsername(''); setDockerPassword('');
setTlsCert(''); setTlsKey(''); setTlsCa('');
setAkskProvider(''); setAkskAccessKey(''); setAkskSecretKey(''); setAkskRegion('');
setDbType('mysql'); setDbHost(''); setDbPort('3306'); setDbName(''); setDbUsername(''); setDbPassword('');
setApiKey(''); setApiEndpoint('');
setGenericKey(''); setGenericValue('');
} else {
setName(''); setVarName(''); setSecretType('ssh'); setDescription(''); setTags(''); setIsActive(true);
setSshUser('root'); setSshPort('22'); setSshPassword(''); setSshPrivateKey(''); setSshPublicKey('');
setDockerServer(''); setDockerUsername(''); setDockerPassword('');
setTlsCert(''); setTlsKey(''); setTlsCa('');
setAkskProvider(''); setAkskAccessKey(''); setAkskSecretKey(''); setAkskRegion('');
setDbType('mysql'); setDbHost(''); setDbPort('3306'); setDbName(''); setDbUsername(''); setDbPassword('');
setApiKey(''); setApiEndpoint('');
setGenericKey(''); setGenericValue('');
}
setShowMap({});
}, [editData, open]);
const toggleShow = (key: string) => setShowMap(prev => ({ ...prev, [key]: !prev[key] }));
const handleSubmit = async () => {
if (!name.trim()) { toast.error('请填写凭据名称'); return; }
setSubmitting(true);
try {
const payload: Record<string, any> = {
name: name.trim(),
secret_type: secretType,
description: description.trim(),
tags: tags.split(',').map(t => t.trim()).filter(Boolean),
is_active: isActive,
};
if (varName.trim()) payload.variable_name = varName.trim();
// Type-specific
if (secretType === 'ssh') {
if (sshUser.trim()) payload.ssh_user = sshUser.trim();
if (sshPort) payload.ssh_port = parseInt(sshPort) || 22;
if (sshPassword.trim()) payload.ssh_password = sshPassword.trim();
if (sshPrivateKey.trim()) payload.ssh_private_key = sshPrivateKey.trim();
if (sshPublicKey.trim()) payload.ssh_public_key = sshPublicKey.trim();
} else if (secretType === 'docker') {
if (dockerServer.trim()) payload.docker_server = dockerServer.trim();
if (dockerUsername.trim()) payload.docker_username = dockerUsername.trim();
if (dockerPassword.trim()) payload.docker_password = dockerPassword.trim();
} else if (secretType === 'tls') {
if (tlsCert.trim()) payload.tls_cert = tlsCert.trim();
if (tlsKey.trim()) payload.tls_key = tlsKey.trim();
if (tlsCa.trim()) payload.tls_ca = tlsCa.trim();
} else if (secretType === 'aksk') {
if (akskAccessKey.trim()) payload.aksk_access_key = akskAccessKey.trim();
if (akskSecretKey.trim()) payload.aksk_secret_key = akskSecretKey.trim();
} else if (secretType === 'database') {
if (dbType.trim()) payload.db_type = dbType.trim();
if (dbHost.trim()) payload.db_host = dbHost.trim();
if (dbPort) payload.db_port = parseInt(dbPort) || 3306;
if (dbName.trim()) payload.db_name = dbName.trim();
if (dbUsername.trim()) payload.db_username = dbUsername.trim();
if (dbPassword.trim()) payload.db_password = dbPassword.trim();
} else if (secretType === 'api') {
if (apiKey.trim()) payload.api_key = apiKey.trim();
if (apiEndpoint.trim()) payload.api_endpoint = apiEndpoint.trim();
} else if (secretType === 'generic') {
if (genericKey.trim()) payload.generic_key = genericKey.trim();
if (genericValue.trim()) payload.generic_value = genericValue.trim();
}
if (isEdit) {
await credentialApi.update(editData!.id, payload);
toast.success('已更新');
} else {
await credentialApi.create(payload);
toast.success('已创建');
}
onSaved();
onClose();
} catch (e: any) {
toast.error(e?.response?.data?.message || '操作失败');
} finally { setSubmitting(false); }
};
const FieldWithToggle = ({ label, value, onChange, placeholder, fieldKey, required, isLong }: {
label: string; value: string; onChange: (v: string) => void; placeholder: string;
fieldKey: string; required?: boolean; isLong?: boolean;
}) => {
const show = showMap[fieldKey];
return (
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">{label}{required && !isEdit && <span className="text-red-400"> *</span>}</Label>
<div className="relative">
{isLong ? (
<textarea value={value} onChange={e => onChange(e.target.value)}
placeholder={placeholder} rows={5}
className="w-full rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] p-2 text-sm font-mono placeholder:text-[var(--dg-text-muted)]" />
) : (
<Input value={value} onChange={e => onChange(e.target.value)}
type={show ? 'text' : 'password'} placeholder={placeholder}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] pr-10 font-mono text-sm" />
)}
<button type="button" onClick={() => toggleShow(fieldKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] text-xs">
{show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
</div>
);
};
const TI = TYPE_CONFIG[secretType];
const TIicon = TI?.icon || Key;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[620px] max-h-[90vh] overflow-y-auto bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<TIicon className={`h-5 w-5 ${TI?.color || ''}`} />
{isEdit ? '编辑凭据' : '新增凭据'}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)] text-xs">
{TI?.desc || '管理敏感凭据信息'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Basic */}
<div className="border-b border-[var(--dg-border)] pb-3">
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2">基本信息</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">凭据名称 <span className="text-red-400">*</span></Label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="如:生产环境 SSH"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">变量别名</Label>
<Input value={varName} onChange={e => setVarName(e.target.value)} placeholder="如:MYSQL_PROD"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono text-sm" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">类型{!isEdit && <span className="text-red-400"> *</span>}</Label>
<Select value={secretType} onValueChange={(v) => setSecretType(v as SecretType)} disabled={isEdit}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{(Object.entries(TYPE_CONFIG) as [SecretType, typeof TYPE_CONFIG[SecretType]][]).map(([k, v]) => (
<SelectItem key={k} value={k}>
<span className="flex items-center gap-2"><v.icon className="h-4 w-4" />{v.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">标签</Label>
<Input value={tags} onChange={e => setTags(e.target.value)} placeholder="生产环境, 阿里云"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
</div>
<div className="mt-2 space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">描述</Label>
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder="可选备注信息"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
</div>
{/* Type-specific fields */}
<div>
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2 flex items-center gap-2">
<TIicon className="h-3.5 w-3.5" />{TI?.label || secretType} 配置
{isEdit && <span className="text-[var(--dg-text-muted)]">(仅填写需要修改的字段)</span>}
</div>
{secretType === 'ssh' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">SSH 用户</Label>
<Input value={sshUser} onChange={e => setSshUser(e.target.value)} placeholder="root"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">SSH 端口</Label>
<Input value={sshPort} onChange={e => setSshPort(e.target.value)} placeholder="22" type="number"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="col-span-2">
<FieldWithToggle label="SSH 密码" value={sshPassword} onChange={setSshPassword}
placeholder="密码(或留空用密钥)" fieldKey="ssh_pwd" />
</div>
<div className="col-span-2">
<FieldWithToggle label="私钥内容" value={sshPrivateKey} onChange={setSshPrivateKey}
placeholder="粘贴 SSH 私钥..." fieldKey="ssh_pk" isLong />
</div>
<div className="col-span-2">
<FieldWithToggle label="公钥内容" value={sshPublicKey} onChange={setSshPublicKey}
placeholder="粘贴 SSH 公钥..." fieldKey="ssh_pub" isLong />
</div>
</div>
)}
{secretType === 'docker' && (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">Registry 地址 <span className="text-red-400">*</span></Label>
<Input value={dockerServer} onChange={e => setDockerServer(e.target.value)} placeholder="registry.example.com"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">用户名</Label>
<Input value={dockerUsername} onChange={e => setDockerUsername(e.target.value)} placeholder="admin"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div><FieldWithToggle label="密码" value={dockerPassword} onChange={setDockerPassword}
placeholder="Registry 密码" fieldKey="docker_pwd" required /></div>
</div>
)}
{secretType === 'tls' && (
<div className="space-y-3">
<FieldWithToggle label="证书 (PEM)" value={tlsCert} onChange={setTlsCert}
placeholder="-----BEGIN CERTIFICATE-----" fieldKey="tls_cert" isLong required />
<FieldWithToggle label="私钥 (PEM)" value={tlsKey} onChange={setTlsKey}
placeholder="-----BEGIN PRIVATE KEY-----" fieldKey="tls_key" isLong required />
<FieldWithToggle label="CA 证书" value={tlsCa} onChange={setTlsCa}
placeholder="CA 证书内容..." fieldKey="tls_ca" isLong />
</div>
)}
{secretType === 'aksk' && (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">Access Key ID <span className="text-red-400">*</span></Label>
<Input value={akskAccessKey} onChange={e => setAkskAccessKey(e.target.value)} placeholder="AKIDxxxx..."
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<div className="col-span-2">
<FieldWithToggle label="Secret Access Key" value={akskSecretKey} onChange={setAkskSecretKey}
placeholder="输入 Secret Key" fieldKey="aksk_sk" required />
</div>
</div>
)}
{secretType === 'database' && (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">数据库类型</Label>
<Select value={dbType} onValueChange={setDbType}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mysql">MySQL</SelectItem>
<SelectItem value="postgres">PostgreSQL</SelectItem>
<SelectItem value="mongodb">MongoDB</SelectItem>
<SelectItem value="redis">Redis</SelectItem>
<SelectItem value="mssql">SQL Server</SelectItem>
<SelectItem value="oracle">Oracle</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">主机 <span className="text-red-400">*</span></Label>
<Input value={dbHost} onChange={e => setDbHost(e.target.value)} placeholder="10.0.0.1"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">端口</Label>
<Input value={dbPort} onChange={e => setDbPort(e.target.value)} placeholder="3306" type="number"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">数据库名</Label>
<Input value={dbName} onChange={e => setDbName(e.target.value)} placeholder="aiops"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">用户名</Label>
<Input value={dbUsername} onChange={e => setDbUsername(e.target.value)} placeholder="root"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div><FieldWithToggle label="密码" value={dbPassword} onChange={setDbPassword}
placeholder="数据库密码" fieldKey="db_pwd" /></div>
</div>
)}
{secretType === 'api' && (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">API 端点</Label>
<Input value={apiEndpoint} onChange={e => setApiEndpoint(e.target.value)} placeholder="https://api.example.com"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<FieldWithToggle label="API Key / Token" value={apiKey} onChange={setApiKey}
placeholder="sk-xxxx..." fieldKey="api_key" required />
</div>
)}
{secretType === 'generic' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">Key <span className="text-red-400">*</span></Label>
<Input value={genericKey} onChange={e => setGenericKey(e.target.value)} placeholder="MY_SECRET"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<FieldWithToggle label="Value" value={genericValue} onChange={setGenericValue}
placeholder="密钥值" fieldKey="generic_val" required />
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>取消</Button>
<Button onClick={handleSubmit} disabled={submitting} className="bg-[var(--dg-blue)] hover:bg-[var(--dg-blue)]/80">
{submitting ? '保存中...' : (isEdit ? '更新' : '创建')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
// ────── Main Manager ──────
// 凭据管理页编排器 // Logan.Li
export default function CredentialManager() { export default function CredentialManager() {
const confirm = useConfirm(); const confirm = useConfirm();
const [credentials, setCredentials] = useState<Credential[]>([]);
const [loading, setLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Credential | null>(null); const [editing, setEditing] = useState<Credential | null>(null);
const [detailId, setDetailId] = useState<number | null>(null); const [detailId, setDetailId] = useState<number | null>(null);
const [detailOpen, setDetailOpen] = useState(false); const [detailOpen, setDetailOpen] = useState(false);
// Filters // 筛选条件 // Logan.Li
const [filterType, setFilterType] = useState<string>(''); const [filterType, setFilterType] = useState('all');
const [filterSearch, setFilterSearch] = useState(''); const [filterSearch, setFilterSearch] = useState('');
const fetchAll = useCallback(async () => { const listParams = {
setLoading(true); page_size: 100,
try { ...(filterSearch ? { search: filterSearch } : {}),
const res: Record<string, any> = await credentialApi.list({ ...(filterType && filterType !== 'all' ? { secret_type: filterType } : {}),
page_size: 100, };
...(filterType ? { secret_type: filterType } : {}),
...(filterSearch ? { search: filterSearch } : {}),
});
setCredentials(res?.list || []);
} catch { toast.error('获取凭证列表失败'); }
finally { setLoading(false); }
}, [filterType, filterSearch]);
useEffect(() => { fetchAll(); }, [fetchAll]); const { data: credentials = [], isLoading: loading, refetch } = useCredentials(listParams);
const { deleteMutation, toggleActiveMutation } = useCredentialMutations();
const handleDelete = async (cred: Credential) => { const handleDelete = async (cred: Credential) => {
const ok = await confirm({ const ok = await confirm({
@@ -664,17 +45,15 @@ export default function CredentialManager() {
}); });
if (!ok) return; if (!ok) return;
try { try {
await credentialApi.delete(cred.id); await deleteMutation.mutateAsync(cred.id);
toast.success('已删除'); toast.success('已删除');
fetchAll();
} catch { toast.error('删除失败'); } } catch { toast.error('删除失败'); }
}; };
const handleToggleActive = async (cred: Credential) => { const handleToggleActive = async (cred: Credential) => {
try { try {
await credentialApi.toggleActive(cred.id, !cred.is_active); await toggleActiveMutation.mutateAsync({ id: cred.id, isActive: !cred.is_active });
toast.success(cred.is_active ? '已禁用' : '已启用'); toast.success(cred.is_active ? '已禁用' : '已启用');
fetchAll();
} catch { toast.error('操作失败'); } } catch { toast.error('操作失败'); }
}; };
@@ -695,7 +74,7 @@ export default function CredentialManager() {
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchAll}> <Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4 mr-1" />刷新 <RefreshCw className="w-4 h-4 mr-1" />刷新
</Button> </Button>
<Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}> <Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}>
@@ -704,7 +83,7 @@ export default function CredentialManager() {
</div> </div>
</div> </div>
{/* Search/Filter */} {/* 搜索与筛选 // Logan.Li */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<Select value={filterType} onValueChange={setFilterType}> <Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="w-[140px] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] h-9 text-sm"> <SelectTrigger className="w-[140px] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] h-9 text-sm">
@@ -814,16 +193,16 @@ export default function CredentialManager() {
<td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}> <td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-end gap-1"> <div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7" <Button variant="ghost" size="icon" className="h-7 w-7"
onClick={() => { setEditing(cred); setDialogOpen(true); }} title="编辑"> onClick={() => { setEditing(cred as Credential); setDialogOpen(true); }} title="编辑">
<Pencil className="w-3.5 h-3.5" /> <Pencil className="w-3.5 h-3.5" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7" <Button variant="ghost" size="icon" className="h-7 w-7"
onClick={() => handleToggleActive(cred)} onClick={() => handleToggleActive(cred as Credential)}
title={cred.is_active ? '禁用' : '启用'}> title={cred.is_active ? '禁用' : '启用'}>
{cred.is_active ? <XCircle className="w-3.5 h-3.5 text-yellow-500" /> : <CheckCircle2 className="w-3.5 h-3.5 text-green-500" />} {cred.is_active ? <XCircle className="w-3.5 h-3.5 text-yellow-500" /> : <CheckCircle2 className="w-3.5 h-3.5 text-green-500" />}
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" <Button variant="ghost" size="icon" className="h-7 w-7 text-red-500"
onClick={() => handleDelete(cred)} title="删除"> onClick={() => handleDelete(cred as Credential)} title="删除">
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
</Button> </Button>
</div> </div>
@@ -836,7 +215,7 @@ export default function CredentialManager() {
</div> </div>
)} )}
<CredFormDialog open={dialogOpen} onClose={() => setDialogOpen(false)} onSaved={fetchAll} editData={editing} /> <CredFormDialog open={dialogOpen} onClose={() => setDialogOpen(false)} onSaved={() => refetch()} editData={editing} />
<CredDetailDialog open={detailOpen} onClose={() => setDetailOpen(false)} credId={detailId} /> <CredDetailDialog open={detailOpen} onClose={() => setDetailOpen(false)} credId={detailId} />
</div> </div>
); );
@@ -0,0 +1,180 @@
import { useState } from 'react';
import {
Key, Lock, Eye, EyeOff, Copy,
CheckCircle2, XCircle, AlertTriangle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { useCredentialDetail } from '@/hooks/api';
import type { SecretType } from './types';
import { TYPE_CONFIG, TYPE_BADGE_CLASS } from './constants';
export interface CredDetailDialogProps {
open: boolean;
onClose: () => void;
credId: number | null;
}
// 凭据详情弹窗 // Logan.Li
export function CredDetailDialog({ open, onClose, credId }: CredDetailDialogProps) {
const [showMap, setShowMap] = useState<Record<string, boolean>>({});
const { data: detail, isLoading: loading } = useCredentialDetail(credId, open);
if (!open || !credId) return null;
const toggleShow = (key: string) => setShowMap(prev => ({ ...prev, [key]: !prev[key] }));
const copyValue = (val: string) => { navigator.clipboard.writeText(val); toast.success('已复制'); };
const st = detail?.secret_type as SecretType;
const cfg = TYPE_CONFIG[st];
const content = (detail as Record<string, unknown>)?.content as Record<string, unknown> || {};
const Field = ({ label, value, sensitive, fieldKey }: { label: string; value: unknown; sensitive?: boolean; fieldKey?: string }) => {
if (value === undefined || value === null || value === '') return null;
const isLong = typeof value === 'string' && value.length > 80;
const show = fieldKey ? showMap[fieldKey] : false;
return (
<div className="mb-3">
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)] mb-1">
<Lock className="h-3 w-3" />{label}
</div>
<div className="flex gap-1">
{isLong ? (
<textarea readOnly value={sensitive && !show ? '••••••••' : String(value)}
className="flex-1 rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] p-2 text-xs font-mono resize-none"
rows={4} />
) : (
<Input readOnly value={sensitive && !show ? '••••••••' : String(value)}
className="flex-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono text-xs" />
)}
<div className="flex flex-col gap-0.5 shrink-0">
{sensitive && (
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => toggleShow(fieldKey!)}>
{show ? <EyeOff className="h-3 w-3" /> : <Eye className="h-3 w-3" />}
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => copyValue(String(value))}>
<Copy className="h-3 w-3" />
</Button>
</div>
</div>
</div>
);
};
const detailRecord = detail as Record<string, unknown> | undefined;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[680px] max-h-[85vh] overflow-y-auto bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
{cfg ? <cfg.icon className={`h-5 w-5 ${cfg.color}`} /> : <Key className="h-5 w-5 text-[var(--dg-text-muted)]" />}
凭据详情
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="text-center py-8 text-[var(--dg-text-muted)]">加载中...</div>
) : detail ? (
<div className="space-y-4">
{/* 基本信息 // Logan.Li */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div><span className="text-[var(--dg-text-muted)]">名称:</span><span className="text-[var(--dg-text-primary)] font-medium">{detailRecord?.name as string}</span></div>
<div><span className="text-[var(--dg-text-muted)]">类型:</span>
<Badge variant="outline" className={`text-xs ml-1 ${TYPE_BADGE_CLASS[st] || ''}`}>{cfg?.label || st}</Badge>
</div>
<div><span className="text-[var(--dg-text-muted)]">状态:</span>
{detailRecord?.is_active
? <Badge className="bg-green-500/20 text-green-400 text-xs ml-1"><CheckCircle2 className="h-3 w-3 mr-0.5" />启用</Badge>
: <Badge className="bg-red-500/20 text-red-400 text-xs ml-1"><XCircle className="h-3 w-3 mr-0.5" />禁用</Badge>}
{detailRecord?.expires_at && new Date(detailRecord.expires_at as string) < new Date() && (
<Badge className="bg-yellow-500/20 text-yellow-400 text-xs ml-1"><AlertTriangle className="h-3 w-3 mr-0.5" />已过期</Badge>
)}
</div>
<div className="col-span-2 cursor-pointer" onClick={() => copyValue((detailRecord?.env_prefix as string) || '')}>
<span className="text-[var(--dg-text-muted)]">引用ID:</span>
<code className="text-[var(--dg-blue)] font-mono text-xs">{detailRecord?.env_prefix as string || '无'}</code>
<Copy className="h-3 w-3 inline ml-1 text-[var(--dg-text-muted)]" />
</div>
{(detailRecord?.tags as string[])?.length > 0 && (
<div className="col-span-2">
<span className="text-[var(--dg-text-muted)]">标签:</span>
{(detailRecord?.tags as string[]).map((t: string) => (
<Badge key={t} variant="outline" className="text-xs ml-1 border-[var(--dg-border)]">{t}</Badge>
))}
</div>
)}
<div className="col-span-2"><span className="text-[var(--dg-text-muted)]">描述:</span><span className="text-[var(--dg-text-secondary)]">{(detailRecord?.description as string) || '无'}</span></div>
<div><span className="text-[var(--dg-text-muted)]">创建:</span><span className="text-[var(--dg-text-secondary)] text-xs">{new Date(detailRecord?.created_at as string).toLocaleString()}</span></div>
<div><span className="text-[var(--dg-text-muted)]">更新:</span><span className="text-[var(--dg-text-secondary)] text-xs">{new Date(detailRecord?.updated_at as string).toLocaleString()}</span></div>
</div>
{/* 密钥数据 // Logan.Li */}
<div className="border-t border-[var(--dg-border)] pt-3">
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-primary)] font-medium mb-3">
<Key className="h-4 w-4 text-yellow-500" />密钥数据
</div>
<div className="p-3 rounded-md bg-yellow-500/5 border border-yellow-500/20 mb-3">
<p className="text-xs text-yellow-400/80">以下内容包含敏感信息,请妥善保管,不要泄露给他人。</p>
</div>
{st === 'ssh' && <>
<Field label="SSH 用户" value={content.ssh_user} />
<Field label="SSH 端口" value={content.ssh_port} />
<Field label="SSH 密码" value={content.password} sensitive fieldKey="ssh_password" />
<Field label="私钥" value={content.private_key} sensitive fieldKey="ssh_private_key" />
<Field label="公钥" value={content.public_key} fieldKey="ssh_public_key" />
</>}
{st === 'docker' && <>
<Field label="Registry 地址" value={content.server} />
<Field label="用户名" value={content.username} />
<Field label="密码" value={content.password} sensitive fieldKey="docker_password" />
</>}
{st === 'tls' && <>
<Field label="证书" value={content.cert} sensitive fieldKey="tls_cert" />
<Field label="私钥" value={content.key} sensitive fieldKey="tls_key" />
<Field label="CA 证书" value={content.ca} sensitive fieldKey="tls_ca" />
</>}
{st === 'aksk' && <>
<Field label="Access Key ID" value={content.access_key} />
<Field label="Secret Access Key" value={content.secret_key} sensitive fieldKey="aksk_secret_key" />
</>}
{st === 'database' && <>
<Field label="数据库类型" value={String(content.db_type || '').toUpperCase()} />
<Field label="主机" value={content.host} />
<Field label="端口" value={content.port} />
<Field label="数据库名" value={content.db_name} />
<Field label="用户名" value={content.username} />
<Field label="密码" value={content.password} sensitive fieldKey="db_password" />
</>}
{st === 'api' && <>
<Field label="API Key" value={content.api_key} sensitive fieldKey="api_key" />
<Field label="API 端点" value={content.endpoint} />
</>}
{st === 'generic' && <>
<Field label="Key" value={content.generic_key} />
<Field label="Value" value={content.generic_value} sensitive fieldKey="generic_value" />
</>}
</div>
{/* 引用提示 // Logan.Li */}
<div className="border-t border-[var(--dg-border)] pt-3">
<div className="text-xs text-[var(--dg-text-muted)] mb-2">在流水线/脚本中引用此凭据:</div>
<code className="text-xs bg-[var(--dg-bg-tertiary)] px-3 py-1.5 rounded block text-[var(--dg-blue)]">
{'${'}{String(detailRecord?.variable_name || String(detailRecord?.name || '').toUpperCase().replace(/[^A-Z0-9]/g, '_'))}{'}'}
</code>
</div>
</div>
) : (
<div className="text-center py-8 text-[var(--dg-text-muted)]">未找到凭据数据</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>关闭</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,395 @@
import { useState, useEffect } from 'react';
import { Key, Eye, EyeOff } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { toast } from 'sonner';
import { credentialApi } from '@/api';
import type { Credential, SecretType } from './types';
import { TYPE_CONFIG } from './constants';
export interface CredFormDialogProps {
open: boolean;
onClose: () => void;
onSaved: () => void;
editData: Credential | null;
}
// 凭据新增/编辑弹窗 // Logan.Li
export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDialogProps) {
const isEdit = !!editData;
const [name, setName] = useState('');
const [varName, setVarName] = useState('');
const [secretType, setSecretType] = useState<SecretType>('ssh');
const [description, setDescription] = useState('');
const [tags, setTags] = useState('');
const [isActive, setIsActive] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [showMap, setShowMap] = useState<Record<string, boolean>>({});
// SSH 字段 // Logan.Li
const [sshUser, setSshUser] = useState('root');
const [sshPort, setSshPort] = useState('22');
const [sshPassword, setSshPassword] = useState('');
const [sshPrivateKey, setSshPrivateKey] = useState('');
const [sshPublicKey, setSshPublicKey] = useState('');
// Docker 字段
const [dockerServer, setDockerServer] = useState('');
const [dockerUsername, setDockerUsername] = useState('');
const [dockerPassword, setDockerPassword] = useState('');
// TLS 字段
const [tlsCert, setTlsCert] = useState('');
const [tlsKey, setTlsKey] = useState('');
const [tlsCa, setTlsCa] = useState('');
// AKSK 字段
const [, setAkskProvider] = useState('');
const [akskAccessKey, setAkskAccessKey] = useState('');
const [akskSecretKey, setAkskSecretKey] = useState('');
const [, setAkskRegion] = useState('');
// 数据库字段
const [dbType, setDbType] = useState('mysql');
const [dbHost, setDbHost] = useState('');
const [dbPort, setDbPort] = useState('3306');
const [dbName, setDbName] = useState('');
const [dbUsername, setDbUsername] = useState('');
const [dbPassword, setDbPassword] = useState('');
// API 字段
const [apiKey, setApiKey] = useState('');
const [apiEndpoint, setApiEndpoint] = useState('');
// 通用字段
const [genericKey, setGenericKey] = useState('');
const [genericValue, setGenericValue] = useState('');
useEffect(() => {
if (!open) return;
if (editData) {
setName(editData.name);
setVarName(editData.variable_name || '');
setSecretType(editData.secret_type);
setDescription(editData.description || '');
setTags((editData.tags || []).join(', '));
setIsActive(editData.is_active);
// 编辑时不预填类型字段,仅修改用户填写的项 // Logan.Li
setSshUser(''); setSshPort('22'); setSshPassword(''); setSshPrivateKey(''); setSshPublicKey('');
setDockerServer(''); setDockerUsername(''); setDockerPassword('');
setTlsCert(''); setTlsKey(''); setTlsCa('');
setAkskProvider(''); setAkskAccessKey(''); setAkskSecretKey(''); setAkskRegion('');
setDbType('mysql'); setDbHost(''); setDbPort('3306'); setDbName(''); setDbUsername(''); setDbPassword('');
setApiKey(''); setApiEndpoint('');
setGenericKey(''); setGenericValue('');
} else {
setName(''); setVarName(''); setSecretType('ssh'); setDescription(''); setTags(''); setIsActive(true);
setSshUser('root'); setSshPort('22'); setSshPassword(''); setSshPrivateKey(''); setSshPublicKey('');
setDockerServer(''); setDockerUsername(''); setDockerPassword('');
setTlsCert(''); setTlsKey(''); setTlsCa('');
setAkskProvider(''); setAkskAccessKey(''); setAkskSecretKey(''); setAkskRegion('');
setDbType('mysql'); setDbHost(''); setDbPort('3306'); setDbName(''); setDbUsername(''); setDbPassword('');
setApiKey(''); setApiEndpoint('');
setGenericKey(''); setGenericValue('');
}
setShowMap({});
}, [editData, open]);
const toggleShow = (key: string) => setShowMap(prev => ({ ...prev, [key]: !prev[key] }));
const handleSubmit = async () => {
if (!name.trim()) { toast.error('请填写凭据名称'); return; }
setSubmitting(true);
try {
const payload: Record<string, unknown> = {
name: name.trim(),
secret_type: secretType,
description: description.trim(),
tags: tags.split(',').map(t => t.trim()).filter(Boolean),
is_active: isActive,
};
if (varName.trim()) payload.variable_name = varName.trim();
if (secretType === 'ssh') {
if (sshUser.trim()) payload.ssh_user = sshUser.trim();
if (sshPort) payload.ssh_port = parseInt(sshPort) || 22;
if (sshPassword.trim()) payload.ssh_password = sshPassword.trim();
if (sshPrivateKey.trim()) payload.ssh_private_key = sshPrivateKey.trim();
if (sshPublicKey.trim()) payload.ssh_public_key = sshPublicKey.trim();
} else if (secretType === 'docker') {
if (dockerServer.trim()) payload.docker_server = dockerServer.trim();
if (dockerUsername.trim()) payload.docker_username = dockerUsername.trim();
if (dockerPassword.trim()) payload.docker_password = dockerPassword.trim();
} else if (secretType === 'tls') {
if (tlsCert.trim()) payload.tls_cert = tlsCert.trim();
if (tlsKey.trim()) payload.tls_key = tlsKey.trim();
if (tlsCa.trim()) payload.tls_ca = tlsCa.trim();
} else if (secretType === 'aksk') {
if (akskAccessKey.trim()) payload.aksk_access_key = akskAccessKey.trim();
if (akskSecretKey.trim()) payload.aksk_secret_key = akskSecretKey.trim();
} else if (secretType === 'database') {
if (dbType.trim()) payload.db_type = dbType.trim();
if (dbHost.trim()) payload.db_host = dbHost.trim();
if (dbPort) payload.db_port = parseInt(dbPort) || 3306;
if (dbName.trim()) payload.db_name = dbName.trim();
if (dbUsername.trim()) payload.db_username = dbUsername.trim();
if (dbPassword.trim()) payload.db_password = dbPassword.trim();
} else if (secretType === 'api') {
if (apiKey.trim()) payload.api_key = apiKey.trim();
if (apiEndpoint.trim()) payload.api_endpoint = apiEndpoint.trim();
} else if (secretType === 'generic') {
if (genericKey.trim()) payload.generic_key = genericKey.trim();
if (genericValue.trim()) payload.generic_value = genericValue.trim();
}
if (isEdit) {
await credentialApi.update(editData!.id, payload);
toast.success('已更新');
} else {
await credentialApi.create(payload);
toast.success('已创建');
}
onSaved();
onClose();
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } };
toast.error(err?.response?.data?.message || '操作失败');
} finally { setSubmitting(false); }
};
const FieldWithToggle = ({ label, value, onChange, placeholder, fieldKey, required, isLong }: {
label: string; value: string; onChange: (v: string) => void; placeholder: string;
fieldKey: string; required?: boolean; isLong?: boolean;
}) => {
const show = showMap[fieldKey];
return (
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">{label}{required && !isEdit && <span className="text-red-400"> *</span>}</Label>
<div className="relative">
{isLong ? (
<textarea value={value} onChange={e => onChange(e.target.value)}
placeholder={placeholder} rows={5}
className="w-full rounded-md bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] text-[var(--dg-text-primary)] p-2 text-sm font-mono placeholder:text-[var(--dg-text-muted)]" />
) : (
<Input value={value} onChange={e => onChange(e.target.value)}
type={show ? 'text' : 'password'} placeholder={placeholder}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] pr-10 font-mono text-sm" />
)}
<button type="button" onClick={() => toggleShow(fieldKey)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] text-xs">
{show ? <EyeOff className="h-3.5 w-3.5" /> : <Eye className="h-3.5 w-3.5" />}
</button>
</div>
</div>
);
};
const TI = TYPE_CONFIG[secretType];
const TIicon = TI?.icon || Key;
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-[620px] max-h-[90vh] overflow-y-auto bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<TIicon className={`h-5 w-5 ${TI?.color || ''}`} />
{isEdit ? '编辑凭据' : '新增凭据'}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)] text-xs">
{TI?.desc || '管理敏感凭据信息'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* 基本信息 // Logan.Li */}
<div className="border-b border-[var(--dg-border)] pb-3">
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2">基本信息</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">凭据名称 <span className="text-red-400">*</span></Label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder="如:生产环境 SSH"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">变量别名</Label>
<Input value={varName} onChange={e => setVarName(e.target.value)} placeholder="如:MYSQL_PROD"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono text-sm" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">类型{!isEdit && <span className="text-red-400"> *</span>}</Label>
<Select value={secretType} onValueChange={(v) => setSecretType(v as SecretType)} disabled={isEdit}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-[300px]">
{(Object.entries(TYPE_CONFIG) as [SecretType, typeof TYPE_CONFIG[SecretType]][]).map(([k, v]) => (
<SelectItem key={k} value={k}>
<span className="flex items-center gap-2"><v.icon className="h-4 w-4" />{v.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">标签</Label>
<Input value={tags} onChange={e => setTags(e.target.value)} placeholder="生产环境, 阿里云"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
</div>
<div className="mt-2 space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">描述</Label>
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder="可选备注信息"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
</div>
{/* 类型专属字段 // Logan.Li */}
<div>
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2 flex items-center gap-2">
<TIicon className="h-3.5 w-3.5" />{TI?.label || secretType} 配置
{isEdit && <span className="text-[var(--dg-text-muted)]">(仅填写需要修改的字段)</span>}
</div>
{secretType === 'ssh' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">SSH 用户</Label>
<Input value={sshUser} onChange={e => setSshUser(e.target.value)} placeholder="root"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">SSH 端口</Label>
<Input value={sshPort} onChange={e => setSshPort(e.target.value)} placeholder="22" type="number"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="col-span-2">
<FieldWithToggle label="SSH 密码" value={sshPassword} onChange={setSshPassword}
placeholder="密码(或留空用密钥)" fieldKey="ssh_pwd" />
</div>
<div className="col-span-2">
<FieldWithToggle label="私钥内容" value={sshPrivateKey} onChange={setSshPrivateKey}
placeholder="粘贴 SSH 私钥..." fieldKey="ssh_pk" isLong />
</div>
<div className="col-span-2">
<FieldWithToggle label="公钥内容" value={sshPublicKey} onChange={setSshPublicKey}
placeholder="粘贴 SSH 公钥..." fieldKey="ssh_pub" isLong />
</div>
</div>
)}
{secretType === 'docker' && (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">Registry 地址 <span className="text-red-400">*</span></Label>
<Input value={dockerServer} onChange={e => setDockerServer(e.target.value)} placeholder="registry.example.com"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">用户名</Label>
<Input value={dockerUsername} onChange={e => setDockerUsername(e.target.value)} placeholder="admin"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div><FieldWithToggle label="密码" value={dockerPassword} onChange={setDockerPassword}
placeholder="Registry 密码" fieldKey="docker_pwd" required /></div>
</div>
)}
{secretType === 'tls' && (
<div className="space-y-3">
<FieldWithToggle label="证书 (PEM)" value={tlsCert} onChange={setTlsCert}
placeholder="-----BEGIN CERTIFICATE-----" fieldKey="tls_cert" isLong required />
<FieldWithToggle label="私钥 (PEM)" value={tlsKey} onChange={setTlsKey}
placeholder="-----BEGIN PRIVATE KEY-----" fieldKey="tls_key" isLong required />
<FieldWithToggle label="CA 证书" value={tlsCa} onChange={setTlsCa}
placeholder="CA 证书内容..." fieldKey="tls_ca" isLong />
</div>
)}
{secretType === 'aksk' && (
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">Access Key ID <span className="text-red-400">*</span></Label>
<Input value={akskAccessKey} onChange={e => setAkskAccessKey(e.target.value)} placeholder="AKIDxxxx..."
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<div className="col-span-2">
<FieldWithToggle label="Secret Access Key" value={akskSecretKey} onChange={setAkskSecretKey}
placeholder="输入 Secret Key" fieldKey="aksk_sk" required />
</div>
</div>
)}
{secretType === 'database' && (
<div className="grid grid-cols-3 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">数据库类型</Label>
<Select value={dbType} onValueChange={setDbType}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="mysql">MySQL</SelectItem>
<SelectItem value="postgres">PostgreSQL</SelectItem>
<SelectItem value="mongodb">MongoDB</SelectItem>
<SelectItem value="redis">Redis</SelectItem>
<SelectItem value="mssql">SQL Server</SelectItem>
<SelectItem value="oracle">Oracle</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">主机 <span className="text-red-400">*</span></Label>
<Input value={dbHost} onChange={e => setDbHost(e.target.value)} placeholder="10.0.0.1"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">端口</Label>
<Input value={dbPort} onChange={e => setDbPort(e.target.value)} placeholder="3306" type="number"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">数据库名</Label>
<Input value={dbName} onChange={e => setDbName(e.target.value)} placeholder="aiops"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">用户名</Label>
<Input value={dbUsername} onChange={e => setDbUsername(e.target.value)} placeholder="root"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
<div><FieldWithToggle label="密码" value={dbPassword} onChange={setDbPassword}
placeholder="数据库密码" fieldKey="db_pwd" /></div>
</div>
)}
{secretType === 'api' && (
<div className="space-y-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">API 端点</Label>
<Input value={apiEndpoint} onChange={e => setApiEndpoint(e.target.value)} placeholder="https://api.example.com"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<FieldWithToggle label="API Key / Token" value={apiKey} onChange={setApiKey}
placeholder="sk-xxxx..." fieldKey="api_key" required />
</div>
)}
{secretType === 'generic' && (
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-[var(--dg-text-secondary)] text-xs">Key <span className="text-red-400">*</span></Label>
<Input value={genericKey} onChange={e => setGenericKey(e.target.value)} placeholder="MY_SECRET"
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
</div>
<FieldWithToggle label="Value" value={genericValue} onChange={setGenericValue}
placeholder="密钥值" fieldKey="generic_val" required />
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>取消</Button>
<Button onClick={handleSubmit} disabled={submitting} className="bg-[var(--dg-blue)] hover:bg-[var(--dg-blue)]/80">
{submitting ? '保存中...' : (isEdit ? '更新' : '创建')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,26 @@
// 凭据类型配置常量 // Logan.Li
import {
Key, Server, Container, Shield, Cloud, Database, FileText, Globe,
} from 'lucide-react';
import type { SecretType } from './types';
export const TYPE_CONFIG: Record<SecretType, { label: string; icon: typeof Key; color: string; desc: string }> = {
ssh: { label: 'SSH 密钥', icon: Server, color: 'text-amber-400', desc: 'SSH 私钥/公钥/密码' },
docker: { label: 'Docker 凭证', icon: Container, color: 'text-cyan-400', desc: 'Docker Registry 登录凭据' },
tls: { label: 'TLS 证书', icon: Shield, color: 'text-green-400', desc: 'TLS/SSL 证书与私钥' },
aksk: { label: 'AKSK 密钥', icon: Cloud, color: 'text-purple-400', desc: '云服务 AccessKey/SecretKey' },
database: { label: '数据库凭证', icon: Database, color: 'text-blue-400', desc: '数据库连接信息' },
api: { label: 'API 密钥', icon: Globe, color: 'text-orange-400', desc: 'API Token/Key + 端点' },
generic: { label: '通用凭证', icon: FileText, color: 'text-gray-400', desc: '自定义 Key/Value 键值对' },
};
export const TYPE_BADGE_CLASS: Record<SecretType, string> = {
ssh: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
docker: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
tls: 'bg-green-500/20 text-green-400 border-green-500/30',
aksk: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
database: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
api: 'bg-orange-500/20 text-orange-400 border-orange-500/30',
generic: 'bg-gray-500/20 text-gray-400 border-gray-500/30',
};
@@ -0,0 +1,18 @@
// 凭据管理类型定义 // Logan.Li
export interface Credential {
id: number;
name: string;
variable_name: string;
secret_type: SecretType;
summary: string;
tags: string[];
description: string;
expires_at: string | null;
is_active: boolean;
env_prefix: string;
created_at: string;
updated_at: string;
}
export type SecretType = 'ssh' | 'docker' | 'tls' | 'aksk' | 'database' | 'api' | 'generic';
@@ -1,75 +1,42 @@
// DeploymentWorkload.tsx — Kuboard 风格 Deployment 详情主组件 // DeploymentWorkload.tsx — Kuboard 风格 Deployment 详情主组件
// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 暂停/恢复更新 | CI/CD发布 | 删除 | 元数据 | 运行时 | 服务 | 应用路由 // - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 暂停/恢复更新 | CI/CD发布 | 删除 | 元数据 | 运行时 | 服务 | 应用路由
// - 三模式: view(只读增强看板)/ edit(可视化编辑器)/ yaml(YAML编辑器) // - 三模式: view(只读增强看板)/ edit(可视化编辑器)/ yaml(YAML编辑器)
// 作者: Logan <admin@attacker.club> // 作者: Logan.Li
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
ArrowLeft, ArrowLeft,
Image as ImageIcon, Image as ImageIcon,
Minus,
Plus,
RotateCw, RotateCw,
Trash2, Trash2,
FileText,
RefreshCw, RefreshCw,
Loader2, Loader2,
X, XCircle,
Network,
CheckCircle, CheckCircle,
AlertTriangle, AlertTriangle,
XCircle,
Pause, Pause,
Play, Play,
Rocket, Rocket,
Container,
Settings,
Database,
} from 'lucide-react'; } from 'lucide-react';
import { kubernetesApi } from '@/api'; import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast'; import { useToast } from '@/hooks/use-toast';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import { useUserStore } from '@/store'; import { useUserStore } from '@/store';
import { YamlViewer } from './YamlViewer'; import { YamlViewer } from './YamlViewer';
import { DeploymentView } from './DeploymentView'; import { DeploymentView } from './DeploymentView';
import { DeploymentEditContainers } from './DeploymentEditContainers';
import { DeploymentEditBasic } from './DeploymentEditBasic';
import { DeploymentEditStorage } from './DeploymentEditStorage';
import { DeploymentEditAdvanced } from './DeploymentEditAdvanced';
import { DeploymentEditService } from './DeploymentEditService';
import { PodExecTerminal } from './PodExecTerminal'; import { PodExecTerminal } from './PodExecTerminal';
import { PodFileBrowser } from './PodFileBrowser'; import { PodFileBrowser } from './PodFileBrowser';
import { EditModeContainer } from './deployment-workload/EditModeContainer';
import { CICDWebhookContent } from './deployment-workload/CICDWebhookContent';
import { ModalShell } from './deployment-workload/ModalShell';
import { PodLogsModal } from './deployment-workload/PodLogsModal';
import { ActionModals } from './deployment-workload/ActionModals';
import type { ActionModal, DeploymentWorkloadProps } from './deployment-workload/types';
type WorkloadMode = 'view' | 'edit' | 'yaml'; export type { DeploymentWorkloadProps, WorkloadMode } from './deployment-workload/types';
export interface DeploymentWorkloadProps {
clusterCode: string;
namespace: string;
deploymentName: string;
/** 'view' | 'edit' | 'yaml' — 来自路由参数 */
mode: WorkloadMode;
onBack: () => void;
/** 跳转到另一模式(用于"编辑"按钮、保存成功后切换) */
onModeChange: (mode: WorkloadMode) => void;
}
type ActionModal =
| { kind: 'yaml' }
| { kind: 'image' }
| { kind: 'scale' }
| { kind: 'podLogs'; pod: string }
| { kind: 'podYaml'; pod: string }
| { kind: 'delete' }
| { kind: 'cicd' }
| null;
export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mode, onBack, onModeChange }: DeploymentWorkloadProps) { export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mode, onBack, onModeChange }: DeploymentWorkloadProps) {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -436,82 +403,21 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
/> />
)} )}
{/* ============ 弹窗:调整镜像版本 ============ */} <ActionModals
{modal?.kind === 'image' && ( modal={modal}
<ModalShell title="调整镜像版本" onClose={() => setModal(null)}> deployment={deployment}
<div className="space-y-3"> namespace={namespace}
<div> deploymentName={deploymentName}
<Label className="text-slate-400 text-sm">当前镜像</Label> newImage={newImage}
<p className="text-xs text-slate-500 font-mono mt-1 bg-slate-800 rounded px-3 py-2">{deployment.image || '-'}</p> setNewImage={setNewImage}
</div> scaleValue={scaleValue}
<div> setScaleValue={setScaleValue}
<Label className="text-slate-400 text-sm">新镜像地址</Label> actionLoading={actionLoading}
<Input value={newImage} onChange={e => setNewImage(e.target.value)} onClose={() => setModal(null)}
placeholder="harbor.example.com/app:v2.0" onUpdateImage={handleUpdateImage}
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" /> onScaleTo={handleScaleTo}
</div> onDelete={handleDelete}
<div className="flex justify-end gap-2 pt-2"> />
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setModal(null)}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'image' || !newImage.trim()} onClick={handleUpdateImage}>
{actionLoading === 'image' ? <Loader2 className="h-4 w-4 animate-spin" /> : '更新镜像'}
</Button>
</div>
</div>
</ModalShell>
)}
{/* ============ 弹窗:伸缩 ============ */}
{modal?.kind === 'scale' && (
<ModalShell title="伸缩 (Scale)" onClose={() => setModal(null)}>
<div className="space-y-3">
<p className="text-xs text-slate-500">当前副本: {deployment.replicas || 0}</p>
<div className="flex items-center justify-center gap-3 py-3">
<Button variant="outline" size="icon" className="border-slate-700 text-slate-300"
disabled={actionLoading === 'scale' || scaleValue <= 0}
onClick={() => setScaleValue(v => Math.max(0, v - 1))}>
<Minus className="h-4 w-4" />
</Button>
<Input type="number" min={0} value={scaleValue}
onChange={e => setScaleValue(Math.max(0, Number(e.target.value)))}
className="w-24 text-center text-lg font-bold bg-slate-800 border-slate-700 text-slate-200" />
<Button variant="outline" size="icon" className="border-slate-700 text-slate-300"
disabled={actionLoading === 'scale'}
onClick={() => setScaleValue(v => v + 1)}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setModal(null)}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'scale'} onClick={handleScaleTo}>
{actionLoading === 'scale' ? <Loader2 className="h-4 w-4 animate-spin" /> : '确认'}
</Button>
</div>
</div>
</ModalShell>
)}
{/* ============ 弹窗:删除 ============ */}
{modal?.kind === 'delete' && (
<ModalShell title="删除 Deployment" onClose={() => setModal(null)}>
<div className="space-y-3">
<div className="flex items-center gap-2 text-red-400">
<AlertTriangle className="h-5 w-5" />
<span className="font-semibold">此操作不可撤销</span>
</div>
<p className="text-sm text-slate-400">
确认删除 Deployment <code className="text-slate-200 font-mono">{namespace}/{deploymentName}</code>?
其管理的所有 Pod 将被一并回收。
</p>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={() => setModal(null)}>取消</Button>
<Button variant="destructive" disabled={actionLoading === 'delete'} onClick={handleDelete}>
{actionLoading === 'delete' ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Trash2 className="h-4 w-4 mr-1" />}
确认删除
</Button>
</div>
</div>
</ModalShell>
)}
{/* ============ CI/CD Webhook 弹窗 ============ */} {/* ============ CI/CD Webhook 弹窗 ============ */}
{modal?.kind === 'cicd' && ( {modal?.kind === 'cicd' && (
@@ -552,249 +458,3 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
</div> </div>
); );
} }
// ============ 编辑模式 Tab 容器(M6: 基本信息Tab + 容器编辑预留) ============
function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: {
clusterCode: string;
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: () => void;
onCancel: () => void;
}) {
type EditTab = 'basic' | 'containers' | 'storage' | 'advanced' | 'service';
const [activeTab, setActiveTab] = useState<EditTab>('basic');
const tabs: Array<{ key: EditTab; label: string; icon: typeof Settings; available: boolean; badge?: string }> = [
{ key: 'basic', label: '基本信息', icon: Settings, available: true },
{ key: 'containers', label: '容器信息', icon: Container, available: true },
{ key: 'storage', label: '存储挂载', icon: Database, available: true },
{ key: 'advanced', label: '高级设置', icon: Settings, available: true },
{ key: 'service', label: '服务/应用路由', icon: Network, available: true },
];
return (
<div className="space-y-4">
{/* Tab 导航 */}
<div className="flex items-center gap-1 border-b border-slate-800 pb-px overflow-x-auto">
{tabs.map(tab => {
const active = activeTab === tab.key;
const Icon = tab.icon;
return (
<button
key={tab.key}
disabled={!tab.available}
onClick={() => tab.available && setActiveTab(tab.key)}
className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap
${active
? 'border-blue-500 text-blue-400'
: tab.available
? 'border-transparent text-slate-400 hover:text-slate-200'
: 'border-transparent text-slate-600 cursor-not-allowed'
}`}
>
<Icon className="h-3.5 w-3.5" />
{tab.label}
{tab.badge && (
<span className={`text-[9px] px-1 py-0.5 rounded ${active ? 'bg-blue-500/20 text-blue-300' : 'bg-slate-800 text-slate-600'}`}>
{tab.badge}
</span>
)}
</button>
);
})}
</div>
{/* Tab 内容 */}
{activeTab === 'basic' ? (
<DeploymentEditBasic
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : activeTab === 'containers' ? (
/* M7: 完整容器编辑器 — 工作容器 + 初始化容器 */
<DeploymentEditContainers
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : activeTab === 'storage' ? (
/* M8: 存储挂载 — volumes + volumeMounts 汇总 */
<DeploymentEditStorage
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : activeTab === 'advanced' ? (
/* M8: 高级设置 — 更新策略/调度/DNS/安全上下文/终止宽限期 */
<DeploymentEditAdvanced
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : activeTab === 'service' ? (
/* M9: 服务/应用路由 — 关联 Service + Ingress,可创建 */
<DeploymentEditService
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : null}
</div>
);
}
// ============ CI/CD Webhook 内容 ============
function CICDWebhookContent({ clusterCode, namespace, deploymentName }: { clusterCode: string; namespace: string; deploymentName: string }) {
const { toast } = useToast();
const token = useUserStore(( s: any) => s.token) || localStorage.getItem('token') || '';
const webhookUrl = `${window.location.origin}/api/v1/clusters/${clusterCode}/namespaces/${namespace}/deployments/${deploymentName}/update-image`;
const curlCmd = `curl -X POST '${webhookUrl}' \\
-H 'Authorization: Bearer ${token ? token.substring(0, 16) + '...' : 'YOUR_TOKEN'}' \\
-H 'Content-Type: application/json' \\
-d '{"image": "harbor.example.com/app:v1.0", "container": "your-container-name"}'`;
const copy = (text: string) => {
navigator.clipboard.writeText(text).then(() => toast({ title: '已复制' })).catch(() => {});
};
return (
<div className="space-y-4">
<div>
<Label className="text-slate-400 text-sm">Webhook URL</Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 text-xs text-slate-300 bg-slate-800 rounded px-3 py-2 break-all font-mono">{webhookUrl}</code>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 shrink-0" onClick={() => copy(webhookUrl)}>复制</Button>
</div>
</div>
<div>
<Label className="text-slate-400 text-sm">curl 示例</Label>
<div className="relative mt-1">
<pre className="text-xs text-slate-300 bg-slate-800 rounded p-3 overflow-x-auto font-mono whitespace-pre-wrap">{curlCmd}</pre>
<Button variant="outline" size="sm" className="absolute top-2 right-2 border-slate-700 text-slate-400" onClick={() => copy(curlCmd)}>复制</Button>
</div>
</div>
<div className="text-xs text-slate-500 space-y-1">
<p>• 使用 Bearer Token 认证(JWT 登录令牌)</p>
<p>• <code className="text-slate-400">image</code> 必填,指定新镜像地址</p>
<p>• <code className="text-slate-400">container</code> 可选,不填则更新第一个容器</p>
</div>
</div>
);
}
// ============ 通用 Modal 外壳 ============
function ModalShell({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 w-[460px] max-w-[90vw] shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-bold text-slate-100">{title}</h2>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
{children}
</div>
</div>
);
}
// ============ Pod 日志弹窗(带容器切换 + 行数 + 实时流) ============
function PodLogsModal({ clusterCode, namespace, podName, containers, onClose }: {
clusterCode: string; namespace: string; podName: string; containers: string[]; onClose: () => void;
}) {
const { toast } = useToast();
const [logs, setLogs] = useState('');
const [tail, setTail] = useState(200);
const [container, setContainer] = useState(containers[0] || '');
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false);
const loadLogs = async () => {
setLoading(true);
try {
const res: Record<string, any> = await kubernetesApi.getPodLogs(clusterCode, namespace, podName, tail, container || undefined);
setLogs(res?.data?.logs || res?.logs || res || '无日志');
} catch {
setLogs('获取日志失败');
} finally { setLoading(false); }
};
useEffect(() => { loadLogs(); }, [container, tail]);
const startStream = async () => {
setStreaming(true);
const token = useUserStore.getState().token || '';
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
const cParam = container ? `&container=${encodeURIComponent(container)}` : '';
const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs?tail=${tail}&stream=true${cParam}`;
try {
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const reader = resp.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
setLogs(buf);
}
} catch {
toast({ title: '流式日志连接中断', variant: 'destructive' });
} finally { setStreaming(false); }
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-3 border-b border-slate-700">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
<span className="text-sm font-semibold text-slate-200">{podName}</span>
{container && <Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{container}</Badge>}
</div>
<div className="flex items-center gap-2">
{containers.length > 0 && (
<select value={container} onChange={e => setContainer(e.target.value)}
className="bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2 py-1">
{containers.map(c => <option key={c} value={c}>{c}</option>)}
</select>
)}
<span className="text-[11px] text-slate-500">最近</span>
<Input type="number" value={tail} onChange={e => setTail(Number(e.target.value) || 200)}
className="w-16 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs" />
<span className="text-[11px] text-slate-500">行</span>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={loadLogs} disabled={loading}>
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
</Button>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-emerald-400 text-xs" onClick={startStream} disabled={streaming}>
{streaming ? '流中...' : '实时流'}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
</div>
<div className="flex-1 overflow-auto p-3">
<pre className="bg-slate-950 rounded p-3 text-xs text-slate-300 font-mono whitespace-pre-wrap max-h-[60vh] overflow-auto">
{logs || '无日志内容'}
</pre>
</div>
</div>
</div>
);
}
@@ -0,0 +1,121 @@
// ActionModals.tsx — 调整镜像 / 伸缩 / 删除 弹窗
// 作者: Logan.Li
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AlertTriangle, Loader2, Minus, Plus, Trash2 } from 'lucide-react';
import { ModalShell } from './ModalShell';
import type { ActionModal } from './types';
export interface ActionModalsProps {
modal: ActionModal;
deployment: any;
namespace: string;
deploymentName: string;
newImage: string;
setNewImage: (value: string) => void;
scaleValue: number;
setScaleValue: (value: number | ((prev: number) => number)) => void;
actionLoading: string;
onClose: () => void;
onUpdateImage: () => void;
onScaleTo: () => void;
onDelete: () => void;
}
export function ActionModals({
modal,
deployment,
namespace,
deploymentName,
newImage,
setNewImage,
scaleValue,
setScaleValue,
actionLoading,
onClose,
onUpdateImage,
onScaleTo,
onDelete,
}: ActionModalsProps) {
return (
<>
{/* ============ 弹窗:调整镜像版本 ============ */}
{modal?.kind === 'image' && (
<ModalShell title="调整镜像版本" onClose={onClose}>
<div className="space-y-3">
<div>
<Label className="text-slate-400 text-sm">当前镜像</Label>
<p className="text-xs text-slate-500 font-mono mt-1 bg-slate-800 rounded px-3 py-2">{deployment.image || '-'}</p>
</div>
<div>
<Label className="text-slate-400 text-sm">新镜像地址</Label>
<Input value={newImage} onChange={e => setNewImage(e.target.value)}
placeholder="harbor.example.com/app:v2.0"
className="mt-1 bg-slate-800 border-slate-700 text-slate-200 text-sm font-mono" />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={onClose}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'image' || !newImage.trim()} onClick={onUpdateImage}>
{actionLoading === 'image' ? <Loader2 className="h-4 w-4 animate-spin" /> : '更新镜像'}
</Button>
</div>
</div>
</ModalShell>
)}
{/* ============ 弹窗:伸缩 ============ */}
{modal?.kind === 'scale' && (
<ModalShell title="伸缩 (Scale)" onClose={onClose}>
<div className="space-y-3">
<p className="text-xs text-slate-500">当前副本: {deployment.replicas || 0}</p>
<div className="flex items-center justify-center gap-3 py-3">
<Button variant="outline" size="icon" className="border-slate-700 text-slate-300"
disabled={actionLoading === 'scale' || scaleValue <= 0}
onClick={() => setScaleValue(v => Math.max(0, v - 1))}>
<Minus className="h-4 w-4" />
</Button>
<Input type="number" min={0} value={scaleValue}
onChange={e => setScaleValue(Math.max(0, Number(e.target.value)))}
className="w-24 text-center text-lg font-bold bg-slate-800 border-slate-700 text-slate-200" />
<Button variant="outline" size="icon" className="border-slate-700 text-slate-300"
disabled={actionLoading === 'scale'}
onClick={() => setScaleValue(v => v + 1)}>
<Plus className="h-4 w-4" />
</Button>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={onClose}>取消</Button>
<Button className="bg-blue-600 hover:bg-blue-700" disabled={actionLoading === 'scale'} onClick={onScaleTo}>
{actionLoading === 'scale' ? <Loader2 className="h-4 w-4 animate-spin" /> : '确认'}
</Button>
</div>
</div>
</ModalShell>
)}
{/* ============ 弹窗:删除 ============ */}
{modal?.kind === 'delete' && (
<ModalShell title="删除 Deployment" onClose={onClose}>
<div className="space-y-3">
<div className="flex items-center gap-2 text-red-400">
<AlertTriangle className="h-5 w-5" />
<span className="font-semibold">此操作不可撤销</span>
</div>
<p className="text-sm text-slate-400">
确认删除 Deployment <code className="text-slate-200 font-mono">{namespace}/{deploymentName}</code>?
其管理的所有 Pod 将被一并回收。
</p>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" className="border-slate-700 text-slate-400" onClick={onClose}>取消</Button>
<Button variant="destructive" disabled={actionLoading === 'delete'} onClick={onDelete}>
{actionLoading === 'delete' ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Trash2 className="h-4 w-4 mr-1" />}
确认删除
</Button>
</div>
</div>
</ModalShell>
)}
</>
);
}
@@ -0,0 +1,50 @@
// CICDWebhookContent.tsx — CI/CD Webhook 弹窗内容
// 作者: Logan.Li
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { useToast } from '@/hooks/use-toast';
import { useUserStore } from '@/store';
export interface CICDWebhookContentProps {
clusterCode: string;
namespace: string;
deploymentName: string;
}
export function CICDWebhookContent({ clusterCode, namespace, deploymentName }: CICDWebhookContentProps) {
const { toast } = useToast();
const token = useUserStore(( s: any) => s.token) || localStorage.getItem('token') || '';
const webhookUrl = `${window.location.origin}/api/v1/clusters/${clusterCode}/namespaces/${namespace}/deployments/${deploymentName}/update-image`;
const curlCmd = `curl -X POST '${webhookUrl}' \\
-H 'Authorization: Bearer ${token ? token.substring(0, 16) + '...' : 'YOUR_TOKEN'}' \\
-H 'Content-Type: application/json' \\
-d '{"image": "harbor.example.com/app:v1.0", "container": "your-container-name"}'`;
const copy = (text: string) => {
navigator.clipboard.writeText(text).then(() => toast({ title: '已复制' })).catch(() => {});
};
return (
<div className="space-y-4">
<div>
<Label className="text-slate-400 text-sm">Webhook URL</Label>
<div className="flex items-center gap-2 mt-1">
<code className="flex-1 text-xs text-slate-300 bg-slate-800 rounded px-3 py-2 break-all font-mono">{webhookUrl}</code>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-400 shrink-0" onClick={() => copy(webhookUrl)}>复制</Button>
</div>
</div>
<div>
<Label className="text-slate-400 text-sm">curl 示例</Label>
<div className="relative mt-1">
<pre className="text-xs text-slate-300 bg-slate-800 rounded p-3 overflow-x-auto font-mono whitespace-pre-wrap">{curlCmd}</pre>
<Button variant="outline" size="sm" className="absolute top-2 right-2 border-slate-700 text-slate-400" onClick={() => copy(curlCmd)}>复制</Button>
</div>
</div>
<div className="text-xs text-slate-500 space-y-1">
<p>• 使用 Bearer Token 认证(JWT 登录令牌)</p>
<p>• <code className="text-slate-400">image</code> 必填,指定新镜像地址</p>
<p>• <code className="text-slate-400">container</code> 可选,不填则更新第一个容器</p>
</div>
</div>
);
}
@@ -0,0 +1,117 @@
// EditModeContainer.tsx — 编辑模式 Tab 容器(M6: 基本信息Tab + 容器编辑预留)
// 作者: Logan.Li
import { useState } from 'react';
import { Container, Settings, Database, Network } from 'lucide-react';
import { DeploymentEditBasic } from '../DeploymentEditBasic';
import { DeploymentEditContainers } from '../DeploymentEditContainers';
import { DeploymentEditStorage } from '../DeploymentEditStorage';
import { DeploymentEditAdvanced } from '../DeploymentEditAdvanced';
import { DeploymentEditService } from '../DeploymentEditService';
export interface EditModeContainerProps {
clusterCode: string;
namespace: string;
deploymentName: string;
manifest: any | null;
onSaved: () => void;
onCancel: () => void;
}
export function EditModeContainer({ clusterCode, namespace, deploymentName, manifest, onSaved, onCancel }: EditModeContainerProps) {
type EditTab = 'basic' | 'containers' | 'storage' | 'advanced' | 'service';
const [activeTab, setActiveTab] = useState<EditTab>('basic');
const tabs: Array<{ key: EditTab; label: string; icon: typeof Settings; available: boolean; badge?: string }> = [
{ key: 'basic', label: '基本信息', icon: Settings, available: true },
{ key: 'containers', label: '容器信息', icon: Container, available: true },
{ key: 'storage', label: '存储挂载', icon: Database, available: true },
{ key: 'advanced', label: '高级设置', icon: Settings, available: true },
{ key: 'service', label: '服务/应用路由', icon: Network, available: true },
];
return (
<div className="space-y-4">
{/* Tab 导航 */}
<div className="flex items-center gap-1 border-b border-slate-800 pb-px overflow-x-auto">
{tabs.map(tab => {
const active = activeTab === tab.key;
const Icon = tab.icon;
return (
<button
key={tab.key}
disabled={!tab.available}
onClick={() => tab.available && setActiveTab(tab.key)}
className={`flex items-center gap-1.5 px-3 py-2 text-xs font-medium border-b-2 transition-colors whitespace-nowrap
${active
? 'border-blue-500 text-blue-400'
: tab.available
? 'border-transparent text-slate-400 hover:text-slate-200'
: 'border-transparent text-slate-600 cursor-not-allowed'
}`}
>
<Icon className="h-3.5 w-3.5" />
{tab.label}
{tab.badge && (
<span className={`text-[9px] px-1 py-0.5 rounded ${active ? 'bg-blue-500/20 text-blue-300' : 'bg-slate-800 text-slate-600'}`}>
{tab.badge}
</span>
)}
</button>
);
})}
</div>
{/* Tab 内容 */}
{activeTab === 'basic' ? (
<DeploymentEditBasic
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : activeTab === 'containers' ? (
/* M7: 完整容器编辑器 — 工作容器 + 初始化容器 */
<DeploymentEditContainers
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : activeTab === 'storage' ? (
/* M8: 存储挂载 — volumes + volumeMounts 汇总 */
<DeploymentEditStorage
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : activeTab === 'advanced' ? (
/* M8: 高级设置 — 更新策略/调度/DNS/安全上下文/终止宽限期 */
<DeploymentEditAdvanced
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : activeTab === 'service' ? (
/* M9: 服务/应用路由 — 关联 Service + Ingress,可创建 */
<DeploymentEditService
clusterCode={clusterCode}
namespace={namespace}
deploymentName={deploymentName}
manifest={manifest}
onSaved={onSaved}
onCancel={onCancel}
/>
) : null}
</div>
);
}
@@ -0,0 +1,24 @@
// ModalShell.tsx — 通用 Modal 外壳
// 作者: Logan.Li
import { Button } from '@/components/ui/button';
import { X } from 'lucide-react';
export interface ModalShellProps {
title: string;
children: React.ReactNode;
onClose: () => void;
}
export function ModalShell({ title, children, onClose }: ModalShellProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="bg-slate-900 border border-slate-700 rounded-xl p-6 w-[460px] max-w-[90vw] shadow-2xl" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-bold text-slate-100">{title}</h2>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
{children}
</div>
</div>
);
}
@@ -0,0 +1,100 @@
// PodLogsModal.tsx — Pod 日志弹窗(带容器切换 + 行数 + 实时流)
// 作者: Logan.Li
import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { FileText, RefreshCw, Loader2, X } from 'lucide-react';
import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast';
import { useUserStore } from '@/store';
export interface PodLogsModalProps {
clusterCode: string;
namespace: string;
podName: string;
containers: string[];
onClose: () => void;
}
export function PodLogsModal({ clusterCode, namespace, podName, containers, onClose }: PodLogsModalProps) {
const { toast } = useToast();
const [logs, setLogs] = useState('');
const [tail, setTail] = useState(200);
const [container, setContainer] = useState(containers[0] || '');
const [loading, setLoading] = useState(false);
const [streaming, setStreaming] = useState(false);
const loadLogs = async () => {
setLoading(true);
try {
const res: Record<string, any> = await kubernetesApi.getPodLogs(clusterCode, namespace, podName, tail, container || undefined);
setLogs(res?.data?.logs || res?.logs || res || '无日志');
} catch {
setLogs('获取日志失败');
} finally { setLoading(false); }
};
useEffect(() => { loadLogs(); }, [container, tail]);
const startStream = async () => {
setStreaming(true);
const token = useUserStore.getState().token || '';
const baseUrl = import.meta.env.VITE_API_BASE_URL || '';
const cParam = container ? `&container=${encodeURIComponent(container)}` : '';
const url = `${baseUrl}/api/v1/kubernetes/${clusterCode}/namespace/${namespace}/pods/${podName}/logs?tail=${tail}&stream=true${cParam}`;
try {
const resp = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
const reader = resp.body?.getReader();
if (!reader) return;
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
setLogs(buf);
}
} catch {
toast({ title: '流式日志连接中断', variant: 'destructive' });
} finally { setStreaming(false); }
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60" onClick={onClose}>
<div className="bg-slate-900 border border-slate-700 rounded-xl w-[90vw] max-w-4xl max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between p-3 border-b border-slate-700">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
<span className="text-sm font-semibold text-slate-200">{podName}</span>
{container && <Badge variant="outline" className="border-slate-700 text-slate-400 text-[10px]">{container}</Badge>}
</div>
<div className="flex items-center gap-2">
{containers.length > 0 && (
<select value={container} onChange={e => setContainer(e.target.value)}
className="bg-slate-800 border border-slate-700 rounded text-xs text-slate-300 px-2 py-1">
{containers.map(c => <option key={c} value={c}>{c}</option>)}
</select>
)}
<span className="text-[11px] text-slate-500">最近</span>
<Input type="number" value={tail} onChange={e => setTail(Number(e.target.value) || 200)}
className="w-16 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs" />
<span className="text-[11px] text-slate-500">行</span>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-slate-300 text-xs" onClick={loadLogs} disabled={loading}>
{loading ? <Loader2 className="h-3 w-3 animate-spin" /> : <RefreshCw className="h-3 w-3" />}
</Button>
<Button size="sm" variant="outline" className="h-7 border-slate-700 text-emerald-400 text-xs" onClick={startStream} disabled={streaming}>
{streaming ? '流中...' : '实时流'}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400" onClick={onClose}><X className="h-4 w-4" /></Button>
</div>
</div>
<div className="flex-1 overflow-auto p-3">
<pre className="bg-slate-950 rounded p-3 text-xs text-slate-300 font-mono whitespace-pre-wrap max-h-[60vh] overflow-auto">
{logs || '无日志内容'}
</pre>
</div>
</div>
</div>
);
}
@@ -0,0 +1,25 @@
// deployment-workload/types.ts — Deployment 详情页类型定义
// 作者: Logan.Li
export type WorkloadMode = 'view' | 'edit' | 'yaml';
export interface DeploymentWorkloadProps {
clusterCode: string;
namespace: string;
deploymentName: string;
/** 'view' | 'edit' | 'yaml' — 来自路由参数 */
mode: WorkloadMode;
onBack: () => void;
/** 跳转到另一模式(用于"编辑"按钮、保存成功后切换) */
onModeChange: (mode: WorkloadMode) => void;
}
export type ActionModal =
| { kind: 'yaml' }
| { kind: 'image' }
| { kind: 'scale' }
| { kind: 'podLogs'; pod: string }
| { kind: 'podYaml'; pod: string }
| { kind: 'delete' }
| { kind: 'cicd' }
| null;
@@ -1,247 +1,49 @@
// Prometheus 数据源管理 — 参考 Grafana 界面设计 // Logan.Li // Prometheus 数据源管理 — 列表编排器 // Logan.Li
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect } from 'react';
import { import {
Database, Database,
Plus, Plus,
Edit, Edit,
Trash2, Trash2,
Star, Star,
RefreshCw, RefreshCw,
CheckCircle,
XCircle,
Shield,
Globe,
Clock,
Wrench,
Settings,
Key, Key,
X,
ChevronDown,
ChevronUp,
Plug, Plug,
AlertTriangle,
} from 'lucide-react'; } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import { promDatasourceApi } from '@/api'; import { usePromDatasources, usePromDatasourceMutations } from '@/hooks/api';
interface PromDS { import type { PromDS } from './prometheus-ds/types';
id: number; import { handleTestQuick } from './prometheus-ds/helpers';
name: string; import PromDsFormDialog from './prometheus-ds/PromDsFormDialog';
url: string;
access: string;
basic_auth: boolean;
basic_auth_user: string;
basic_auth_pass: string;
with_credentials: boolean;
tls_client_auth: boolean;
with_ca_cert: boolean;
ca_cert: string;
skip_tls_verify: boolean;
custom_headers: string;
enable_alerting: boolean;
alertmanager_ds: string;
scrape_interval: number;
query_timeout: number;
http_method: string;
disable_metrics_lookup: boolean;
custom_query_params: string;
is_default: boolean;
status: number;
created_at: string;
updated_at: string;
}
interface HeaderEntry {
header: string;
value: string;
}
const defaultForm = {
name: '',
url: '',
access: 'server',
http_method: 'POST',
basic_auth: false,
basic_auth_user: '',
basic_auth_pass: '',
with_credentials: false,
tls_client_auth: false,
with_ca_cert: false,
ca_cert: '',
skip_tls_verify: false,
custom_headers_json: '',
enable_alerting: true,
alertmanager_ds: '',
scrape_interval: 15,
query_timeout: 60,
disable_metrics_lookup: false,
custom_query_params: '',
is_default: false,
};
export default function PrometheusDatasourceManager() { export default function PrometheusDatasourceManager() {
const _confirm = useConfirm(); const _confirm = useConfirm();
const [sources, setSources] = useState<PromDS[]>([]); const { data: sources = [], isLoading, isError } = usePromDatasources();
const [loading, setLoading] = useState(true); const { deleteMutation, setDefaultMutation } = usePromDatasourceMutations();
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null); const [editingDs, setEditingDs] = useState<PromDS | null>(null);
const [form, setForm] = useState({ ...defaultForm });
// 自定义 HTTP Headers(动态键值对) useEffect(() => {
const [headers, setHeaders] = useState<HeaderEntry[]>([]); if (isError) toast.error('获取数据源列表失败');
// 折叠面板 }, [isError]);
const [showAuth, setShowAuth] = useState(false);
const [, setShowTLS] = useState(false);
const [showAlerting, setShowAlerting] = useState(false);
const [showMisc, setShowMisc] = useState(false);
// 连接测试
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string; target_count?: number } | null>(null);
const fetchList = useCallback(async () => {
try {
const res: Record<string, any> = await promDatasourceApi.list();
setSources(res?.data || res || []);
} catch {
toast.error('获取数据源列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchList(); }, [fetchList]);
// ==================== 表单操作 ====================
const openCreate = () => { const openCreate = () => {
setEditingId(null); setEditingDs(null);
setForm({ ...defaultForm });
setHeaders([]);
setTestResult(null);
setShowAuth(false);
setShowTLS(false);
setShowAlerting(false);
setShowMisc(false);
setDialogOpen(true); setDialogOpen(true);
}; };
const openEdit = (ds: PromDS) => { const openEdit = (ds: PromDS) => {
setEditingId(ds.id); setEditingDs(ds);
setForm({
name: ds.name,
url: ds.url,
access: ds.access || 'server',
http_method: ds.http_method || 'POST',
basic_auth: ds.basic_auth || false,
basic_auth_user: ds.basic_auth_user || '',
basic_auth_pass: ds.basic_auth_pass || '',
with_credentials: ds.with_credentials || false,
tls_client_auth: ds.tls_client_auth || false,
with_ca_cert: ds.with_ca_cert || false,
ca_cert: ds.ca_cert || '',
skip_tls_verify: ds.skip_tls_verify || false,
custom_headers_json: ds.custom_headers || '',
enable_alerting: ds.enable_alerting !== false,
alertmanager_ds: ds.alertmanager_ds || '',
scrape_interval: ds.scrape_interval || 15,
query_timeout: ds.query_timeout || 60,
disable_metrics_lookup: ds.disable_metrics_lookup || false,
custom_query_params: ds.custom_query_params || '',
is_default: ds.is_default || false,
});
// 解析 custom_headers JSON
try {
const parsed = JSON.parse(ds.custom_headers || '[]');
setHeaders(Array.isArray(parsed) ? parsed : []);
} catch {
setHeaders([]);
}
setTestResult(null);
setDialogOpen(true); setDialogOpen(true);
}; };
const updateForm = (field: string, value: any) => {
setForm(prev => ({ ...prev, [field]: value }));
};
// ==================== HTTP Headers ====================
const addHeader = () => {
setHeaders(prev => [...prev, { header: '', value: '' }]);
};
const removeHeader = (idx: number) => {
setHeaders(prev => prev.filter((_, i) => i !== idx));
};
const updateHeader = (idx: number, field: 'header' | 'value', val: string) => {
setHeaders(prev => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: val };
return next;
});
};
// ==================== CRUD ====================
const handleSave = async () => {
if (!form.name.trim()) { toast.error('请输入数据源名称'); return; }
if (!form.url.trim()) { toast.error('请输入 Prometheus URL'); return; }
const payload: Record<string, any> = {
name: form.name.trim(),
url: form.url.trim(),
access: form.access,
http_method: form.http_method,
basic_auth: form.basic_auth,
basic_auth_user: form.basic_auth_user,
basic_auth_pass: form.basic_auth_pass,
with_credentials: form.with_credentials,
tls_client_auth: form.tls_client_auth,
with_ca_cert: form.with_ca_cert,
ca_cert: form.ca_cert,
skip_tls_verify: form.skip_tls_verify,
custom_headers: JSON.stringify(headers),
enable_alerting: form.enable_alerting,
alertmanager_ds: form.alertmanager_ds,
scrape_interval: form.scrape_interval,
query_timeout: form.query_timeout,
disable_metrics_lookup: form.disable_metrics_lookup,
custom_query_params: form.custom_query_params,
is_default: form.is_default,
};
try {
if (editingId) {
await promDatasourceApi.update(editingId, payload);
toast.success('数据源更新成功');
} else {
await promDatasourceApi.create(payload);
toast.success('数据源创建成功');
}
setDialogOpen(false);
fetchList();
} catch (err: any) {
toast.error(err?.message || '操作失败');
}
};
const handleDelete = async (ds: PromDS) => { const handleDelete = async (ds: PromDS) => {
const ok = await _confirm({ const ok = await _confirm({
title: '删除数据源', title: '删除数据源',
@@ -250,9 +52,8 @@ export default function PrometheusDatasourceManager() {
}); });
if (!ok) return; if (!ok) return;
try { try {
await promDatasourceApi.delete(ds.id); await deleteMutation.mutateAsync(ds.id);
toast.success('已删除'); toast.success('已删除');
fetchList();
} catch { } catch {
toast.error('删除失败'); toast.error('删除失败');
} }
@@ -260,40 +61,14 @@ export default function PrometheusDatasourceManager() {
const handleSetDefault = async (ds: PromDS) => { const handleSetDefault = async (ds: PromDS) => {
try { try {
await promDatasourceApi.setDefault(ds.id); await setDefaultMutation.mutateAsync(ds.id);
toast.success(`已将「${ds.name}」设为默认数据源`); toast.success(`已将「${ds.name}」设为默认数据源`);
fetchList();
} catch { } catch {
toast.error('设置失败'); toast.error('设置失败');
} }
}; };
// ==================== 连接测试 ==================== if (isLoading) {
const handleTestConnection = async () => {
if (!form.url.trim()) { toast.error('请先输入 URL'); return; }
setTesting(true);
setTestResult(null);
try {
const res: Record<string, any> = await promDatasourceApi.testConnection({
url: form.url.trim(),
basic_auth: form.basic_auth,
basic_auth_user: form.basic_auth_user,
basic_auth_pass: form.basic_auth_pass,
skip_tls_verify: form.skip_tls_verify,
});
const data = res?.data || res;
setTestResult(data);
} catch (err: any) {
setTestResult({ success: false, message: err?.message || '测试失败' });
} finally {
setTesting(false);
}
};
// ==================== 渲染 ====================
if (loading) {
return ( return (
<div className="flex items-center justify-center h-96"> <div className="flex items-center justify-center h-96">
<RefreshCw className="animate-spin w-8 h-8 text-[var(--dg-text-secondary)]" /> <RefreshCw className="animate-spin w-8 h-8 text-[var(--dg-text-secondary)]" />
@@ -434,413 +209,12 @@ export default function PrometheusDatasourceManager() {
</CardContent> </CardContent>
</Card> </Card>
{/* ==================== 添加/编辑 Dialog ==================== */} <PromDsFormDialog
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}> open={dialogOpen}
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"> onOpenChange={setDialogOpen}
<DialogHeader> editingDs={editingDs}
<DialogTitle className="flex items-center gap-2 text-lg"> sources={sources}
<Database className="w-5 h-5 text-orange-400" /> />
{editingId ? '编辑数据源' : '添加数据源'}
</DialogTitle>
<DialogDescription>
Prometheus 数据源配置 · 参考 Grafana 数据源管理
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
{/* === Name & Default === */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ds-name" className="text-sm">Name <span className="text-red-400">*</span></Label>
<Input
id="ds-name"
placeholder="Prometheus 数据源名称"
value={form.name}
onChange={e => updateForm('name', e.target.value)}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
/>
</div>
<div className="flex items-end pb-2">
<div className="flex items-center gap-3">
<Switch
id="ds-default"
checked={form.is_default}
onCheckedChange={v => updateForm('is_default', v)}
/>
<Label htmlFor="ds-default" className="text-sm cursor-pointer">Default</Label>
</div>
</div>
</div>
{/* === HTTP 设置 === */}
<div className="space-y-3 bg-[var(--dg-bg-tertiary)]/50 rounded-lg p-4 border border-[var(--dg-border)]/50">
<h4 className="text-sm font-medium flex items-center gap-2 text-[var(--dg-text-primary)]">
<Globe className="w-4 h-4" /> HTTP
</h4>
<div className="space-y-2">
<Label htmlFor="ds-url" className="text-sm">URL <span className="text-red-400">*</span></Label>
<div className="flex gap-2">
<Select value={form.access} onValueChange={v => updateForm('access', v)}>
<SelectTrigger className="w-32 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="server">Server</SelectItem>
<SelectItem value="browser">Browser</SelectItem>
</SelectContent>
</Select>
<Input
id="ds-url"
placeholder="http://localhost:9090"
value={form.url}
onChange={e => updateForm('url', e.target.value)}
className="flex-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] font-mono text-sm"
/>
</div>
</div>
<div className="text-xs text-[var(--dg-text-secondary)]">
Access: <span className="text-[var(--dg-text-primary)]">{form.access === 'server' ? 'Server (default)' : 'Browser'}</span>
</div>
</div>
{/* === Auth 折叠 === */}
<div className="border border-[var(--dg-border)]/50 rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 bg-[var(--dg-bg-tertiary)]/30 hover:bg-[var(--dg-bg-tertiary)]/60 transition-colors"
onClick={() => setShowAuth(!showAuth)}
>
<span className="text-sm font-medium flex items-center gap-2">
<Shield className="w-4 h-4" /> Auth
</span>
{showAuth ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showAuth && (
<div className="p-4 space-y-4 border-t border-[var(--dg-border)]/50">
{/* Basic Auth */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<Switch
id="ds-basic-auth"
checked={form.basic_auth}
onCheckedChange={v => updateForm('basic_auth', v)}
/>
<Label htmlFor="ds-basic-auth" className="text-sm cursor-pointer">Basic auth</Label>
</div>
{form.basic_auth && (
<div className="grid grid-cols-2 gap-3 ml-9">
<Input
placeholder="用户名"
value={form.basic_auth_user}
onChange={e => updateForm('basic_auth_user', e.target.value)}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm"
/>
<Input
type="password"
placeholder="密码"
value={form.basic_auth_pass}
onChange={e => updateForm('basic_auth_pass', e.target.value)}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm"
/>
</div>
)}
</div>
{/* With Credentials */}
<div className="flex items-center gap-3">
<Switch
id="ds-with-creds"
checked={form.with_credentials}
onCheckedChange={v => updateForm('with_credentials', v)}
/>
<Label htmlFor="ds-with-creds" className="text-sm cursor-pointer">With Credentials</Label>
</div>
{/* TLS Client Auth */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<Switch
id="ds-tls-auth"
checked={form.tls_client_auth}
onCheckedChange={v => updateForm('tls_client_auth', v)}
/>
<Label htmlFor="ds-tls-auth" className="text-sm cursor-pointer">TLS Client Auth</Label>
</div>
{form.tls_client_auth && (
<div className="ml-9 space-y-3">
<div className="flex items-center gap-3">
<Switch
id="ds-ca-cert"
checked={form.with_ca_cert}
onCheckedChange={v => updateForm('with_ca_cert', v)}
/>
<Label htmlFor="ds-ca-cert" className="text-sm cursor-pointer">With CA Cert</Label>
</div>
{form.with_ca_cert && (
<textarea
placeholder="PEM 格式的 CA 证书内容..."
value={form.ca_cert}
onChange={e => updateForm('ca_cert', e.target.value)}
rows={4}
className="w-full bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md p-3 text-xs font-mono text-[var(--dg-text-primary)] resize-y"
/>
)}
</div>
)}
</div>
{/* Skip TLS Verify */}
<div className="flex items-center gap-3">
<Switch
id="ds-skip-tls"
checked={form.skip_tls_verify}
onCheckedChange={v => updateForm('skip_tls_verify', v)}
/>
<Label htmlFor="ds-skip-tls" className="text-sm cursor-pointer">Skip TLS Verify</Label>
</div>
{/* Forward OAuth Identity */}
<div className="text-xs text-[var(--dg-text-secondary)] italic">
Forward OAuth Identity
</div>
</div>
)}
</div>
{/* === Custom HTTP Headers === */}
<div className="space-y-3 bg-[var(--dg-bg-tertiary)]/30 rounded-lg p-4 border border-[var(--dg-border)]/50">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium flex items-center gap-2 text-[var(--dg-text-primary)]">
<Wrench className="w-4 h-4" /> Custom HTTP Headers
</h4>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addHeader}>
<Plus className="w-3 h-3 mr-1" /> Add header
</Button>
</div>
{headers.length === 0 ? (
<p className="text-xs text-[var(--dg-text-secondary)] italic">暂无自定义 Header</p>
) : (
<div className="space-y-2">
{headers.map((h, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
placeholder="Header"
value={h.header}
onChange={e => updateHeader(idx, 'header', e.target.value)}
className="flex-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm h-8"
/>
<Input
placeholder="Value"
value={h.value}
onChange={e => updateHeader(idx, 'value', e.target.value)}
className="flex-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm h-8"
/>
<Button
variant="ghost" size="sm" className="h-8 w-8 p-0 text-red-400 hover:text-red-300"
onClick={() => removeHeader(idx)}
>
<X className="w-3.5 h-3.5" />
</Button>
</div>
))}
</div>
)}
</div>
{/* === Alerting 折叠 === */}
<div className="border border-[var(--dg-border)]/50 rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 bg-[var(--dg-bg-tertiary)]/30 hover:bg-[var(--dg-bg-tertiary)]/60 transition-colors"
onClick={() => setShowAlerting(!showAlerting)}
>
<span className="text-sm font-medium flex items-center gap-2">
<AlertTriangle className="w-4 h-4" /> Alerting
</span>
{showAlerting ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showAlerting && (
<div className="p-4 space-y-4 border-t border-[var(--dg-border)]/50">
<div className="flex items-center gap-3">
<Switch
id="ds-enable-alerting"
checked={form.enable_alerting}
onCheckedChange={v => updateForm('enable_alerting', v)}
/>
<Label htmlFor="ds-enable-alerting" className="text-sm cursor-pointer">
Manage alerts via Alerting UI
</Label>
</div>
<div className="space-y-2">
<Label className="text-sm text-[var(--dg-text-secondary)]">Alertmanager data source</Label>
<Select value={form.alertmanager_ds} onValueChange={v => updateForm('alertmanager_ds', v)}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm">
<SelectValue placeholder="Choose" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none">无</SelectItem>
{sources.filter(s => s.id !== editingId).map(s => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</div>
{/* === 采集与查询 === */}
<div className="grid grid-cols-2 gap-4 bg-[var(--dg-bg-tertiary)]/30 rounded-lg p-4 border border-[var(--dg-border)]/50">
<div className="space-y-2">
<Label className="text-sm flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" /> Scrape interval
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={form.scrape_interval}
onChange={e => updateForm('scrape_interval', Number(e.target.value))}
className="w-20 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm text-center"
/>
<span className="text-xs text-[var(--dg-text-secondary)]">秒</span>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" /> Query timeout
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={form.query_timeout}
onChange={e => updateForm('query_timeout', Number(e.target.value))}
className="w-20 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm text-center"
/>
<span className="text-xs text-[var(--dg-text-secondary)]">秒</span>
</div>
</div>
</div>
{/* === HTTP Method === */}
<div className="space-y-2">
<Label className="text-sm">HTTP Method</Label>
<Select value={form.http_method} onValueChange={v => updateForm('http_method', v)}>
<SelectTrigger className="w-32 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="GET">GET</SelectItem>
</SelectContent>
</Select>
</div>
{/* === Misc 折叠 === */}
<div className="border border-[var(--dg-border)]/50 rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 bg-[var(--dg-bg-tertiary)]/30 hover:bg-[var(--dg-bg-tertiary)]/60 transition-colors"
onClick={() => setShowMisc(!showMisc)}
>
<span className="text-sm font-medium flex items-center gap-2">
<Settings className="w-4 h-4" /> Misc
</span>
{showMisc ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showMisc && (
<div className="p-4 space-y-4 border-t border-[var(--dg-border)]/50">
<div className="flex items-center gap-3">
<Switch
id="ds-disable-lookup"
checked={form.disable_metrics_lookup}
onCheckedChange={v => updateForm('disable_metrics_lookup', v)}
/>
<Label htmlFor="ds-disable-lookup" className="text-sm cursor-pointer">
Disable metrics lookup
</Label>
</div>
<div className="space-y-2">
<Label className="text-sm text-[var(--dg-text-secondary)]">Custom query parameters</Label>
<Input
placeholder='例: {"timeout": "30s"}'
value={form.custom_query_params}
onChange={e => updateForm('custom_query_params', e.target.value)}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm font-mono"
/>
</div>
</div>
)}
</div>
{/* 连接测试结果 */}
{testResult && (
<div className={`rounded-lg p-3 flex items-center gap-2 text-sm ${
testResult.success
? 'bg-emerald-500/10 border border-emerald-500/30 text-emerald-400'
: 'bg-red-500/10 border border-red-500/30 text-red-400'
}`}>
{testResult.success
? <CheckCircle className="w-4 h-4 shrink-0" />
: <XCircle className="w-4 h-4 shrink-0" />
}
<span>{testResult.message}</span>
{testResult.target_count !== undefined && (
<Badge variant="secondary" className="ml-auto text-xs">
{testResult.target_count} targets
</Badge>
)}
</div>
)}
</div>
{/* Footer */}
<DialogFooter className="flex items-center gap-2 pt-2 border-t border-[var(--dg-border)]/50">
<Button
variant="outline"
onClick={handleTestConnection}
disabled={testing || !form.url.trim()}
className="gap-2"
>
{testing ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Plug className="w-4 h-4" />
)}
Save & test
</Button>
<div className="flex-1" />
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button onClick={handleSave}>
{editingId ? '更新' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
// 快速测试连接(列表中的测试按钮)
async function handleTestQuick(ds: PromDS) {
try {
const res: Record<string, any> = await promDatasourceApi.testConnection({
url: ds.url,
basic_auth: ds.basic_auth,
basic_auth_user: ds.basic_auth_user,
basic_auth_pass: ds.basic_auth_pass,
skip_tls_verify: ds.skip_tls_verify,
});
const data = res?.data || res;
if (data?.success) {
toast.success(`连接成功 · ${data.target_count || 0} targets`);
} else {
toast.error(data?.message || '连接失败');
}
} catch (err: any) {
toast.error(err?.message || '测试失败');
}
}
@@ -0,0 +1,527 @@
// Prometheus 数据源添加/编辑弹窗 // Logan.Li
import { useState, useEffect } from 'react';
import {
Database,
Plus,
RefreshCw,
CheckCircle,
XCircle,
Shield,
Globe,
Clock,
Wrench,
Settings,
X,
ChevronDown,
ChevronUp,
Plug,
AlertTriangle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
} from '@/components/ui/select';
import { toast } from 'sonner';
import { usePromDatasourceMutations } from '@/hooks/api';
import type { PromDS, HeaderEntry, PromDsForm } from './types';
import { defaultForm } from './types';
import { dsToForm, parseHeaders, buildPayload, testPromConnection, type TestConnectionResult } from './helpers';
interface PromDsFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editingDs: PromDS | null;
sources: PromDS[];
}
export default function PromDsFormDialog({
open,
onOpenChange,
editingDs,
sources,
}: PromDsFormDialogProps) {
const { createMutation, updateMutation } = usePromDatasourceMutations();
const editingId = editingDs?.id ?? null;
const [form, setForm] = useState<PromDsForm>({ ...defaultForm });
const [headers, setHeaders] = useState<HeaderEntry[]>([]);
const [showAuth, setShowAuth] = useState(false);
const [, setShowTLS] = useState(false);
const [showAlerting, setShowAlerting] = useState(false);
const [showMisc, setShowMisc] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestConnectionResult | null>(null);
// 打开弹窗时初始化表单
useEffect(() => {
if (!open) return;
if (editingDs) {
setForm(dsToForm(editingDs));
setHeaders(parseHeaders(editingDs.custom_headers));
} else {
setForm({ ...defaultForm });
setHeaders([]);
}
setTestResult(null);
setShowAuth(false);
setShowTLS(false);
setShowAlerting(false);
setShowMisc(false);
}, [open, editingDs]);
const updateForm = (field: keyof PromDsForm, value: PromDsForm[keyof PromDsForm]) => {
setForm(prev => ({ ...prev, [field]: value }));
};
const addHeader = () => {
setHeaders(prev => [...prev, { header: '', value: '' }]);
};
const removeHeader = (idx: number) => {
setHeaders(prev => prev.filter((_, i) => i !== idx));
};
const updateHeader = (idx: number, field: 'header' | 'value', val: string) => {
setHeaders(prev => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: val };
return next;
});
};
const handleSave = async () => {
if (!form.name.trim()) { toast.error('请输入数据源名称'); return; }
if (!form.url.trim()) { toast.error('请输入 Prometheus URL'); return; }
const payload = buildPayload(form, headers);
try {
if (editingId) {
await updateMutation.mutateAsync({ id: editingId, data: payload });
toast.success('数据源更新成功');
} else {
await createMutation.mutateAsync(payload);
toast.success('数据源创建成功');
}
onOpenChange(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '操作失败';
toast.error(message);
}
};
const handleTestConnection = async () => {
if (!form.url.trim()) { toast.error('请先输入 URL'); return; }
setTesting(true);
setTestResult(null);
try {
const data = await testPromConnection({
url: form.url,
basic_auth: form.basic_auth,
basic_auth_user: form.basic_auth_user,
basic_auth_pass: form.basic_auth_pass,
skip_tls_verify: form.skip_tls_verify,
});
setTestResult(data);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '测试失败';
setTestResult({ success: false, message });
} finally {
setTesting(false);
}
};
const saving = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
<Database className="w-5 h-5 text-orange-400" />
{editingId ? '编辑数据源' : '添加数据源'}
</DialogTitle>
<DialogDescription>
Prometheus 数据源配置 · 参考 Grafana 数据源管理
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
{/* === Name & Default === */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ds-name" className="text-sm">Name <span className="text-red-400">*</span></Label>
<Input
id="ds-name"
placeholder="Prometheus 数据源名称"
value={form.name}
onChange={e => updateForm('name', e.target.value)}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
/>
</div>
<div className="flex items-end pb-2">
<div className="flex items-center gap-3">
<Switch
id="ds-default"
checked={form.is_default}
onCheckedChange={v => updateForm('is_default', v)}
/>
<Label htmlFor="ds-default" className="text-sm cursor-pointer">Default</Label>
</div>
</div>
</div>
{/* === HTTP 设置 === */}
<div className="space-y-3 bg-[var(--dg-bg-tertiary)]/50 rounded-lg p-4 border border-[var(--dg-border)]/50">
<h4 className="text-sm font-medium flex items-center gap-2 text-[var(--dg-text-primary)]">
<Globe className="w-4 h-4" /> HTTP
</h4>
<div className="space-y-2">
<Label htmlFor="ds-url" className="text-sm">URL <span className="text-red-400">*</span></Label>
<div className="flex gap-2">
<Select value={form.access} onValueChange={v => updateForm('access', v)}>
<SelectTrigger className="w-32 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="server">Server</SelectItem>
<SelectItem value="browser">Browser</SelectItem>
</SelectContent>
</Select>
<Input
id="ds-url"
placeholder="http://localhost:9090"
value={form.url}
onChange={e => updateForm('url', e.target.value)}
className="flex-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] font-mono text-sm"
/>
</div>
</div>
<div className="text-xs text-[var(--dg-text-secondary)]">
Access: <span className="text-[var(--dg-text-primary)]">{form.access === 'server' ? 'Server (default)' : 'Browser'}</span>
</div>
</div>
{/* === Auth 折叠 === */}
<div className="border border-[var(--dg-border)]/50 rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 bg-[var(--dg-bg-tertiary)]/30 hover:bg-[var(--dg-bg-tertiary)]/60 transition-colors"
onClick={() => setShowAuth(!showAuth)}
>
<span className="text-sm font-medium flex items-center gap-2">
<Shield className="w-4 h-4" /> Auth
</span>
{showAuth ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showAuth && (
<div className="p-4 space-y-4 border-t border-[var(--dg-border)]/50">
<div className="space-y-3">
<div className="flex items-center gap-3">
<Switch
id="ds-basic-auth"
checked={form.basic_auth}
onCheckedChange={v => updateForm('basic_auth', v)}
/>
<Label htmlFor="ds-basic-auth" className="text-sm cursor-pointer">Basic auth</Label>
</div>
{form.basic_auth && (
<div className="grid grid-cols-2 gap-3 ml-9">
<Input
placeholder="用户名"
value={form.basic_auth_user}
onChange={e => updateForm('basic_auth_user', e.target.value)}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm"
/>
<Input
type="password"
placeholder="密码"
value={form.basic_auth_pass}
onChange={e => updateForm('basic_auth_pass', e.target.value)}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm"
/>
</div>
)}
</div>
<div className="flex items-center gap-3">
<Switch
id="ds-with-creds"
checked={form.with_credentials}
onCheckedChange={v => updateForm('with_credentials', v)}
/>
<Label htmlFor="ds-with-creds" className="text-sm cursor-pointer">With Credentials</Label>
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<Switch
id="ds-tls-auth"
checked={form.tls_client_auth}
onCheckedChange={v => updateForm('tls_client_auth', v)}
/>
<Label htmlFor="ds-tls-auth" className="text-sm cursor-pointer">TLS Client Auth</Label>
</div>
{form.tls_client_auth && (
<div className="ml-9 space-y-3">
<div className="flex items-center gap-3">
<Switch
id="ds-ca-cert"
checked={form.with_ca_cert}
onCheckedChange={v => updateForm('with_ca_cert', v)}
/>
<Label htmlFor="ds-ca-cert" className="text-sm cursor-pointer">With CA Cert</Label>
</div>
{form.with_ca_cert && (
<textarea
placeholder="PEM 格式的 CA 证书内容..."
value={form.ca_cert}
onChange={e => updateForm('ca_cert', e.target.value)}
rows={4}
className="w-full bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md p-3 text-xs font-mono text-[var(--dg-text-primary)] resize-y"
/>
)}
</div>
)}
</div>
<div className="flex items-center gap-3">
<Switch
id="ds-skip-tls"
checked={form.skip_tls_verify}
onCheckedChange={v => updateForm('skip_tls_verify', v)}
/>
<Label htmlFor="ds-skip-tls" className="text-sm cursor-pointer">Skip TLS Verify</Label>
</div>
<div className="text-xs text-[var(--dg-text-secondary)] italic">
Forward OAuth Identity
</div>
</div>
)}
</div>
{/* === Custom HTTP Headers === */}
<div className="space-y-3 bg-[var(--dg-bg-tertiary)]/30 rounded-lg p-4 border border-[var(--dg-border)]/50">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium flex items-center gap-2 text-[var(--dg-text-primary)]">
<Wrench className="w-4 h-4" /> Custom HTTP Headers
</h4>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addHeader}>
<Plus className="w-3 h-3 mr-1" /> Add header
</Button>
</div>
{headers.length === 0 ? (
<p className="text-xs text-[var(--dg-text-secondary)] italic">暂无自定义 Header</p>
) : (
<div className="space-y-2">
{headers.map((h, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
placeholder="Header"
value={h.header}
onChange={e => updateHeader(idx, 'header', e.target.value)}
className="flex-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm h-8"
/>
<Input
placeholder="Value"
value={h.value}
onChange={e => updateHeader(idx, 'value', e.target.value)}
className="flex-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm h-8"
/>
<Button
variant="ghost" size="sm" className="h-8 w-8 p-0 text-red-400 hover:text-red-300"
onClick={() => removeHeader(idx)}
>
<X className="w-3.5 h-3.5" />
</Button>
</div>
))}
</div>
)}
</div>
{/* === Alerting 折叠 === */}
<div className="border border-[var(--dg-border)]/50 rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 bg-[var(--dg-bg-tertiary)]/30 hover:bg-[var(--dg-bg-tertiary)]/60 transition-colors"
onClick={() => setShowAlerting(!showAlerting)}
>
<span className="text-sm font-medium flex items-center gap-2">
<AlertTriangle className="w-4 h-4" /> Alerting
</span>
{showAlerting ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showAlerting && (
<div className="p-4 space-y-4 border-t border-[var(--dg-border)]/50">
<div className="flex items-center gap-3">
<Switch
id="ds-enable-alerting"
checked={form.enable_alerting}
onCheckedChange={v => updateForm('enable_alerting', v)}
/>
<Label htmlFor="ds-enable-alerting" className="text-sm cursor-pointer">
Manage alerts via Alerting UI
</Label>
</div>
<div className="space-y-2">
<Label className="text-sm text-[var(--dg-text-secondary)]">Alertmanager data source</Label>
<Select value={form.alertmanager_ds} onValueChange={v => updateForm('alertmanager_ds', v)}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm">
<SelectValue placeholder="Choose" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none">无</SelectItem>
{sources.filter(s => s.id !== editingId).map(s => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</div>
{/* === 采集与查询 === */}
<div className="grid grid-cols-2 gap-4 bg-[var(--dg-bg-tertiary)]/30 rounded-lg p-4 border border-[var(--dg-border)]/50">
<div className="space-y-2">
<Label className="text-sm flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" /> Scrape interval
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={form.scrape_interval}
onChange={e => updateForm('scrape_interval', Number(e.target.value))}
className="w-20 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm text-center"
/>
<span className="text-xs text-[var(--dg-text-secondary)]">秒</span>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" /> Query timeout
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={form.query_timeout}
onChange={e => updateForm('query_timeout', Number(e.target.value))}
className="w-20 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm text-center"
/>
<span className="text-xs text-[var(--dg-text-secondary)]">秒</span>
</div>
</div>
</div>
{/* === HTTP Method === */}
<div className="space-y-2">
<Label className="text-sm">HTTP Method</Label>
<Select value={form.http_method} onValueChange={v => updateForm('http_method', v)}>
<SelectTrigger className="w-32 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="GET">GET</SelectItem>
</SelectContent>
</Select>
</div>
{/* === Misc 折叠 === */}
<div className="border border-[var(--dg-border)]/50 rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 bg-[var(--dg-bg-tertiary)]/30 hover:bg-[var(--dg-bg-tertiary)]/60 transition-colors"
onClick={() => setShowMisc(!showMisc)}
>
<span className="text-sm font-medium flex items-center gap-2">
<Settings className="w-4 h-4" /> Misc
</span>
{showMisc ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showMisc && (
<div className="p-4 space-y-4 border-t border-[var(--dg-border)]/50">
<div className="flex items-center gap-3">
<Switch
id="ds-disable-lookup"
checked={form.disable_metrics_lookup}
onCheckedChange={v => updateForm('disable_metrics_lookup', v)}
/>
<Label htmlFor="ds-disable-lookup" className="text-sm cursor-pointer">
Disable metrics lookup
</Label>
</div>
<div className="space-y-2">
<Label className="text-sm text-[var(--dg-text-secondary)]">Custom query parameters</Label>
<Input
placeholder='例: {"timeout": "30s"}'
value={form.custom_query_params}
onChange={e => updateForm('custom_query_params', e.target.value)}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm font-mono"
/>
</div>
</div>
)}
</div>
{/* 连接测试结果 */}
{testResult && (
<div className={`rounded-lg p-3 flex items-center gap-2 text-sm ${
testResult.success
? 'bg-emerald-500/10 border border-emerald-500/30 text-emerald-400'
: 'bg-red-500/10 border border-red-500/30 text-red-400'
}`}>
{testResult.success
? <CheckCircle className="w-4 h-4 shrink-0" />
: <XCircle className="w-4 h-4 shrink-0" />
}
<span>{testResult.message}</span>
{testResult.target_count !== undefined && (
<Badge variant="secondary" className="ml-auto text-xs">
{testResult.target_count} targets
</Badge>
)}
</div>
)}
</div>
<DialogFooter className="flex items-center gap-2 pt-2 border-t border-[var(--dg-border)]/50">
<Button
variant="outline"
onClick={handleTestConnection}
disabled={testing || !form.url.trim()}
className="gap-2"
>
{testing ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Plug className="w-4 h-4" />
)}
Save & test
</Button>
<div className="flex-1" />
<Button variant="ghost" onClick={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleSave} disabled={saving}>
{editingId ? '更新' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,115 @@
// Prometheus 数据源辅助函数 // Logan.Li
import { toast } from 'sonner';
import { promDatasourceApi } from '@/api';
import type { PromDS, HeaderEntry, PromDsForm } from './types';
export type TestConnectionParams = {
url: string;
basic_auth: boolean;
basic_auth_user: string;
basic_auth_pass: string;
skip_tls_verify: boolean;
};
export type TestConnectionResult = {
success: boolean;
message: string;
target_count?: number;
};
/** 从数据源记录构建表单初始值 */
export function dsToForm(ds: PromDS): PromDsForm {
return {
name: ds.name,
url: ds.url,
access: ds.access || 'server',
http_method: ds.http_method || 'POST',
basic_auth: ds.basic_auth || false,
basic_auth_user: ds.basic_auth_user || '',
basic_auth_pass: ds.basic_auth_pass || '',
with_credentials: ds.with_credentials || false,
tls_client_auth: ds.tls_client_auth || false,
with_ca_cert: ds.with_ca_cert || false,
ca_cert: ds.ca_cert || '',
skip_tls_verify: ds.skip_tls_verify || false,
custom_headers_json: ds.custom_headers || '',
enable_alerting: ds.enable_alerting !== false,
alertmanager_ds: ds.alertmanager_ds || '',
scrape_interval: ds.scrape_interval || 15,
query_timeout: ds.query_timeout || 60,
disable_metrics_lookup: ds.disable_metrics_lookup || false,
custom_query_params: ds.custom_query_params || '',
is_default: ds.is_default || false,
};
}
/** 解析 custom_headers JSON 为键值对列表 */
export function parseHeaders(json: string): HeaderEntry[] {
try {
const parsed = JSON.parse(json || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/** 构建保存请求的 payload */
export function buildPayload(form: PromDsForm, headers: HeaderEntry[]): Record<string, unknown> {
return {
name: form.name.trim(),
url: form.url.trim(),
access: form.access,
http_method: form.http_method,
basic_auth: form.basic_auth,
basic_auth_user: form.basic_auth_user,
basic_auth_pass: form.basic_auth_pass,
with_credentials: form.with_credentials,
tls_client_auth: form.tls_client_auth,
with_ca_cert: form.with_ca_cert,
ca_cert: form.ca_cert,
skip_tls_verify: form.skip_tls_verify,
custom_headers: JSON.stringify(headers),
enable_alerting: form.enable_alerting,
alertmanager_ds: form.alertmanager_ds,
scrape_interval: form.scrape_interval,
query_timeout: form.query_timeout,
disable_metrics_lookup: form.disable_metrics_lookup,
custom_query_params: form.custom_query_params,
is_default: form.is_default,
};
}
/** 测试 Prometheus 连接(表单与列表共用) */
export async function testPromConnection(params: TestConnectionParams): Promise<TestConnectionResult> {
const res: Record<string, unknown> = await promDatasourceApi.testConnection({
url: params.url.trim(),
basic_auth: params.basic_auth,
basic_auth_user: params.basic_auth_user,
basic_auth_pass: params.basic_auth_pass,
skip_tls_verify: params.skip_tls_verify,
});
const data = (res?.data || res) as TestConnectionResult;
return data;
}
/** 列表行快速测试连接 */
export async function handleTestQuick(ds: PromDS): Promise<void> {
try {
const data = await testPromConnection({
url: ds.url,
basic_auth: ds.basic_auth,
basic_auth_user: ds.basic_auth_user,
basic_auth_pass: ds.basic_auth_pass,
skip_tls_verify: ds.skip_tls_verify,
});
if (data?.success) {
toast.success(`连接成功 · ${data.target_count || 0} targets`);
} else {
toast.error(data?.message || '连接失败');
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '测试失败';
toast.error(message);
}
}
@@ -0,0 +1,58 @@
// Prometheus 数据源类型定义 // Logan.Li
export interface PromDS {
id: number;
name: string;
url: string;
access: string;
basic_auth: boolean;
basic_auth_user: string;
basic_auth_pass: string;
with_credentials: boolean;
tls_client_auth: boolean;
with_ca_cert: boolean;
ca_cert: string;
skip_tls_verify: boolean;
custom_headers: string;
enable_alerting: boolean;
alertmanager_ds: string;
scrape_interval: number;
query_timeout: number;
http_method: string;
disable_metrics_lookup: boolean;
custom_query_params: string;
is_default: boolean;
status: number;
created_at: string;
updated_at: string;
}
export interface HeaderEntry {
header: string;
value: string;
}
export const defaultForm = {
name: '',
url: '',
access: 'server',
http_method: 'POST',
basic_auth: false,
basic_auth_user: '',
basic_auth_pass: '',
with_credentials: false,
tls_client_auth: false,
with_ca_cert: false,
ca_cert: '',
skip_tls_verify: false,
custom_headers_json: '',
enable_alerting: true,
alertmanager_ds: '',
scrape_interval: 15,
query_timeout: 60,
disable_metrics_lookup: false,
custom_query_params: '',
is_default: false,
};
export type PromDsForm = typeof defaultForm;
@@ -1,4 +1,5 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef, useMemo } from 'react';
import { useQueryClient } from '@tanstack/react-query';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -39,7 +40,7 @@ import {
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
} from 'lucide-react'; } from 'lucide-react';
import { serverApi } from '@/api'; import { useServers, useServerMutations, queryKeys } from '@/hooks/api';
import { ServerStatsStrip } from './ServerStatsStrip'; import { ServerStatsStrip } from './ServerStatsStrip';
import { useConfirm } from '@/components/ui/confirm-dialog'; import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -55,9 +56,8 @@ import { usePermission } from '@/hooks/usePermission';
export function ServerManager() { export function ServerManager() {
const confirm = useConfirm(); const confirm = useConfirm();
const queryClient = useQueryClient();
const { hasPermission } = usePermission(); const { hasPermission } = usePermission();
const [servers, setServers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedServers, setSelectedServers] = useState<string[]>([]); const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusFilter, setStatusFilter] = useState<string>('all');
@@ -82,7 +82,18 @@ export function ServerManager() {
// 分页状态 // 分页状态
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10); const [pageSize, setPageSize] = useState(10);
const [total, setTotal] = useState(0);
const queryParams = useMemo(() => {
const params: Record<string, unknown> = { page, page_size: pageSize };
if (searchQuery) params.keyword = searchQuery;
if (statusFilter && statusFilter !== 'all') params.status = statusFilter;
return params;
}, [page, pageSize, searchQuery, statusFilter]);
const { data, isLoading: loading, refetch } = useServers(queryParams);
const { createMutation, updateMutation, deleteMutation, batchDeleteMutation } = useServerMutations();
const servers = data?.list ?? [];
const total = data?.total ?? 0;
const openTerminal = ( server: any) => { const openTerminal = ( server: any) => {
setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip, hasAuth: server.has_auth ?? true }); setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip, hasAuth: server.has_auth ?? true });
@@ -94,25 +105,6 @@ export function ServerManager() {
setFileBrowserOpen(true); setFileBrowserOpen(true);
}; };
const loadData = async (kw?: string, st?: string) => {
try {
setLoading(true);
const q = kw ?? searchQuery;
const s = st ?? statusFilter;
const params: Record<string, any> = { page, page_size: pageSize };
if (q) params.keyword = q;
if (s && s !== 'all') params.status = s;
const res: Record<string, any> = await serverApi.getServers(params);
setServers(res?.list || []);
setTotal(res?.total || 0);
} catch (e) {
console.error('加载服务器列表失败:', e);
setServers([]);
} finally {
setLoading(false);
}
};
// 搜索/筛选变化时重置到第1页 // 搜索/筛选变化时重置到第1页
const handleSearch = (val: string) => { const handleSearch = (val: string) => {
setSearchQuery(val); setSearchQuery(val);
@@ -123,10 +115,6 @@ export function ServerManager() {
setPage(1); setPage(1);
}; };
useEffect(() => {
loadData();
}, [page, pageSize]);
// WebSocket 实时接收 Agent 指标推送 // Logan.Li // WebSocket 实时接收 Agent 指标推送 // Logan.Li
const wsRef = useRef<WebSocket | null>(null); const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -152,27 +140,30 @@ export function ServerManager() {
try { try {
const msg = JSON.parse(event.data); const msg = JSON.parse(event.data);
if (msg.type === 'metrics_update') { if (msg.type === 'metrics_update') {
setServers((prev) => queryClient.setQueryData(queryKeys.servers(queryParams), (old: typeof data) => {
prev.map((s) => { if (!old) return old;
// 通过 hostname 匹配(Agent 上报时使用 hostname) return {
if (s.hostname === msg.hostname) { ...old,
return { list: old.list.map((s) => {
...s, if (s.hostname === msg.hostname) {
cpu_usage: msg.cpu_usage, return {
memory_usage: msg.memory_usage, ...s,
disk_usage: msg.disk_usage, cpu_usage: msg.cpu_usage,
status: msg.status, memory_usage: msg.memory_usage,
tcp_established: msg.tcp_established, disk_usage: msg.disk_usage,
tcp_time_wait: msg.tcp_time_wait, status: msg.status,
net_rx_bps: msg.net_rx_bps, tcp_established: msg.tcp_established,
net_tx_bps: msg.net_tx_bps, tcp_time_wait: msg.tcp_time_wait,
disk_read_bps: msg.disk_read_bps, net_rx_bps: msg.net_rx_bps,
disk_write_bps: msg.disk_write_bps, net_tx_bps: msg.net_tx_bps,
}; disk_read_bps: msg.disk_read_bps,
} disk_write_bps: msg.disk_write_bps,
return s; };
}) }
); return s;
}),
};
});
} }
} catch { } catch {
// 忽略非 JSON 消息 // 忽略非 JSON 消息
@@ -201,14 +192,14 @@ export function ServerManager() {
wsRef.current = null; wsRef.current = null;
} }
}; };
}, []); }, [queryClient, queryParams]);
// 导出 CSV // 导出 CSV
const toggleSelectAll = () => { const toggleSelectAll = () => {
if (selectedServers.length === servers.length) { if (selectedServers.length === servers.length) {
setSelectedServers([]); setSelectedServers([]);
} else { } else {
setSelectedServers(servers.map((s) => s.id)); setSelectedServers(servers.map((s) => String(s.id)));
} }
}; };
@@ -230,9 +221,8 @@ export function ServerManager() {
}); });
if (!ok) return; if (!ok) return;
try { try {
await serverApi.deleteServer(id); await deleteMutation.mutateAsync(id);
toast.success('服务器已删除'); toast.success('服务器已删除');
loadData();
} catch (e) { } catch (e) {
console.error('删除服务器失败:', e); console.error('删除服务器失败:', e);
toast.error('删除失败'); toast.error('删除失败');
@@ -250,10 +240,9 @@ export function ServerManager() {
}); });
if (!ok) return; if (!ok) return;
try { try {
await serverApi.batchDelete(selectedServers.map((id) => Number(id))); await batchDeleteMutation.mutateAsync(selectedServers.map((id) => Number(id)));
toast.success(`已删除 ${selectedServers.length} 台服务器`); toast.success(`已删除 ${selectedServers.length} 台服务器`);
setSelectedServers([]); setSelectedServers([]);
loadData();
} catch (e) { } catch (e) {
console.error('批量删除失败:', e); console.error('批量删除失败:', e);
toast.error('批量删除失败'); toast.error('批量删除失败');
@@ -326,13 +315,12 @@ export function ServerManager() {
} }
} }
if (editingId) { if (editingId) {
await serverApi.updateServer(editingId, payload); await updateMutation.mutateAsync({ id: editingId, data: payload });
} else { } else {
await serverApi.createServer(payload); await createMutation.mutateAsync(payload);
} }
toast.success(editingId ? '服务器已更新' : '服务器已创建'); toast.success(editingId ? '服务器已更新' : '服务器已创建');
setFormOpen(false); setFormOpen(false);
loadData();
} catch (e) { } catch (e) {
console.error('保存服务器失败:', e); console.error('保存服务器失败:', e);
toast.error('保存失败,请重试'); toast.error('保存失败,请重试');
@@ -349,7 +337,7 @@ export function ServerManager() {
} }
const headers = ['主机名', 'IP地址', '公网IP', '端口', '操作系统', 'CPU(核)', '内存(GB)', '磁盘(GB)', '状态', '标签']; const headers = ['主机名', 'IP地址', '公网IP', '端口', '操作系统', 'CPU(核)', '内存(GB)', '磁盘(GB)', '状态', '标签'];
const rows = servers.map((s) => { const rows = servers.map((s) => {
const memGB = s.memory ? Math.round(s.memory / 1024) : 0; const memGB = s.memory ? Math.round(Number(s.memory) / 1024) : 0;
const tags = getTags(s).join(';'); const tags = getTags(s).join(';');
return [s.hostname || '', s.ip || '', s.public_ip || '', s.port || 22, s.os || '', return [s.hostname || '', s.ip || '', s.public_ip || '', s.port || 22, s.os || '',
s.cpu || 0, memGB, s.disk || 0, s.status || '', tags]; s.cpu || 0, memGB, s.disk || 0, s.status || '', tags];
@@ -381,7 +369,7 @@ export function ServerManager() {
<Button <Button
variant="outline" variant="outline"
className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)]"
onClick={() => loadData()} onClick={() => refetch()}
> >
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} /> <RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
刷新 刷新
@@ -527,7 +515,7 @@ export function ServerManager() {
<ServerTableRow <ServerTableRow
key={server.id} key={server.id}
server={server} server={server}
selected={selectedServers.includes(server.id)} selected={selectedServers.includes(String(server.id))}
onToggleSelect={toggleSelect} onToggleSelect={toggleSelect}
onOpenTerminal={openTerminal} onOpenTerminal={openTerminal}
onOpenFiles={openFileBrowser} onOpenFiles={openFileBrowser}
@@ -602,14 +590,14 @@ export function ServerManager() {
open={cloudImportOpen} open={cloudImportOpen}
onClose={() => { onClose={() => {
setCloudImportOpen(false); setCloudImportOpen(false);
loadData(); refetch();
}} }}
/> />
<ExcelServerImport <ExcelServerImport
open={excelImportOpen} open={excelImportOpen}
onClose={() => { onClose={() => {
setExcelImportOpen(false); setExcelImportOpen(false);
loadData(); refetch();
}} }}
/> />
+3 -1
View File
@@ -4,7 +4,9 @@ export { queryKeys } from './queryKeys';
export { unwrapList, unwrapTotal } from './unwrap'; export { unwrapList, unwrapTotal } from './unwrap';
export { useTerminalSessions, useTerminalSessionMutations, type TerminalSession } from './useTerminalSessions'; export { useTerminalSessions, useTerminalSessionMutations, type TerminalSession } from './useTerminalSessions';
export { useHostList, useHostHistory, useHealthProbes, usePortPolicy } from './useHostMonitor'; export { useHostList, useHostHistory, useHealthProbes, usePortPolicy } from './useHostMonitor';
export { useServers, useServerMutations } from './useServers'; export { useServers, useServerMutations, type ServerRecord } from './useServers';
export { useCredentials, useCredentialDetail, useCredentialMutations, type CredentialRecord } from './useCredentials';
export { usePromDatasources, usePromDatasourceMutations, type PromDatasource } from './usePromDatasources';
export { usePipelines, usePipelineRuns, usePipelineMutations } from './usePipelines'; export { usePipelines, usePipelineRuns, usePipelineMutations } from './usePipelines';
export { useAlertRules, useAlerts, useAlertMutations } from './useAlerts'; export { useAlertRules, useAlerts, useAlertMutations } from './useAlerts';
export { useAuditLogs } from './useAuditLogs'; export { useAuditLogs } from './useAuditLogs';
+3
View File
@@ -16,4 +16,7 @@ export const queryKeys = {
auditLogs: (params?: Record<string, unknown>) => ['audit-logs', params] as const, auditLogs: (params?: Record<string, unknown>) => ['audit-logs', params] as const,
sessionCommands: (params?: Record<string, unknown>) => ['session-commands', params] as const, sessionCommands: (params?: Record<string, unknown>) => ['session-commands', params] as const,
k8sClusters: () => ['k8s-clusters'] as const, k8sClusters: () => ['k8s-clusters'] as const,
credentials: (params?: Record<string, unknown>) => ['credentials', params] as const,
credentialDetail: (id: number | null) => ['credential-detail', id] as const,
promDatasources: () => ['prom-datasources'] as const,
}; };
+77
View File
@@ -0,0 +1,77 @@
// 凭据管理 — TanStack Query // Logan.Li
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { credentialApi } from '@/api';
import { queryKeys } from './queryKeys';
import { unwrapList } from './unwrap';
export type CredentialRecord = {
id: number;
name: string;
variable_name: string;
secret_type: string;
summary: string;
tags: string[];
description: string;
expires_at: string | null;
is_active: boolean;
env_prefix: string;
created_at: string;
updated_at: string;
};
type ListParams = {
page?: number;
page_size?: number;
secret_type?: string;
search?: string;
};
export function useCredentials(params: ListParams = { page_size: 100 }) {
return useQuery({
queryKey: queryKeys.credentials(params),
queryFn: async () => {
const resp = await credentialApi.list(params);
return unwrapList<CredentialRecord>(resp);
},
staleTime: 30_000,
});
}
export function useCredentialDetail(id: number | null, enabled = true) {
return useQuery({
queryKey: queryKeys.credentialDetail(id),
queryFn: () => credentialApi.get(id!),
enabled: !!id && enabled,
staleTime: 10_000,
});
}
export function useCredentialMutations() {
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ['credentials'] });
const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => credentialApi.create(data),
onSuccess: invalidate,
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number | string; data: Record<string, unknown> }) =>
credentialApi.update(id, data),
onSuccess: invalidate,
});
const deleteMutation = useMutation({
mutationFn: (id: number | string) => credentialApi.delete(id),
onSuccess: invalidate,
});
const toggleActiveMutation = useMutation({
mutationFn: ({ id, isActive }: { id: number | string; isActive: boolean }) =>
credentialApi.toggleActive(id, isActive),
onSuccess: invalidate,
});
return { createMutation, updateMutation, deleteMutation, toggleActiveMutation, invalidate };
}
@@ -0,0 +1,72 @@
// Prometheus 数据源 — TanStack Query // Logan.Li
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { promDatasourceApi } from '@/api';
import { queryKeys } from './queryKeys';
import { unwrapList } from './unwrap';
export type PromDatasource = {
id: number;
name: string;
url: string;
access: string;
basic_auth: boolean;
basic_auth_user: string;
basic_auth_pass: string;
with_credentials: boolean;
tls_client_auth: boolean;
with_ca_cert: boolean;
ca_cert: string;
skip_tls_verify: boolean;
custom_headers: string;
enable_alerting: boolean;
alertmanager_ds: string;
scrape_interval: number;
query_timeout: number;
http_method: string;
disable_metrics_lookup: boolean;
custom_query_params: string;
is_default: boolean;
status: number;
created_at: string;
updated_at: string;
};
export function usePromDatasources() {
return useQuery({
queryKey: queryKeys.promDatasources(),
queryFn: async () => {
const resp = await promDatasourceApi.list();
return unwrapList<PromDatasource>(resp);
},
staleTime: 30_000,
});
}
export function usePromDatasourceMutations() {
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ['prom-datasources'] });
const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => promDatasourceApi.create(data),
onSuccess: invalidate,
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number | string; data: Record<string, unknown> }) =>
promDatasourceApi.update(id, data),
onSuccess: invalidate,
});
const deleteMutation = useMutation({
mutationFn: (id: number | string) => promDatasourceApi.delete(id),
onSuccess: invalidate,
});
const setDefaultMutation = useMutation({
mutationFn: (id: number | string) => promDatasourceApi.setDefault(id),
onSuccess: invalidate,
});
return { createMutation, updateMutation, deleteMutation, setDefaultMutation, invalidate };
}
+31 -4
View File
@@ -3,7 +3,15 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { serverApi } from '@/api'; import { serverApi } from '@/api';
import { queryKeys } from './queryKeys'; import { queryKeys } from './queryKeys';
import { unwrapList } from './unwrap'; import { unwrapList, unwrapTotal } from './unwrap';
export type ServerRecord = Record<string, unknown> & {
id: number | string;
hostname?: string;
ip?: string;
status?: string;
has_auth?: boolean;
};
type ListParams = { type ListParams = {
page?: number; page?: number;
@@ -12,12 +20,20 @@ type ListParams = {
status?: string; status?: string;
}; };
type ServerListResult = {
list: ServerRecord[];
total: number;
};
export function useServers(params: ListParams = { page: 1, page_size: 100 }) { export function useServers(params: ListParams = { page: 1, page_size: 100 }) {
return useQuery({ return useQuery({
queryKey: queryKeys.servers(params), queryKey: queryKeys.servers(params),
queryFn: async () => { queryFn: async (): Promise<ServerListResult> => {
const resp = await serverApi.getServers(params); const resp = await serverApi.getServers(params);
return unwrapList<Record<string, unknown>>(resp); return {
list: unwrapList<ServerRecord>(resp),
total: unwrapTotal(resp),
};
}, },
staleTime: 30_000, staleTime: 30_000,
}); });
@@ -27,6 +43,17 @@ export function useServerMutations() {
const qc = useQueryClient(); const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ['servers'] }); const invalidate = () => qc.invalidateQueries({ queryKey: ['servers'] });
const createMutation = useMutation({
mutationFn: (data: Record<string, unknown>) => serverApi.createServer(data),
onSuccess: invalidate,
});
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: number | string; data: Record<string, unknown> }) =>
serverApi.updateServer(id, data),
onSuccess: invalidate,
});
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: number | string) => serverApi.deleteServer(id), mutationFn: (id: number | string) => serverApi.deleteServer(id),
onSuccess: invalidate, onSuccess: invalidate,
@@ -37,5 +64,5 @@ export function useServerMutations() {
onSuccess: invalidate, onSuccess: invalidate,
}); });
return { deleteMutation, batchDeleteMutation, invalidate }; return { createMutation, updateMutation, deleteMutation, batchDeleteMutation, invalidate };
} }