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
+2280 -1940

No files matched your search

+2 -105
View File
@@ -1,106 +1,3 @@
// 云账号/平台/账单
// 从 services/api.ts 拆分 — Q3 api.ts 模块化
// 云资源 API — 子模块 re-export(保持 @/api 导入路径不变) // Logan.Li
import { http, type ListResponse } from '@/utils/request';
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 ====================
export * from './cloud/index';
+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 @@
// 服务器/资产/区域/资源型号
// 从 services/api.ts 拆分 — Q3 api.ts 模块化
// 服务器域 API — 子模块 re-export(保持 @/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: 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 ====================
export * from './server/index';
+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 {
Plus,
Pencil,
Trash2,
RefreshCw,
Key,
Shield,
Globe,
Search,
Lock,
Eye,
EyeOff,
Copy,
Server,
Container,
Database,
FileText,
Cloud,
Clock,
AlertTriangle,
CheckCircle2,
XCircle,
Zap,
Plus, Pencil, Trash2, RefreshCw, Key, Shield, Search,
Clock, CheckCircle2, XCircle, Zap,
} from 'lucide-react';
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 { 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 { useConfirm } from '@/components/ui/confirm-dialog';
import { credentialApi } from '@/api';
// ────── Types ──────
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;
}
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 ──────
import { useCredentials, useCredentialMutations } from '@/hooks/api';
import type { Credential, SecretType } from './credential-manager/types';
import { TYPE_CONFIG, TYPE_BADGE_CLASS } from './credential-manager/constants';
import { CredDetailDialog } from './credential-manager/CredDetailDialog';
import { CredFormDialog } from './credential-manager/CredFormDialog';
// 凭据管理页编排器 // Logan.Li
export default function CredentialManager() {
const confirm = useConfirm();
const [credentials, setCredentials] = useState<Credential[]>([]);
const [loading, setLoading] = useState(false);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Credential | null>(null);
const [detailId, setDetailId] = useState<number | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
// Filters
const [filterType, setFilterType] = useState<string>('');
// 筛选条件 // Logan.Li
const [filterType, setFilterType] = useState('all');
const [filterSearch, setFilterSearch] = useState('');
const fetchAll = useCallback(async () => {
setLoading(true);
try {
const res: Record<string, any> = await credentialApi.list({
page_size: 100,
...(filterType ? { secret_type: filterType } : {}),
...(filterSearch ? { search: filterSearch } : {}),
});
setCredentials(res?.list || []);
} catch { toast.error('获取凭证列表失败'); }
finally { setLoading(false); }
}, [filterType, filterSearch]);
const listParams = {
page_size: 100,
...(filterSearch ? { search: filterSearch } : {}),
...(filterType && filterType !== 'all' ? { secret_type: filterType } : {}),
};
useEffect(() => { fetchAll(); }, [fetchAll]);
const { data: credentials = [], isLoading: loading, refetch } = useCredentials(listParams);
const { deleteMutation, toggleActiveMutation } = useCredentialMutations();
const handleDelete = async (cred: Credential) => {
const ok = await confirm({
@@ -664,17 +45,15 @@ export default function CredentialManager() {
});
if (!ok) return;
try {
await credentialApi.delete(cred.id);
await deleteMutation.mutateAsync(cred.id);
toast.success('已删除');
fetchAll();
} catch { toast.error('删除失败'); }
};
const handleToggleActive = async (cred: Credential) => {
try {
await credentialApi.toggleActive(cred.id, !cred.is_active);
await toggleActiveMutation.mutateAsync({ id: cred.id, isActive: !cred.is_active });
toast.success(cred.is_active ? '已禁用' : '已启用');
fetchAll();
} catch { toast.error('操作失败'); }
};
@@ -695,7 +74,7 @@ export default function CredentialManager() {
</p>
</div>
<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" />刷新
</Button>
<Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}>
@@ -704,7 +83,7 @@ export default function CredentialManager() {
</div>
</div>
{/* Search/Filter */}
{/* 搜索与筛选 // Logan.Li */}
<div className="flex items-center gap-3 flex-wrap">
<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">
@@ -814,16 +193,16 @@ export default function CredentialManager() {
<td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-end gap-1">
<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" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7"
onClick={() => handleToggleActive(cred)}
onClick={() => handleToggleActive(cred as Credential)}
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" />}
</Button>
<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" />
</Button>
</div>
@@ -836,8 +215,8 @@ export default function CredentialManager() {
</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} />
</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 详情主组件
// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 暂停/恢复更新 | CI/CD发布 | 删除 | 元数据 | 运行时 | 服务 | 应用路由
// - 三模式: view(只读增强看板)/ edit(可视化编辑器)/ yaml(YAML编辑器)
// 作者: Logan <admin@attacker.club>
// 作者: Logan.Li
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
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 {
ArrowLeft,
Image as ImageIcon,
Minus,
Plus,
RotateCw,
Trash2,
FileText,
RefreshCw,
Loader2,
X,
Network,
XCircle,
CheckCircle,
AlertTriangle,
XCircle,
Pause,
Play,
Rocket,
Container,
Settings,
Database,
} from 'lucide-react';
import { kubernetesApi } from '@/api';
import { useToast } from '@/hooks/use-toast';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { useUserStore } from '@/store';
import { YamlViewer } from './YamlViewer';
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 { 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 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 type { DeploymentWorkloadProps, WorkloadMode } from './deployment-workload/types';
export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mode, onBack, onModeChange }: DeploymentWorkloadProps) {
const navigate = useNavigate();
@@ -436,82 +403,21 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
/>
)}
{/* ============ 弹窗:调整镜像版本 ============ */}
{modal?.kind === 'image' && (
<ModalShell title="调整镜像版本" onClose={() => setModal(null)}>
<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={() => 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>
)}
<ActionModals
modal={modal}
deployment={deployment}
namespace={namespace}
deploymentName={deploymentName}
newImage={newImage}
setNewImage={setNewImage}
scaleValue={scaleValue}
setScaleValue={setScaleValue}
actionLoading={actionLoading}
onClose={() => setModal(null)}
onUpdateImage={handleUpdateImage}
onScaleTo={handleScaleTo}
onDelete={handleDelete}
/>
{/* ============ CI/CD Webhook 弹窗 ============ */}
{modal?.kind === 'cicd' && (
@@ -551,250 +457,4 @@ export function DeploymentWorkload({ clusterCode, namespace, deploymentName, mod
/>
</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
import { useState, useEffect, useCallback } from 'react';
// Prometheus 数据源管理 — 列表编排器 // Logan.Li
import { useState, useEffect } from 'react';
import {
Database,
Plus,
Edit,
Trash2,
Star,
RefreshCw,
CheckCircle,
XCircle,
Shield,
Globe,
Clock,
Wrench,
Settings,
Key,
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 { 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 { useConfirm } from '@/components/ui/confirm-dialog';
import { promDatasourceApi } from '@/api';
import { usePromDatasources, usePromDatasourceMutations } from '@/hooks/api';
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;
}
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,
};
import type { PromDS } from './prometheus-ds/types';
import { handleTestQuick } from './prometheus-ds/helpers';
import PromDsFormDialog from './prometheus-ds/PromDsFormDialog';
export default function PrometheusDatasourceManager() {
const _confirm = useConfirm();
const [sources, setSources] = useState<PromDS[]>([]);
const [loading, setLoading] = useState(true);
const { data: sources = [], isLoading, isError } = usePromDatasources();
const { deleteMutation, setDefaultMutation } = usePromDatasourceMutations();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState({ ...defaultForm });
const [editingDs, setEditingDs] = useState<PromDS | null>(null);
// 自定义 HTTP Headers(动态键值对)
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<{ 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]);
// ==================== 表单操作 ====================
useEffect(() => {
if (isError) toast.error('获取数据源列表失败');
}, [isError]);
const openCreate = () => {
setEditingId(null);
setForm({ ...defaultForm });
setHeaders([]);
setTestResult(null);
setShowAuth(false);
setShowTLS(false);
setShowAlerting(false);
setShowMisc(false);
setEditingDs(null);
setDialogOpen(true);
};
const openEdit = (ds: PromDS) => {
setEditingId(ds.id);
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);
setEditingDs(ds);
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 ok = await _confirm({
title: '删除数据源',
@@ -250,9 +52,8 @@ export default function PrometheusDatasourceManager() {
});
if (!ok) return;
try {
await promDatasourceApi.delete(ds.id);
await deleteMutation.mutateAsync(ds.id);
toast.success('已删除');
fetchList();
} catch {
toast.error('删除失败');
}
@@ -260,40 +61,14 @@ export default function PrometheusDatasourceManager() {
const handleSetDefault = async (ds: PromDS) => {
try {
await promDatasourceApi.setDefault(ds.id);
await setDefaultMutation.mutateAsync(ds.id);
toast.success(`已将「${ds.name}」设为默认数据源`);
fetchList();
} catch {
toast.error('设置失败');
}
};
// ==================== 连接测试 ====================
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) {
if (isLoading) {
return (
<div className="flex items-center justify-center h-96">
<RefreshCw className="animate-spin w-8 h-8 text-[var(--dg-text-secondary)]" />
@@ -434,413 +209,12 @@ export default function PrometheusDatasourceManager() {
</CardContent>
</Card>
{/* ==================== 添加/编辑 Dialog ==================== */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<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">
{/* 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>
<PromDsFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
editingDs={editingDs}
sources={sources}
/>
</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 { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -39,7 +40,7 @@ import {
ChevronLeft,
ChevronRight,
} from 'lucide-react';
import { serverApi } from '@/api';
import { useServers, useServerMutations, queryKeys } from '@/hooks/api';
import { ServerStatsStrip } from './ServerStatsStrip';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
@@ -55,9 +56,8 @@ import { usePermission } from '@/hooks/usePermission';
export function ServerManager() {
const confirm = useConfirm();
const queryClient = useQueryClient();
const { hasPermission } = usePermission();
const [servers, setServers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
@@ -82,7 +82,18 @@ export function ServerManager() {
// 分页状态
const [page, setPage] = useState(1);
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) => {
setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip, hasAuth: server.has_auth ?? true });
@@ -94,25 +105,6 @@ export function ServerManager() {
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页
const handleSearch = (val: string) => {
setSearchQuery(val);
@@ -123,10 +115,6 @@ export function ServerManager() {
setPage(1);
};
useEffect(() => {
loadData();
}, [page, pageSize]);
// WebSocket 实时接收 Agent 指标推送 // Logan.Li
const wsRef = useRef<WebSocket | null>(null);
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -152,27 +140,30 @@ export function ServerManager() {
try {
const msg = JSON.parse(event.data);
if (msg.type === 'metrics_update') {
setServers((prev) =>
prev.map((s) => {
// 通过 hostname 匹配(Agent 上报时使用 hostname)
if (s.hostname === msg.hostname) {
return {
...s,
cpu_usage: msg.cpu_usage,
memory_usage: msg.memory_usage,
disk_usage: msg.disk_usage,
status: msg.status,
tcp_established: msg.tcp_established,
tcp_time_wait: msg.tcp_time_wait,
net_rx_bps: msg.net_rx_bps,
net_tx_bps: msg.net_tx_bps,
disk_read_bps: msg.disk_read_bps,
disk_write_bps: msg.disk_write_bps,
};
}
return s;
})
);
queryClient.setQueryData(queryKeys.servers(queryParams), (old: typeof data) => {
if (!old) return old;
return {
...old,
list: old.list.map((s) => {
if (s.hostname === msg.hostname) {
return {
...s,
cpu_usage: msg.cpu_usage,
memory_usage: msg.memory_usage,
disk_usage: msg.disk_usage,
status: msg.status,
tcp_established: msg.tcp_established,
tcp_time_wait: msg.tcp_time_wait,
net_rx_bps: msg.net_rx_bps,
net_tx_bps: msg.net_tx_bps,
disk_read_bps: msg.disk_read_bps,
disk_write_bps: msg.disk_write_bps,
};
}
return s;
}),
};
});
}
} catch {
// 忽略非 JSON 消息
@@ -201,14 +192,14 @@ export function ServerManager() {
wsRef.current = null;
}
};
}, []);
}, [queryClient, queryParams]);
// 导出 CSV
const toggleSelectAll = () => {
if (selectedServers.length === servers.length) {
setSelectedServers([]);
} else {
setSelectedServers(servers.map((s) => s.id));
setSelectedServers(servers.map((s) => String(s.id)));
}
};
@@ -230,9 +221,8 @@ export function ServerManager() {
});
if (!ok) return;
try {
await serverApi.deleteServer(id);
await deleteMutation.mutateAsync(id);
toast.success('服务器已删除');
loadData();
} catch (e) {
console.error('删除服务器失败:', e);
toast.error('删除失败');
@@ -250,10 +240,9 @@ export function ServerManager() {
});
if (!ok) return;
try {
await serverApi.batchDelete(selectedServers.map((id) => Number(id)));
await batchDeleteMutation.mutateAsync(selectedServers.map((id) => Number(id)));
toast.success(`已删除 ${selectedServers.length} 台服务器`);
setSelectedServers([]);
loadData();
} catch (e) {
console.error('批量删除失败:', e);
toast.error('批量删除失败');
@@ -326,13 +315,12 @@ export function ServerManager() {
}
}
if (editingId) {
await serverApi.updateServer(editingId, payload);
await updateMutation.mutateAsync({ id: editingId, data: payload });
} else {
await serverApi.createServer(payload);
await createMutation.mutateAsync(payload);
}
toast.success(editingId ? '服务器已更新' : '服务器已创建');
setFormOpen(false);
loadData();
} catch (e) {
console.error('保存服务器失败:', e);
toast.error('保存失败,请重试');
@@ -349,7 +337,7 @@ export function ServerManager() {
}
const headers = ['主机名', 'IP地址', '公网IP', '端口', '操作系统', 'CPU(核)', '内存(GB)', '磁盘(GB)', '状态', '标签'];
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(';');
return [s.hostname || '', s.ip || '', s.public_ip || '', s.port || 22, s.os || '',
s.cpu || 0, memGB, s.disk || 0, s.status || '', tags];
@@ -381,7 +369,7 @@ export function ServerManager() {
<Button
variant="outline"
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')} />
刷新
@@ -527,7 +515,7 @@ export function ServerManager() {
<ServerTableRow
key={server.id}
server={server}
selected={selectedServers.includes(server.id)}
selected={selectedServers.includes(String(server.id))}
onToggleSelect={toggleSelect}
onOpenTerminal={openTerminal}
onOpenFiles={openFileBrowser}
@@ -602,14 +590,14 @@ export function ServerManager() {
open={cloudImportOpen}
onClose={() => {
setCloudImportOpen(false);
loadData();
refetch();
}}
/>
<ExcelServerImport
open={excelImportOpen}
onClose={() => {
setExcelImportOpen(false);
loadData();
refetch();
}}
/>
+3 -1
View File
@@ -4,7 +4,9 @@ export { queryKeys } from './queryKeys';
export { unwrapList, unwrapTotal } from './unwrap';
export { useTerminalSessions, useTerminalSessionMutations, type TerminalSession } from './useTerminalSessions';
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 { useAlertRules, useAlerts, useAlertMutations } from './useAlerts';
export { useAuditLogs } from './useAuditLogs';
+3
View File
@@ -16,4 +16,7 @@ export const queryKeys = {
auditLogs: (params?: Record<string, unknown>) => ['audit-logs', params] as const,
sessionCommands: (params?: Record<string, unknown>) => ['session-commands', params] 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 { serverApi } from '@/api';
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 = {
page?: number;
@@ -12,12 +20,20 @@ type ListParams = {
status?: string;
};
type ServerListResult = {
list: ServerRecord[];
total: number;
};
export function useServers(params: ListParams = { page: 1, page_size: 100 }) {
return useQuery({
queryKey: queryKeys.servers(params),
queryFn: async () => {
queryFn: async (): Promise<ServerListResult> => {
const resp = await serverApi.getServers(params);
return unwrapList<Record<string, unknown>>(resp);
return {
list: unwrapList<ServerRecord>(resp),
total: unwrapTotal(resp),
};
},
staleTime: 30_000,
});
@@ -27,6 +43,17 @@ export function useServerMutations() {
const qc = useQueryClient();
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({
mutationFn: (id: number | string) => serverApi.deleteServer(id),
onSuccess: invalidate,
@@ -37,5 +64,5 @@ export function useServerMutations() {
onSuccess: invalidate,
});
return { deleteMutation, batchDeleteMutation, invalidate };
return { createMutation, updateMutation, deleteMutation, batchDeleteMutation, invalidate };
}