@@ -814,16 +193,16 @@ export default function CredentialManager() {
e.stopPropagation()}>
@@ -836,8 +215,8 @@ export default function CredentialManager() {
)}
- setDialogOpen(false)} onSaved={fetchAll} editData={editing} />
+ setDialogOpen(false)} onSaved={() => refetch()} editData={editing} />
setDetailOpen(false)} credId={detailId} />
);
-}
+}
\ No newline at end of file
diff --git a/frontend/src/components/deploy/credential-manager/CredDetailDialog.tsx b/frontend/src/components/deploy/credential-manager/CredDetailDialog.tsx
new file mode 100644
index 0000000..88f8620
--- /dev/null
+++ b/frontend/src/components/deploy/credential-manager/CredDetailDialog.tsx
@@ -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>({});
+ 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)?.content as Record || {};
+
+ 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 (
+
+
+ {label}
+
+
+ {isLong ? (
+
+ ) : (
+
+ )}
+
+ {sensitive && (
+
+ )}
+
+
+
+
+ );
+ };
+
+ const detailRecord = detail as Record | undefined;
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/deploy/credential-manager/CredFormDialog.tsx b/frontend/src/components/deploy/credential-manager/CredFormDialog.tsx
new file mode 100644
index 0000000..416ea9a
--- /dev/null
+++ b/frontend/src/components/deploy/credential-manager/CredFormDialog.tsx
@@ -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('ssh');
+ const [description, setDescription] = useState('');
+ const [tags, setTags] = useState('');
+ const [isActive, setIsActive] = useState(true);
+ const [submitting, setSubmitting] = useState(false);
+ const [showMap, setShowMap] = useState>({});
+
+ // 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 = {
+ 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 (
+
+
+
+ {isLong ? (
+
+
+ );
+ };
+
+ const TI = TYPE_CONFIG[secretType];
+ const TIicon = TI?.icon || Key;
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/deploy/credential-manager/constants.ts b/frontend/src/components/deploy/credential-manager/constants.ts
new file mode 100644
index 0000000..096b843
--- /dev/null
+++ b/frontend/src/components/deploy/credential-manager/constants.ts
@@ -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 = {
+ 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 = {
+ 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',
+};
\ No newline at end of file
diff --git a/frontend/src/components/deploy/credential-manager/types.ts b/frontend/src/components/deploy/credential-manager/types.ts
new file mode 100644
index 0000000..ce14ab1
--- /dev/null
+++ b/frontend/src/components/deploy/credential-manager/types.ts
@@ -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';
\ No newline at end of file
diff --git a/frontend/src/components/kubernetes/DeploymentWorkload.tsx b/frontend/src/components/kubernetes/DeploymentWorkload.tsx
index d3f4262..74d357c 100644
--- a/frontend/src/components/kubernetes/DeploymentWorkload.tsx
+++ b/frontend/src/components/kubernetes/DeploymentWorkload.tsx
@@ -1,75 +1,42 @@
// DeploymentWorkload.tsx — Kuboard 风格 Deployment 详情主组件
// - 顶部操作菜单: 编辑 | 预览/编辑YAML | 调整镜像版本 | 伸缩 | 日志/终端 | 重启 | 暂停/恢复更新 | CI/CD发布 | 删除 | 元数据 | 运行时 | 服务 | 应用路由
// - 三模式: view(只读增强看板)/ edit(可视化编辑器)/ yaml(YAML编辑器)
-// 作者: Logan
+// 作者: 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' && (
- setModal(null)}>
-
-
-
- {deployment.image || '-'}
-
-
-
- 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" />
-
-
-
-
-
-
-
- )}
-
- {/* ============ 弹窗:伸缩 ============ */}
- {modal?.kind === 'scale' && (
- setModal(null)}>
-
- 当前副本: {deployment.replicas || 0}
-
-
- 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" />
-
-
-
-
-
-
-
-
- )}
-
- {/* ============ 弹窗:删除 ============ */}
- {modal?.kind === 'delete' && (
- setModal(null)}>
-
-
-
- 确认删除 Deployment {namespace}/{deploymentName}?
- 其管理的所有 Pod 将被一并回收。
-
-
-
-
-
-
-
- )}
+ 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
/>
);
-}
-
-// ============ 编辑模式 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('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 (
-
- {/* Tab 导航 */}
-
- {tabs.map(tab => {
- const active = activeTab === tab.key;
- const Icon = tab.icon;
- return (
-
- );
- })}
-
-
- {/* Tab 内容 */}
- {activeTab === 'basic' ? (
-
- ) : activeTab === 'containers' ? (
- /* M7: 完整容器编辑器 — 工作容器 + 初始化容器 */
-
- ) : activeTab === 'storage' ? (
- /* M8: 存储挂载 — volumes + volumeMounts 汇总 */
-
- ) : activeTab === 'advanced' ? (
- /* M8: 高级设置 — 更新策略/调度/DNS/安全上下文/终止宽限期 */
-
- ) : activeTab === 'service' ? (
- /* M9: 服务/应用路由 — 关联 Service + Ingress,可创建 */
-
- ) : null}
-
- );
-}
-
-// ============ 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 (
-
-
-
-
- {webhookUrl}
-
-
-
-
-
-
- {curlCmd}
-
-
-
-
- • 使用 Bearer Token 认证(JWT 登录令牌)
- • image 必填,指定新镜像地址
- • container 可选,不填则更新第一个容器
-
-
- );
-}
-
-// ============ 通用 Modal 外壳 ============
-function ModalShell({ title, children, onClose }: { title: string; children: React.ReactNode; onClose: () => void }) {
- return (
-
- e.stopPropagation()}>
-
- {title}
-
-
- {children}
-
-
- );
-}
-
-// ============ 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 = 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 (
-
- e.stopPropagation()}>
-
-
-
- {podName}
- {container && {container}}
-
-
- {containers.length > 0 && (
-
- )}
- 最近
- setTail(Number(e.target.value) || 200)}
- className="w-16 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs" />
- 行
-
-
-
-
-
-
-
- {logs || '无日志内容'}
-
-
-
-
- );
-}
+}
\ No newline at end of file
diff --git a/frontend/src/components/kubernetes/deployment-workload/ActionModals.tsx b/frontend/src/components/kubernetes/deployment-workload/ActionModals.tsx
new file mode 100644
index 0000000..2929019
--- /dev/null
+++ b/frontend/src/components/kubernetes/deployment-workload/ActionModals.tsx
@@ -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' && (
+
+
+
+
+ {deployment.image || '-'}
+
+
+
+ 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" />
+
+
+
+
+
+
+
+ )}
+
+ {/* ============ 弹窗:伸缩 ============ */}
+ {modal?.kind === 'scale' && (
+
+
+ 当前副本: {deployment.replicas || 0}
+
+
+ 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" />
+
+
+
+
+
+
+
+
+ )}
+
+ {/* ============ 弹窗:删除 ============ */}
+ {modal?.kind === 'delete' && (
+
+
+
+
+ 确认删除 Deployment {namespace}/{deploymentName}?
+ 其管理的所有 Pod 将被一并回收。
+
+
+
+
+
+
+
+ )}
+ >
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/kubernetes/deployment-workload/CICDWebhookContent.tsx b/frontend/src/components/kubernetes/deployment-workload/CICDWebhookContent.tsx
new file mode 100644
index 0000000..0e2157e
--- /dev/null
+++ b/frontend/src/components/kubernetes/deployment-workload/CICDWebhookContent.tsx
@@ -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 (
+
+
+
+
+ {webhookUrl}
+
+
+
+
+
+
+ {curlCmd}
+
+
+
+
+ • 使用 Bearer Token 认证(JWT 登录令牌)
+ • image 必填,指定新镜像地址
+ • container 可选,不填则更新第一个容器
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/kubernetes/deployment-workload/EditModeContainer.tsx b/frontend/src/components/kubernetes/deployment-workload/EditModeContainer.tsx
new file mode 100644
index 0000000..f78ba75
--- /dev/null
+++ b/frontend/src/components/kubernetes/deployment-workload/EditModeContainer.tsx
@@ -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('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 (
+
+ {/* Tab 导航 */}
+
+ {tabs.map(tab => {
+ const active = activeTab === tab.key;
+ const Icon = tab.icon;
+ return (
+
+ );
+ })}
+
+
+ {/* Tab 内容 */}
+ {activeTab === 'basic' ? (
+
+ ) : activeTab === 'containers' ? (
+ /* M7: 完整容器编辑器 — 工作容器 + 初始化容器 */
+
+ ) : activeTab === 'storage' ? (
+ /* M8: 存储挂载 — volumes + volumeMounts 汇总 */
+
+ ) : activeTab === 'advanced' ? (
+ /* M8: 高级设置 — 更新策略/调度/DNS/安全上下文/终止宽限期 */
+
+ ) : activeTab === 'service' ? (
+ /* M9: 服务/应用路由 — 关联 Service + Ingress,可创建 */
+
+ ) : null}
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/kubernetes/deployment-workload/ModalShell.tsx b/frontend/src/components/kubernetes/deployment-workload/ModalShell.tsx
new file mode 100644
index 0000000..1ccdc98
--- /dev/null
+++ b/frontend/src/components/kubernetes/deployment-workload/ModalShell.tsx
@@ -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 (
+
+ e.stopPropagation()}>
+
+ {title}
+
+
+ {children}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/kubernetes/deployment-workload/PodLogsModal.tsx b/frontend/src/components/kubernetes/deployment-workload/PodLogsModal.tsx
new file mode 100644
index 0000000..5b21078
--- /dev/null
+++ b/frontend/src/components/kubernetes/deployment-workload/PodLogsModal.tsx
@@ -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 = 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 (
+
+ e.stopPropagation()}>
+
+
+
+ {podName}
+ {container && {container}}
+
+
+ {containers.length > 0 && (
+
+ )}
+ 最近
+ setTail(Number(e.target.value) || 200)}
+ className="w-16 h-7 bg-slate-800 border-slate-700 text-slate-200 text-xs" />
+ 行
+
+
+
+
+
+
+
+ {logs || '无日志内容'}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/kubernetes/deployment-workload/types.ts b/frontend/src/components/kubernetes/deployment-workload/types.ts
new file mode 100644
index 0000000..a78e10b
--- /dev/null
+++ b/frontend/src/components/kubernetes/deployment-workload/types.ts
@@ -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;
\ No newline at end of file
diff --git a/frontend/src/components/monitor/datasource/PrometheusDatasourceManager.tsx b/frontend/src/components/monitor/datasource/PrometheusDatasourceManager.tsx
index 30c386a..2214528 100644
--- a/frontend/src/components/monitor/datasource/PrometheusDatasourceManager.tsx
+++ b/frontend/src/components/monitor/datasource/PrometheusDatasourceManager.tsx
@@ -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([]);
- const [loading, setLoading] = useState(true);
+ const { data: sources = [], isLoading, isError } = usePromDatasources();
+ const { deleteMutation, setDefaultMutation } = usePromDatasourceMutations();
+
const [dialogOpen, setDialogOpen] = useState(false);
- const [editingId, setEditingId] = useState(null);
- const [form, setForm] = useState({ ...defaultForm });
+ const [editingDs, setEditingDs] = useState(null);
- // 自定义 HTTP Headers(动态键值对)
- const [headers, setHeaders] = useState([]);
- // 折叠面板
- 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 = 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 = {
- 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 = 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 (
@@ -434,413 +209,12 @@ export default function PrometheusDatasourceManager() {
- {/* ==================== 添加/编辑 Dialog ==================== */}
-
+
);
-}
-
-// 快速测试连接(列表中的测试按钮)
-async function handleTestQuick(ds: PromDS) {
- try {
- const res: Record = 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 || '测试失败');
- }
-}
+}
\ No newline at end of file
diff --git a/frontend/src/components/monitor/datasource/prometheus-ds/PromDsFormDialog.tsx b/frontend/src/components/monitor/datasource/prometheus-ds/PromDsFormDialog.tsx
new file mode 100644
index 0000000..739569c
--- /dev/null
+++ b/frontend/src/components/monitor/datasource/prometheus-ds/PromDsFormDialog.tsx
@@ -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({ ...defaultForm });
+ const [headers, setHeaders] = useState([]);
+ 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(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 (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/components/monitor/datasource/prometheus-ds/helpers.ts b/frontend/src/components/monitor/datasource/prometheus-ds/helpers.ts
new file mode 100644
index 0000000..10542ee
--- /dev/null
+++ b/frontend/src/components/monitor/datasource/prometheus-ds/helpers.ts
@@ -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 {
+ 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 {
+ const res: Record = 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 {
+ 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);
+ }
+}
\ No newline at end of file
diff --git a/frontend/src/components/monitor/datasource/prometheus-ds/types.ts b/frontend/src/components/monitor/datasource/prometheus-ds/types.ts
new file mode 100644
index 0000000..858066f
--- /dev/null
+++ b/frontend/src/components/monitor/datasource/prometheus-ds/types.ts
@@ -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;
\ No newline at end of file
diff --git a/frontend/src/components/server/ServerManager.tsx b/frontend/src/components/server/ServerManager.tsx
index bdd9410..7cdfd70 100644
--- a/frontend/src/components/server/ServerManager.tsx
+++ b/frontend/src/components/server/ServerManager.tsx
@@ -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([]);
- const [loading, setLoading] = useState(true);
const [selectedServers, setSelectedServers] = useState([]);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('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 = { 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 = { page, page_size: pageSize };
- if (q) params.keyword = q;
- if (s && s !== 'all') params.status = s;
- const res: Record = 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(null);
const reconnectTimerRef = useRef | 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() {
|