feat: 发布权限 RBAC + PipelineEditor 修复 + 菜单优化
- 新增 DeployRBAC 页面: Jenkins-style Item Role 权限管理 - 正则匹配流水线 code,实时命中预览 - 内置用户分配面板,无需跳转系统管理 - 支持创建/编辑/删除/启用禁用 Item Role - 修复 PipelineEditor 成员管理 http 双解包 bug - 修复 PipelineManager 前端 code 路由迁移(delete/trigger/stop/loadRuns) - 新增批量删除流水线运行记录 API - Sidebar: 应用发布下新增「发布权限」, 仓库认证→发布配置, 移除用户组 - 恢复系统管理菜单(不受发布权限改动影响) - 后端 ItemRole CRUD handler + 正则匹配中间件已就绪
This commit is contained in:
1 parent
0fea0ccc20
commit
24f955832e
40 files changed
+7530
-1032
No files matched your search
+12
-5
@@ -24,13 +24,16 @@ import PodMetricsPage from '@/components/kubernetes/PodMetricsPage';
|
||||
import EventsPage from '@/components/kubernetes/EventsPage';
|
||||
import CapacityPage from '@/components/kubernetes/CapacityPage';
|
||||
import { AlertManager } from '@/components/monitor/AlertManager';
|
||||
import { AppReleaseManager } from '@/components/deploy/AppRelease';
|
||||
import { ApprovalFlowManager } from '@/components/approval/ApprovalFlow';
|
||||
import { ScriptManager } from '@/components/automation/ScriptManager';
|
||||
import { SystemManager } from '@/components/system/SystemManager';
|
||||
import { CredentialManager } from '@/components/system/CredentialManager';
|
||||
import CredentialManager from '@/components/deploy/CredentialManager';
|
||||
import { PipelineManager } from '@/components/deploy/PipelineManager';
|
||||
import { ReleaseManager } from '@/components/deploy/ReleaseManager';
|
||||
import DeployCredentials from '@/components/deploy/DeployCredentials';
|
||||
import PipelineEditor from '@/components/deploy/PipelineEditor';
|
||||
import PipelineRunDetail from '@/components/deploy/PipelineRunDetail';
|
||||
import DeployRBAC from '@/components/deploy/DeployRBAC';
|
||||
import UserGroupsManager from '@/components/deploy/UserGroupsManager';
|
||||
import { CronJobManager } from '@/components/automation/CronJobManager';
|
||||
import AnsibleManager from '@/components/automation/AnsibleManager';
|
||||
import AnsibleWorkflow from '@/components/automation/AnsibleWorkflow';
|
||||
@@ -136,8 +139,12 @@ function App() {
|
||||
<Route path="topology" element={<TopologyView />} />
|
||||
{/* 应用发布 */}
|
||||
<Route path="pipelines" element={<PipelineManager />} />
|
||||
<Route path="releases" element={<ReleaseManager />} />
|
||||
<Route path="deploy" element={<AppReleaseManager />} />
|
||||
<Route path="pipelines/:code" element={<PipelineEditor />} />
|
||||
<Route path="pipelines/:code/runs" element={<PipelineRunDetail />} />
|
||||
<Route path="pipelines/:code/edit" element={<PipelineEditor />} />
|
||||
<Route path="deploy/credentials" element={<DeployCredentials />} />
|
||||
<Route path="deploy/groups" element={<UserGroupsManager />} />
|
||||
<Route path="deploy/rbac" element={<DeployRBAC />} />
|
||||
{/* 监控告警 */}
|
||||
<Route path="metrics" element={<MetricsExplorer />} />
|
||||
<Route path="datasources" element={<DatasourcePage />} />
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
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 { Plus, Key, Globe, Container } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { deployApi } from '@/services/api';
|
||||
import http from '@/services/http';
|
||||
|
||||
interface Repo {
|
||||
id: number; name: string; url: string; credential_id: number | null;
|
||||
}
|
||||
interface Registry {
|
||||
id: number; name: string; registry_url: string; namespace: string; credential_id: number | null;
|
||||
}
|
||||
interface Credential {
|
||||
id: number; name: string; secret_type: string; summary: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onOpenChange: (v: boolean) => void;
|
||||
type: 'repo' | 'registry';
|
||||
editId: number | null;
|
||||
repos: Repo[];
|
||||
registries: Registry[];
|
||||
onSave: () => void;
|
||||
}
|
||||
|
||||
export default function CredentialFormDialog({ open, onOpenChange, type, editId, repos, registries, onSave }: Props) {
|
||||
const isRepo = type === 'repo';
|
||||
const [name, setName] = useState('');
|
||||
const [desc, setDesc] = useState('');
|
||||
const [url, setUrl] = useState('');
|
||||
const [namespace, setNamespace] = useState('');
|
||||
const [credentialId, setCredentialId] = useState<number | string>('');
|
||||
const [credentials, setCredentials] = useState<Credential[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Docker 仓库 → docker 类型;Git 仓库 → api 或 docker 类型
|
||||
const filteredCredentials = credentials.filter(c =>
|
||||
isRepo ? true : c.secret_type === 'docker'
|
||||
);
|
||||
|
||||
const loadCredentials = async () => {
|
||||
try {
|
||||
const res: any = await http.get('/credentials?page_size=100');
|
||||
setCredentials(res?.list || []);
|
||||
} catch { /* silent */ }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadCredentials();
|
||||
if (editId) {
|
||||
if (isRepo) {
|
||||
const r = repos.find(x => x.id === editId);
|
||||
if (r) { setName(r.name); setDesc(''); setUrl(r.url); setCredentialId(r.credential_id || ''); }
|
||||
} else {
|
||||
const r = registries.find(x => x.id === editId);
|
||||
if (r) { setName(r.name); setDesc(''); setUrl(r.registry_url); setNamespace(r.namespace || ''); setCredentialId(r.credential_id || ''); }
|
||||
}
|
||||
} else {
|
||||
setName(''); setDesc(''); setUrl(''); setNamespace(''); setCredentialId('');
|
||||
}
|
||||
}, [editId, open, isRepo]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim() || !url.trim()) { toast.error('请填写名称和地址'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
const cid = credentialId ? Number(credentialId) : null;
|
||||
const payload = isRepo
|
||||
? { name: name.trim(), description: desc.trim(), url: url.trim(), credential_id: cid }
|
||||
: { name: name.trim(), description: desc.trim(), registry_url: url.trim(), namespace: namespace.trim(), credential_id: cid };
|
||||
if (editId) {
|
||||
if (isRepo) await deployApi.updateRepo(editId, payload);
|
||||
else await deployApi.updateRegistry(editId, payload);
|
||||
toast.success('已更新');
|
||||
} else {
|
||||
if (isRepo) await deployApi.createRepo(payload);
|
||||
else await deployApi.createRegistry(payload);
|
||||
toast.success('已创建');
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSave();
|
||||
} catch { toast.error('保存失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const CredIcon = isRepo ? Globe : Container;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[480px] bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<CredIcon className="h-5 w-5 text-[var(--dg-blue)]" />
|
||||
{editId ? '编辑' : '新增'}{isRepo ? ' Git 仓库' : ' 镜像仓库'}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1"><Label>名称 *</Label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} placeholder={isRepo ? '如:GitLab CE' : '如:Harbor'} className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>
|
||||
<div className="space-y-1"><Label>{isRepo ? '仓库 URL *' : 'Registry URL *'}</Label>
|
||||
<Input value={url} onChange={e => setUrl(e.target.value)} placeholder={isRepo ? 'http://10.10.10.72:9000/group/repo.git' : 'http://10.10.10.72'} className="font-mono text-sm bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>
|
||||
{!isRepo && <div className="space-y-1"><Label>默认命名空间</Label>
|
||||
<Input value={namespace} onChange={e => setNamespace(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>关联凭证 <span className="text-xs text-[var(--dg-text-secondary)] opacity-70">(认证由凭据管理统一配置)</span></Label>
|
||||
<Select value={String(credentialId)} onValueChange={v => setCredentialId(v === '__none' ? '' : v)}>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<SelectValue placeholder={isRepo ? '选择凭据(API/密码等)' : '选择 Docker 密码凭据'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">不关联凭证</SelectItem>
|
||||
{filteredCredentials.map(c => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Key className="h-3 w-3 opacity-60" />
|
||||
{c.name} {c.summary && <span className="text-[10px] opacity-50">({c.summary})</span>}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{filteredCredentials.length === 0 && (
|
||||
<p className="text-xs text-amber-400 flex items-center gap-1 mt-1">
|
||||
<Plus className="h-3 w-3" />
|
||||
暂无{isRepo ? '' : 'Docker 密码'}类型凭证,请在凭证配置页面新增
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>{saving ? '保存中...' : '保存'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,822 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
Plus, Pencil, Trash2, RefreshCw, Key, Shield, Globe, Search,
|
||||
Lock, Eye, EyeOff, Copy, Server, Container, Database, FileText,
|
||||
Cloud, Tag, Clock, AlertTriangle, 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 http from '@/services/http';
|
||||
|
||||
// ────── 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);
|
||||
http.get(`/credentials/${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 [akskProvider, setAkskProvider] = useState('');
|
||||
const [akskAccessKey, setAkskAccessKey] = useState('');
|
||||
const [akskSecretKey, setAkskSecretKey] = useState('');
|
||||
const [akskRegion, 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: 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 http.put(`/credentials/${editData!.id}`, payload);
|
||||
toast.success('已更新');
|
||||
} else {
|
||||
await http.post('/credentials', 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 ──────
|
||||
|
||||
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>('');
|
||||
const [filterSearch, setFilterSearch] = useState('');
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (filterType) params.set('secret_type', filterType);
|
||||
if (filterSearch) params.set('search', filterSearch);
|
||||
params.set('page_size', '100');
|
||||
const res: any = await http.get(`/credentials?${params.toString()}`);
|
||||
setCredentials(res?.list || []);
|
||||
} catch { toast.error('获取凭证列表失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, [filterType, filterSearch]);
|
||||
|
||||
useEffect(() => { fetchAll(); }, [fetchAll]);
|
||||
|
||||
const handleDelete = async (cred: Credential) => {
|
||||
const ok = await confirm({
|
||||
title: '删除凭据',
|
||||
description: `确定删除凭据「${cred.name}」?此操作不可撤销。`,
|
||||
confirmText: '删除',
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try {
|
||||
await http.delete(`/credentials/${cred.id}`);
|
||||
toast.success('已删除');
|
||||
fetchAll();
|
||||
} catch { toast.error('删除失败'); }
|
||||
};
|
||||
|
||||
const handleToggleActive = async (cred: Credential) => {
|
||||
try {
|
||||
await http.put(`/credentials/${cred.id}`, { is_active: !cred.is_active });
|
||||
toast.success(cred.is_active ? '已禁用' : '已启用');
|
||||
fetchAll();
|
||||
} catch { toast.error('操作失败'); }
|
||||
};
|
||||
|
||||
const handleCopyPrefix = (prefix: string) => {
|
||||
navigator.clipboard.writeText(prefix);
|
||||
toast.success('引用 ID 已复制');
|
||||
};
|
||||
|
||||
const typeOptions = Object.entries(TYPE_CONFIG) as [SecretType, typeof TYPE_CONFIG[SecretType]][];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">凭据管理</h2>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-0.5">
|
||||
统一管理 SSH 密钥、Docker 密码、AKSK、数据库等敏感凭据(AES 加密存储)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchAll}>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />刷新
|
||||
</Button>
|
||||
<Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新增凭据
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search/Filter */}
|
||||
<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">
|
||||
<SelectValue placeholder="全部类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
{typeOptions.map(([k, v]) => (
|
||||
<SelectItem key={k} value={k}>
|
||||
<span className="flex items-center gap-2"><v.icon className="h-3.5 w-3.5" />{v.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="relative flex-1 max-w-xs">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<Input value={filterSearch} onChange={e => setFilterSearch(e.target.value)}
|
||||
placeholder="搜索名称、描述..."
|
||||
className="pl-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] h-9 text-sm" />
|
||||
</div>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{credentials.length} 个凭据</span>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-16 text-[var(--dg-text-secondary)]">
|
||||
<Zap className="w-6 h-6 animate-spin mx-auto mb-2 text-[var(--dg-blue)]" />加载中...
|
||||
</div>
|
||||
) : credentials.length === 0 ? (
|
||||
<div className="text-center py-16 border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
<Shield className="w-12 h-12 mx-auto mb-3 text-[var(--dg-text-muted)]" />
|
||||
<p className="text-[var(--dg-text-secondary)] mb-2">暂无凭据</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-4">创建 SSH、Docker、AKSK 等类型的凭据,供流水线和服务器使用</p>
|
||||
<Button onClick={() => { setEditing(null); setDialogOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />创建第一个凭据
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">名称</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">类型</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">摘要</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden lg:table-cell">标签</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden xl:table-cell">引用ID</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{credentials.map(cred => {
|
||||
const st = cred.secret_type as SecretType;
|
||||
const cfg = TYPE_CONFIG[st];
|
||||
const isExpired = cred.expires_at && new Date(cred.expires_at) < new Date();
|
||||
return (
|
||||
<tr key={cred.id}
|
||||
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50 cursor-pointer"
|
||||
onClick={() => { setDetailId(cred.id); setDetailOpen(true); }}>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{cfg ? <cfg.icon className={`h-4 w-4 ${cfg.color} shrink-0`} /> : <Key className="h-4 w-4 text-[var(--dg-text-muted)] shrink-0" />}
|
||||
<div>
|
||||
<div className="font-medium text-[var(--dg-text-primary)]">{cred.name}</div>
|
||||
{cred.variable_name && (
|
||||
<div className="text-xs text-[var(--dg-text-muted)] font-mono">{cred.variable_name}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge variant="outline" className={`text-xs ${TYPE_BADGE_CLASS[st] || ''}`}>
|
||||
{cfg?.label || st}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] text-xs hidden md:table-cell max-w-[180px] truncate font-mono">
|
||||
{cred.summary || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 hidden lg:table-cell">
|
||||
<div className="flex gap-1 flex-wrap">
|
||||
{(cred.tags || []).slice(0, 3).map(t => (
|
||||
<Badge key={t} variant="outline" className="text-xs border-[var(--dg-border)] text-[var(--dg-text-muted)]">{t}</Badge>
|
||||
))}
|
||||
{(cred.tags || []).length > 3 && (
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">+{cred.tags.length - 3}</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 hidden xl:table-cell">
|
||||
<code className="text-xs text-[var(--dg-blue)] cursor-pointer font-mono"
|
||||
onClick={(e) => { e.stopPropagation(); handleCopyPrefix(cred.env_prefix); }}>
|
||||
{cred.env_prefix}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<div className="flex items-center gap-1">
|
||||
{cred.is_active ? (
|
||||
<Badge className="bg-green-500/20 text-green-400 text-xs border-0">启用</Badge>
|
||||
) : (
|
||||
<Badge className="bg-red-500/20 text-red-400 text-xs border-0">禁用</Badge>
|
||||
)}
|
||||
{isExpired && (
|
||||
<Badge className="bg-yellow-500/20 text-yellow-400 text-xs border-0"><Clock className="h-2.5 w-2.5 mr-0.5" />过期</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<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="编辑">
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7"
|
||||
onClick={() => handleToggleActive(cred)}
|
||||
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="删除">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CredFormDialog open={dialogOpen} onClose={() => setDialogOpen(false)} onSaved={fetchAll} editData={editing} />
|
||||
<CredDetailDialog open={detailOpen} onClose={() => setDetailOpen(false)} credId={detailId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Pencil, Trash2, RefreshCw, Loader2, GitBranch, Container, Shield, Globe } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { deployApi } from '@/services/api';
|
||||
import CredentialFormDialog from './CredentialFormDialog';
|
||||
|
||||
interface Repo {
|
||||
id: number; name: string; description: string; url: string;
|
||||
auth_type: string; credential_id: number | null; enabled: boolean;
|
||||
created_by: string; updated_at: string;
|
||||
}
|
||||
interface Registry {
|
||||
id: number; name: string; description: string; registry_url: string; namespace: string;
|
||||
auth_type: string; credential_id: number | null; enabled: boolean;
|
||||
created_by: string; updated_at: string;
|
||||
}
|
||||
interface Environment {
|
||||
id: number; name: string; display_name: string; type: string; color: string;
|
||||
sort_order: number; description: string; enabled: boolean;
|
||||
}
|
||||
|
||||
function EnvFormDialog({ open, onClose, onSaved, editData }: { open: boolean; onClose: () => void; onSaved: () => void; editData: Environment | null }) {
|
||||
const [name, setName] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [envType, setEnvType] = useState('development');
|
||||
const [color, setColor] = useState('#3B82F6');
|
||||
const [description, setDescription] = useState('');
|
||||
const [sortOrder, setSortOrder] = useState(50);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (editData) {
|
||||
setName(editData.name);
|
||||
setDisplayName(editData.display_name);
|
||||
setEnvType(editData.type);
|
||||
setColor(editData.color);
|
||||
setDescription(editData.description || '');
|
||||
setSortOrder(editData.sort_order);
|
||||
} else {
|
||||
setName(''); setDisplayName(''); setEnvType('development'); setColor('#3B82F6'); setDescription(''); setSortOrder(50);
|
||||
}
|
||||
}, [editData, open]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim() || !displayName.trim()) { toast.error('请填写名称'); return; }
|
||||
setSubmitting(true);
|
||||
try {
|
||||
if (editData) await deployApi.updateEnvironment(editData.id, { name, display_name: displayName, type: envType, color, sort_order: sortOrder, description });
|
||||
else await deployApi.createEnvironment({ name, display_name: displayName, type: envType, color, sort_order: sortOrder, description });
|
||||
toast.success(editData ? '已更新' : '已创建');
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch { toast.error('操作失败'); }
|
||||
finally { setSubmitting(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{editData ? '编辑环境' : '新增环境'}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1"><Label>环境标识</Label><Input value={name} onChange={e => setName(e.target.value)} placeholder="production" /></div>
|
||||
<div className="space-y-1"><Label>显示名称</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="生产环境" /></div>
|
||||
<div className="space-y-1"><Label>环境类型</Label>
|
||||
<Select value={envType} onValueChange={setEnvType}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="production">生产环境</SelectItem>
|
||||
<SelectItem value="staging">预发布</SelectItem>
|
||||
<SelectItem value="testing">测试环境</SelectItem>
|
||||
<SelectItem value="development">开发环境</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1"><Label>标志颜色</Label><Input value={color} onChange={e => setColor(e.target.value)} type="color" className="w-16 h-9 p-1" /></div>
|
||||
<div className="space-y-1"><Label>排序序号</Label><Input value={sortOrder} onChange={e => setSortOrder(Number(e.target.value))} type="number" /></div>
|
||||
<div className="space-y-1"><Label>描述</Label><Input value={description} onChange={e => setDescription(e.target.value)} placeholder="可选" /></div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="outline" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? '...' : editData ? '保存' : '创建'}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DeployCredentials() {
|
||||
const _confirm = useConfirm();
|
||||
const [repos, setRepos] = useState<Repo[]>([]);
|
||||
const [registries, setRegistries] = useState<Registry[]>([]);
|
||||
const [environments, setEnvironments] = useState<Environment[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [editType, setEditType] = useState<'repo' | 'registry'>('repo');
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [envDialogOpen, setEnvDialogOpen] = useState(false);
|
||||
const [editEnv, setEditEnv] = useState<Environment | null>(null);
|
||||
|
||||
const fetchAll = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [rR, rReg, rEnv] = await Promise.all([deployApi.getRepos(), deployApi.getRegistries(), deployApi.getEnvironments()]);
|
||||
setRepos((rR as any)?.list || []);
|
||||
setRegistries((rReg as any)?.list || []);
|
||||
setEnvironments((rEnv as any)?.list || []);
|
||||
} catch { toast.error('获取数据失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { fetchAll(); }, []);
|
||||
|
||||
const handleDelete = async (type: 'repo' | 'registry' | 'env', item: any) => {
|
||||
const ok = await _confirm({ title: '删除', description: `确定删除「${item.name || item.display_name}」?`, confirmText: '删除', variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
if (type === 'repo') await deployApi.deleteRepo(item.id);
|
||||
else if (type === 'registry') await deployApi.deleteRegistry(item.id);
|
||||
else await deployApi.deleteEnvironment(item.id);
|
||||
toast.success('已删除');
|
||||
fetchAll();
|
||||
} catch { toast.error('删除失败'); }
|
||||
};
|
||||
|
||||
const envTypeLabel: Record<string, string> = { production: '生产', staging: '预发布', testing: '测试', development: '开发' };
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">发布配置管理</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={fetchAll}><RefreshCw className="w-4 h-4 mr-1" />刷新</Button>
|
||||
<Button size="sm" onClick={() => { setEditType('repo'); setEditingId(null); setEditorOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新增 Git 仓库
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => { setEditType('registry'); setEditingId(null); setEditorOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新增镜像仓库
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs defaultValue="repos">
|
||||
<TabsList>
|
||||
<TabsTrigger value="repos" className="gap-1"><GitBranch className="w-4 h-4" />Git 仓库 ({repos.length})</TabsTrigger>
|
||||
<TabsTrigger value="registries" className="gap-1"><Container className="w-4 h-4" />镜像仓库 ({registries.length})</TabsTrigger>
|
||||
<TabsTrigger value="environments" className="gap-1"><Globe className="w-4 h-4" />环境管理 ({environments.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="repos" className="mt-3">
|
||||
{loading ? <div className="text-center py-8 text-[var(--dg-text-secondary)]"><Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />加载中...</div>
|
||||
: repos.length === 0 ? <div className="text-center py-8 text-[var(--dg-text-secondary)]">暂无 Git 仓库认证配置</div>
|
||||
: (
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">名称</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">仓库 URL</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">认证方式</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{repos.map(r => (
|
||||
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">{r.name}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden md:table-cell font-mono text-xs max-w-[300px] truncate">{r.url}</td>
|
||||
<td className="px-3 py-2"><Badge variant="outline" className="text-xs">{r.auth_type}</Badge></td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={r.enabled ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{r.enabled ? '启用' : '禁用'}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditType('repo'); setEditingId(r.id); setEditorOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('repo', r)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="registries" className="mt-3">
|
||||
{loading ? <div className="text-center py-8 text-[var(--dg-text-secondary)]"><Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />加载中...</div>
|
||||
: registries.length === 0 ? <div className="text-center py-8 text-[var(--dg-text-secondary)]">暂无镜像仓库认证配置</div>
|
||||
: (
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">名称</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">Registry URL</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden sm:table-cell">命名空间</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">认证</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{registries.map(r => (
|
||||
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">{r.name}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden md:table-cell font-mono text-xs max-w-[250px] truncate">{r.registry_url}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden sm:table-cell">{r.namespace || '-'}</td>
|
||||
<td className="px-3 py-2"><Badge variant="outline" className="text-xs">{r.auth_type}</Badge></td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={r.enabled ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{r.enabled ? '启用' : '禁用'}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditType('registry'); setEditingId(r.id); setEditorOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('registry', r)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* Environments tab */}
|
||||
<TabsContent value="environments" className="mt-3">
|
||||
<div className="flex items-center justify-end mb-3">
|
||||
<Button size="sm" onClick={() => { setEditEnv(null); setEnvDialogOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新增环境
|
||||
</Button>
|
||||
</div>
|
||||
{loading ? <div className="text-center py-8 text-[var(--dg-text-secondary)]"><Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />加载中...</div>
|
||||
: environments.length === 0 ? <div className="text-center py-8 text-[var(--dg-text-secondary)]">暂无环境配置,点击"新增环境"创建</div>
|
||||
: (
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">环境标识</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">显示名称</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">类型</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">颜色</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">排序</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{environments.map(env => (
|
||||
<tr key={env.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2 font-mono text-xs font-medium text-[var(--dg-text-primary)]">{env.name}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-primary)]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2.5 h-2.5 rounded-full inline-block" style={{ backgroundColor: env.color }} />
|
||||
{env.display_name}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2"><Badge variant="secondary" className="text-xs">{envTypeLabel[env.type] || env.type}</Badge></td>
|
||||
<td className="px-3 py-2"><div className="w-6 h-6 rounded border" style={{ backgroundColor: env.color }} /></td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)]">{env.sort_order}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={env.enabled ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-500/20 text-slate-400 border-slate-500/30'}>{env.enabled ? '启用' : '禁用'}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditEnv(env); setEnvDialogOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('env', env)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<CredentialFormDialog
|
||||
open={editorOpen} onOpenChange={setEditorOpen} type={editType} editId={editingId}
|
||||
repos={repos} registries={registries} onSave={fetchAll}
|
||||
/>
|
||||
|
||||
<EnvFormDialog open={envDialogOpen} onClose={() => setEnvDialogOpen(false)} onSaved={fetchAll} editData={editEnv} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Shield, Plus, Trash2, Save, Loader2, Users, Regex, Check, X, GitBranch, UserPlus, Search } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from 'sonner';
|
||||
import { deployApi, userApi, pipelineApi } from '@/services/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
|
||||
interface ItemRole {
|
||||
id: number; name: string; code: string; pattern: string; role: string;
|
||||
description: string; status: number; created_at: string;
|
||||
}
|
||||
|
||||
interface AssignedUser {
|
||||
id: number; user_id: number; item_role_id: number;
|
||||
user?: { id: number; username: string; real_name: string };
|
||||
item_role?: ItemRole;
|
||||
}
|
||||
|
||||
export default function DeployRBAC() {
|
||||
const [roles, setRoles] = useState<ItemRole[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Pipeline list for live preview
|
||||
const [pipelines, setPipelines] = useState<any[]>([]);
|
||||
const [previewRoleId, setPreviewRoleId] = useState<number | null>(null);
|
||||
|
||||
// User assignment
|
||||
const [users, setUsers] = useState<any[]>([]);
|
||||
const [assignments, setAssignments] = useState<AssignedUser[]>([]);
|
||||
const [selectedUserId, setSelectedUserId] = useState<string>('');
|
||||
const [assignRoleId, setAssignRoleId] = useState<string>('');
|
||||
const [assignLoading, setAssignLoading] = useState(false);
|
||||
|
||||
// Create
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState('');
|
||||
const [newCode, setNewCode] = useState('');
|
||||
const [newPattern, setNewPattern] = useState('');
|
||||
const [newRole, setNewRole] = useState('editor');
|
||||
const [newDesc, setNewDesc] = useState('');
|
||||
|
||||
// Edit
|
||||
const [editing, setEditing] = useState<number | null>(null);
|
||||
const [editPattern, setEditPattern] = useState('');
|
||||
const [editRole, setEditRole] = useState('');
|
||||
|
||||
const _confirm = useConfirm();
|
||||
|
||||
const loadData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [rolesRes, pipesRes, usersRes] = await Promise.all([
|
||||
deployApi.getItemRoles(),
|
||||
pipelineApi.getPipelines({ page_size: 200 }),
|
||||
userApi.getUsers({ page_size: 500 }),
|
||||
]);
|
||||
setRoles(Array.isArray(rolesRes) ? rolesRes : (rolesRes as any)?.list || (rolesRes as any)?.data || []);
|
||||
setPipelines(Array.isArray(pipesRes) ? pipesRes : (pipesRes as any)?.list || (pipesRes as any)?.data || []);
|
||||
const uList = Array.isArray(usersRes) ? usersRes : (usersRes as any)?.list || (usersRes as any)?.data || [];
|
||||
setUsers(uList);
|
||||
} catch { /* */ }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadData(); }, []);
|
||||
|
||||
const loadAssignments = async () => {
|
||||
if (!selectedUserId) { setAssignments([]); return; }
|
||||
try {
|
||||
const res: any = await deployApi.getUserItemRoles(Number(selectedUserId));
|
||||
setAssignments(Array.isArray(res) ? res : res?.data || res?.list || []);
|
||||
} catch { setAssignments([]); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadAssignments(); }, [selectedUserId]);
|
||||
|
||||
// Compute matched pipeline codes for each role
|
||||
const matchCount = useMemo(() => {
|
||||
const map: Record<number, string[]> = {};
|
||||
roles.forEach(r => {
|
||||
try {
|
||||
const re = new RegExp(r.pattern);
|
||||
map[r.id] = pipelines.filter(p => re.test(p.code)).map(p => p.code);
|
||||
} catch { map[r.id] = []; }
|
||||
});
|
||||
return map;
|
||||
}, [roles, pipelines]);
|
||||
|
||||
const previewRole = previewRoleId ? roles.find(r => r.id === previewRoleId) : null;
|
||||
const previewMatches = previewRole ? (matchCount[previewRoleId!] || []) : [];
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim() || !newCode.trim() || !newPattern.trim()) {
|
||||
toast.error('名称、代码、正则表达式不能为空'); return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
await deployApi.createItemRole({ name: newName.trim(), code: newCode.trim(), pattern: newPattern.trim(), role: newRole, description: newDesc });
|
||||
toast.success('已创建');
|
||||
setShowCreate(false);
|
||||
setNewName(''); setNewCode(''); setNewPattern(''); setNewRole('editor'); setNewDesc('');
|
||||
loadData();
|
||||
} catch (e: any) { toast.error(e?.message || '创建失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: number) => {
|
||||
if (!editPattern.trim()) { toast.error('正则不能为空'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await deployApi.updateItemRole(id, { pattern: editPattern, role: editRole });
|
||||
toast.success('已更新');
|
||||
setEditing(null);
|
||||
loadData();
|
||||
} catch (e: any) { toast.error(e?.message || '更新失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (role: ItemRole) => {
|
||||
const ok = await _confirm({ title: '删除角色', description: `确定删除「${role.name}」?匹配 ${matchCount[role.id]?.length || 0} 条流水线`, variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try { await deployApi.deleteItemRole(role.id); toast.success('已删除'); loadData(); }
|
||||
catch { toast.error('删除失败'); }
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (role: ItemRole) => {
|
||||
try {
|
||||
await deployApi.updateItemRole(role.id, { status: role.status ? 0 : 1 });
|
||||
toast.success(role.status ? '已禁用' : '已启用');
|
||||
loadData();
|
||||
} catch { toast.error('操作失败'); }
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!selectedUserId || !assignRoleId) { toast.error('请选择用户和角色'); return; }
|
||||
setAssignLoading(true);
|
||||
try {
|
||||
await deployApi.assignItemRole(Number(selectedUserId), { user_id: Number(selectedUserId), item_role_id: Number(assignRoleId) });
|
||||
toast.success('已分配');
|
||||
setAssignRoleId('');
|
||||
loadAssignments();
|
||||
} catch (e: any) { toast.error(e?.message || '分配失败'); }
|
||||
finally { setAssignLoading(false); }
|
||||
};
|
||||
|
||||
const handleRevoke = async (id: number) => {
|
||||
try {
|
||||
await deployApi.removeItemRole(Number(selectedUserId), id);
|
||||
toast.success('已撤销');
|
||||
loadAssignments();
|
||||
} catch { toast.error('撤销失败'); }
|
||||
};
|
||||
|
||||
const roleBadgeClass = (role: string) =>
|
||||
role === 'owner' ? 'bg-amber-500/20 text-amber-400 border-amber-500/30' :
|
||||
role === 'editor' ? 'bg-blue-500/20 text-blue-400 border-blue-500/30' :
|
||||
'bg-slate-500/20 text-slate-400 border-slate-500/30';
|
||||
|
||||
const selectedUser = users.find(u => String(u.id) === selectedUserId);
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="w-6 h-6 animate-spin" /></div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold flex items-center gap-2 text-[var(--dg-text-primary)]">
|
||||
<Shield className="w-5 h-5 text-purple-500" />发布权限
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
|
||||
Jenkins-style Role Strategy — 按流水线 code 正则匹配分配角色
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreate(true)} disabled={showCreate}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建角色
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* ====== Pipeline Preview ====== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<GitBranch className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">流水线命中预览</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">— 选择一个角色查看哪些流水线会被匹配</span>
|
||||
</div>
|
||||
<div className="flex gap-2 mb-3 flex-wrap">
|
||||
<Button variant={previewRoleId === null ? 'secondary' : 'ghost'} size="sm" onClick={() => setPreviewRoleId(null)}>全部 ({pipelines.length})</Button>
|
||||
{roles.map(r => (
|
||||
<Button key={r.id} variant={previewRoleId === r.id ? 'secondary' : 'ghost'} size="sm" onClick={() => setPreviewRoleId(r.id)}
|
||||
className={r.status === 0 ? 'opacity-40' : ''}>
|
||||
{r.name} ({matchCount[r.id]?.length || 0})
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 max-h-32 overflow-y-auto">
|
||||
{previewRoleId === null
|
||||
? pipelines.map(p => <Badge key={p.id} variant="secondary" className="text-xs"><GitBranch className="w-3 h-3 mr-0.5" />{p.code}</Badge>)
|
||||
: pipelines.map(p => {
|
||||
const matched = previewMatches.includes(p.code);
|
||||
return (
|
||||
<Badge key={p.id} variant={matched ? 'default' : 'secondary'}
|
||||
className={`text-xs ${matched ? 'bg-purple-500/20 text-purple-300 border-purple-500/30' : 'opacity-30'}`}>
|
||||
<GitBranch className="w-3 h-3 mr-0.5" />{p.code}
|
||||
</Badge>
|
||||
);
|
||||
})
|
||||
}
|
||||
{previewRoleId !== null && previewMatches.length === 0 && (
|
||||
<span className="text-xs text-red-400">无匹配 — 请调整正则表达式</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create form */}
|
||||
{showCreate && (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-purple-500/20">
|
||||
<CardContent className="p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)] flex items-center gap-2"><Plus className="w-4 h-4" />新建 Item Role</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => setShowCreate(false)}>取消</Button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1"><Label className="text-xs">名称</Label><Input value={newName} onChange={e => setNewName(e.target.value)} placeholder="如: backend" className="h-8 text-sm" /></div>
|
||||
<div className="space-y-1"><Label className="text-xs">代码 (唯一标识)</Label><Input value={newCode} onChange={e => setNewCode(e.target.value)} placeholder="如: backend" className="h-8 text-sm font-mono" /></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs flex items-center gap-1"><Regex className="w-3 h-3" />正则匹配 Pattern</Label>
|
||||
<Input value={newPattern} onChange={e => { setNewPattern(e.target.value); }} placeholder='如: ^cex-.*' className="h-8 text-sm font-mono" />
|
||||
{newPattern && (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">
|
||||
实时匹配: {(() => { try { const n = pipelines.filter(p => new RegExp(newPattern).test(p.code)).length; return n > 0 ? `✅ 命中 ${n} 条流水线` : '⚠️ 无匹配'; } catch { return '❌ 正则语法错误'; } })()}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">角色</Label>
|
||||
<Select value={newRole} onValueChange={setNewRole}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">拥有者 (owner)</SelectItem>
|
||||
<SelectItem value="editor">编辑者 (editor)</SelectItem>
|
||||
<SelectItem value="viewer">查看者 (viewer)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-xs">描述 (可选)</Label><Input value={newDesc} onChange={e => setNewDesc(e.target.value)} placeholder="简要说明此角色的用途" className="h-8 text-sm" /></div>
|
||||
<Button onClick={handleCreate} disabled={saving} className="w-full">
|
||||
{saving ? <Loader2 className="w-4 h-4 mr-1 animate-spin" /> : <Save className="w-4 h-4 mr-1" />}
|
||||
{saving ? '创建中...' : '创建'}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Role list */}
|
||||
{roles.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
|
||||
<Shield className="w-10 h-10 mx-auto mb-2 opacity-20" />
|
||||
<p className="text-sm">暂无 Item Role</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{roles.map(role => (
|
||||
<Card key={role.id} className={`bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] ${role.status === 0 ? 'opacity-50' : ''}`}>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="w-9 h-9 rounded-lg bg-purple-500/20 flex items-center justify-center shrink-0">
|
||||
<Regex className="w-4 h-4 text-purple-400" />
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-1.5 flex-wrap">
|
||||
<span className="text-sm font-semibold text-[var(--dg-text-primary)]">{role.name}</span>
|
||||
<Badge className={`text-xs border ${roleBadgeClass(role.role)}`}>{role.role === 'owner' ? '拥有者' : role.role === 'editor' ? '编辑者' : '查看者'}</Badge>
|
||||
{role.status === 0 && <Badge variant="secondary" className="text-xs text-red-400">已禁用</Badge>}
|
||||
<Badge variant="secondary" className="text-xs cursor-pointer" onClick={() => setPreviewRoleId(previewRoleId === role.id ? null : role.id)}>
|
||||
<GitBranch className="w-3 h-3 mr-0.5" />{matchCount[role.id]?.length || 0} 条
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] mt-0.5 truncate">
|
||||
<code className="font-mono text-purple-400 bg-purple-500/10 px-1 rounded">{role.code}</code>
|
||||
<span className="mx-1">→</span>
|
||||
<code className="font-mono text-blue-400 bg-blue-500/10 px-1 rounded">{role.pattern}</code>
|
||||
{role.description && <span className="ml-2 opacity-60">{role.description}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{editing === role.id ? (
|
||||
<>
|
||||
<Input value={editPattern} onChange={e => setEditPattern(e.target.value)} className="h-7 w-36 text-xs font-mono" placeholder="正则表达式" />
|
||||
<Select value={editRole} onValueChange={setEditRole}>
|
||||
<SelectTrigger className="h-7 w-20 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">拥有者</SelectItem>
|
||||
<SelectItem value="editor">编辑者</SelectItem>
|
||||
<SelectItem value="viewer">查看者</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7 text-green-400" onClick={() => handleUpdate(role.id)}><Check className="w-3.5 h-3.5" /></Button>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7 text-red-400" onClick={() => setEditing(null)}><X className="w-3.5 h-3.5" /></Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => { setEditing(role.id); setEditPattern(role.pattern); setEditRole(role.role); }}><Save className="w-3 h-3 mr-1" />编辑</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggleStatus(role)}>{role.status ? '禁用' : '启用'}</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete(role)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ====== User Assignment ====== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<UserPlus className="w-4 h-4 text-amber-400" />
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">分配角色给用户</span>
|
||||
</div>
|
||||
<div className="flex gap-3 items-end mb-4">
|
||||
<div className="space-y-1 flex-1">
|
||||
<Label className="text-xs">选择用户</Label>
|
||||
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="选择用户..." /></SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
<div className="px-2 py-1.5 sticky top-0 bg-popover z-10"><Input placeholder="搜索用户..." className="h-7 text-xs" /></div>
|
||||
{users.map(u => (
|
||||
<SelectItem key={u.id} value={String(u.id)}>
|
||||
{u.real_name || u.username} ({u.username})
|
||||
{assignments.some(a => a.user_id === u.id) && <span className="ml-2 text-xs text-purple-400">已分配</span>}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1 w-48">
|
||||
<Label className="text-xs">Item Role</Label>
|
||||
<Select value={assignRoleId} onValueChange={setAssignRoleId}>
|
||||
<SelectTrigger className="h-8 text-sm"><SelectValue placeholder="选择角色" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.filter(r => r.status === 1).map(r => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>{r.name} ({r.role})</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button className="h-8" onClick={handleAssign} disabled={assignLoading || !selectedUserId || !assignRoleId}>
|
||||
{assignLoading ? <Loader2 className="w-3.5 h-3.5 mr-1 animate-spin" /> : <UserPlus className="w-3.5 h-3.5 mr-1" />}
|
||||
分配
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Assigned roles for selected user */}
|
||||
{selectedUser && (
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
<Users className="w-3 h-3 mr-1" />{selectedUser.real_name || selectedUser.username}
|
||||
</Badge>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">已分配角色:</span>
|
||||
</div>
|
||||
{assignments.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] py-2">该用户尚未分配任何 Item Role</p>
|
||||
) : (
|
||||
<div className="space-y-1">
|
||||
{assignments.map(a => {
|
||||
const roleInfo = a.item_role || roles.find(r => r.id === a.item_role_id);
|
||||
return (
|
||||
<div key={a.id} className="flex items-center justify-between py-1.5 px-2 rounded bg-[var(--dg-bg-tertiary)] text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={`text-xs border ${roleBadgeClass(roleInfo?.role || 'viewer')}`}>
|
||||
{roleInfo?.role === 'owner' ? '拥有者' : roleInfo?.role === 'editor' ? '编辑者' : '查看者'}
|
||||
</Badge>
|
||||
<span className="text-[var(--dg-text-primary)]">{roleInfo?.name || `#${a.item_role_id}`}</span>
|
||||
{roleInfo && (
|
||||
<code className="font-mono text-[var(--dg-text-muted)] bg-[var(--dg-bg-primary)] px-1 rounded text-xs">
|
||||
{roleInfo.pattern}
|
||||
</code>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-400" onClick={() => handleRevoke(a.id)}>
|
||||
<X className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
FolderOpen,
|
||||
FileText,
|
||||
Download,
|
||||
ChevronRight,
|
||||
Home,
|
||||
ArrowLeft,
|
||||
X,
|
||||
Maximize2,
|
||||
Minimize2,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import http from '@/services/http';
|
||||
import { useUserStore } from '@/store';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
path: string;
|
||||
is_dir: boolean;
|
||||
size: number;
|
||||
mod_time: string;
|
||||
}
|
||||
|
||||
interface ListingData {
|
||||
current: string;
|
||||
dirs: FileItem[];
|
||||
files: FileItem[];
|
||||
}
|
||||
|
||||
interface FileBrowserDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
runId: number | null;
|
||||
buildNumber: number;
|
||||
}
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '-';
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
/**
|
||||
* 工作区文件浏览器 — 浏览/下载流水线构建目录文件
|
||||
*/
|
||||
export function FileBrowserDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
runId,
|
||||
buildNumber,
|
||||
}: FileBrowserDialogProps) {
|
||||
const [listing, setListing] = useState<ListingData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
// 加载目录
|
||||
const loadDir = async (path: string) => {
|
||||
if (!runId) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const params = path ? `?path=${encodeURIComponent(path)}` : '';
|
||||
const data = await http.get(`/pipelines/runs/${runId}/files${params}`);
|
||||
setListing(data);
|
||||
setCurrentPath(data?.current || '');
|
||||
} catch (err: any) {
|
||||
setError(err?.message || '加载失败');
|
||||
setListing(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (open && runId) {
|
||||
setCurrentPath('');
|
||||
loadDir('');
|
||||
}
|
||||
}, [open, runId]);
|
||||
|
||||
// 下载文件
|
||||
const downloadFile = async (file: FileItem) => {
|
||||
const token = useUserStore.getState().token || '';
|
||||
const url = `${location.protocol}//${location.host}/api/v1/pipelines/runs/${runId}/files/download?path=${encodeURIComponent(file.path)}&token=${encodeURIComponent(token)}`;
|
||||
window.open(url, '_blank');
|
||||
};
|
||||
|
||||
// 面包屑导航
|
||||
const parts = currentPath ? currentPath.split('/').filter(Boolean) : [];
|
||||
const breadcrumbs = [''].concat(
|
||||
parts.map((_, i) => '/' + parts.slice(0, i + 1).join('/'))
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
|
||||
'!duration-0 !transition-none',
|
||||
fullscreen
|
||||
? '!max-w-none !w-screen !h-screen !rounded-none'
|
||||
: ''
|
||||
)}
|
||||
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '700px', width: '85vw' }}
|
||||
>
|
||||
<DialogTitle className="sr-only">文件浏览器 #{buildNumber}</DialogTitle>
|
||||
<DialogDescription className="sr-only">流水线构建目录文件浏览</DialogDescription>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--dg-border)] shrink-0">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FolderOpen className="h-4 w-4 text-[var(--dg-text-secondary)] shrink-0" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium text-sm">
|
||||
#{buildNumber} 工作区文件
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
>
|
||||
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-0.5 px-4 py-2 text-xs border-b border-[var(--dg-border)] bg-[var(--dg-bg-primary)] overflow-x-auto shrink-0">
|
||||
{breadcrumbs.map((path, i) => (
|
||||
<span key={path} className="flex items-center gap-0.5">
|
||||
{i === 0 ? (
|
||||
<button
|
||||
onClick={() => loadDir('')}
|
||||
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] p-0.5"
|
||||
>
|
||||
<Home className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
<ChevronRight className="h-3 w-3 text-[var(--dg-text-muted)] shrink-0" />
|
||||
<button
|
||||
onClick={() => loadDir(path)}
|
||||
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] truncate max-w-[120px]"
|
||||
>
|
||||
{parts[i - 1]}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="overflow-y-auto" style={{ height: fullscreen ? 'calc(100vh - 110px)' : '420px' }}>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full text-[var(--dg-text-muted)] text-sm">
|
||||
加载中...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center justify-center h-full text-red-400 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{listing && !loading && (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-[var(--dg-bg-primary)] border-b border-[var(--dg-border)]">
|
||||
<tr className="text-left text-[var(--dg-text-muted)] text-xs">
|
||||
<th className="py-2 pl-4 pr-2 font-medium">名称</th>
|
||||
<th className="py-2 px-2 font-medium w-24 text-right">大小</th>
|
||||
<th className="py-2 pr-4 pl-2 font-medium w-40 hidden sm:table-cell">修改时间</th>
|
||||
<th className="py-2 pr-4 pl-2 font-medium w-16" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{/* Parent dir */}
|
||||
{currentPath && (
|
||||
<tr
|
||||
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] cursor-pointer"
|
||||
onClick={() => {
|
||||
const parent = currentPath.split('/').slice(0, -1).join('/');
|
||||
loadDir(parent);
|
||||
}}
|
||||
>
|
||||
<td className="py-2 pl-4 pr-2 flex items-center gap-2">
|
||||
<ArrowLeft className="h-4 w-4 text-[var(--dg-text-secondary)]" />
|
||||
<span className="text-[var(--dg-text-secondary)]">..</span>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right" />
|
||||
<td className="py-2 pr-4 pl-2 hidden sm:table-cell" />
|
||||
<td className="py-2 pr-4 pl-2" />
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{/* Directories */}
|
||||
{listing.dirs?.map((dir) => (
|
||||
<tr
|
||||
key={dir.path}
|
||||
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] cursor-pointer"
|
||||
onClick={() => loadDir(dir.path)}
|
||||
>
|
||||
<td className="py-2 pl-4 pr-2 flex items-center gap-2">
|
||||
<FolderOpen className="h-4 w-4 text-amber-400 shrink-0" />
|
||||
<span className="text-[var(--dg-text-primary)]">{dir.name}</span>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right text-[var(--dg-text-muted)]">-</td>
|
||||
<td className="py-2 pr-4 pl-2 text-[var(--dg-text-muted)] hidden sm:table-cell">{dir.mod_time}</td>
|
||||
<td className="py-2 pr-4 pl-2" />
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* Files */}
|
||||
{listing.files?.map((file) => (
|
||||
<tr
|
||||
key={file.path}
|
||||
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]"
|
||||
>
|
||||
<td className="py-2 pl-4 pr-2 flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-sky-400 shrink-0" />
|
||||
<span className="text-[var(--dg-text-primary)]">{file.name}</span>
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right text-[var(--dg-text-muted)]">{formatSize(file.size)}</td>
|
||||
<td className="py-2 pr-4 pl-2 text-[var(--dg-text-muted)] hidden sm:table-cell">{file.mod_time}</td>
|
||||
<td className="py-2 pr-4 pl-2">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); downloadFile(file); }}
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-white"
|
||||
title="下载"
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{/* Empty */}
|
||||
{listing.dirs?.length === 0 && listing.files?.length === 0 && !currentPath && (
|
||||
<tr>
|
||||
<td colSpan={4} className="py-8 text-center text-[var(--dg-text-muted)]">
|
||||
{loading ? '加载中...' : '目录为空'}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,602 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
Plus, Trash2, ChevronUp, ChevronDown, ChevronRight, Save, Play, Loader2,
|
||||
GitBranch, Wrench, Server, FlaskConical, Zap, Terminal, FileCode, Container,
|
||||
Clock, Webhook, Hand, Info, Copy, Check, X, StopCircle, ScrollText,
|
||||
Users, UserPlus, Shield, Eye, FileText,
|
||||
} from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { pipelineApi, deployApi } from '@/services/api';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
|
||||
// ---- types ----
|
||||
interface Task {
|
||||
id: string; name: string; type: string; icon: string;
|
||||
config: Record<string, string>; script: string; script_type: string;
|
||||
}
|
||||
interface Stage {
|
||||
id: string; name: string; type: string; tasks: Task[];
|
||||
}
|
||||
interface LogEntry {
|
||||
time: string; level: string; message: string; stage: string;
|
||||
}
|
||||
|
||||
const stageTypeLabels: Record<string, string> = { source: '拉取代码', build: '构建', deploy: '部署', test: '测试', custom: '自定义' };
|
||||
const taskIcons: Record<string, any> = { GitBranch, Wrench, Server, FlaskConical, Zap, Terminal, FileCode, Container };
|
||||
|
||||
function uid() { return `tsk-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; }
|
||||
function fmtDuration(s: number | undefined): string {
|
||||
if (!s) return '-';
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60); const sec = s % 60;
|
||||
return sec > 0 ? `${m}分${sec}秒` : `${m}分`;
|
||||
}
|
||||
|
||||
// ---- Task Card ----
|
||||
function TaskCard({ task, selected, onSelect, onDelete }: { task: Task; selected: boolean; onSelect: () => void; onDelete: () => void }) {
|
||||
const Icon = taskIcons[task.icon] || Wrench;
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className={`flex items-center gap-3 p-3 rounded-md border cursor-pointer transition-all group ${
|
||||
selected ? 'border-blue-500 bg-blue-500/10' : 'border-[var(--dg-border)] hover:border-slate-400 bg-[var(--dg-bg-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<div className={`p-1.5 rounded ${selected ? 'bg-blue-100' : 'bg-slate-100 dark:bg-slate-800'}`}>
|
||||
<Icon className={`w-4 h-4 ${selected ? 'text-blue-600' : 'text-[var(--dg-text-secondary)]'}`} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)] truncate">{task.name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)]">{task.type}</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 opacity-0 group-hover:opacity-100" onClick={e => { e.stopPropagation(); onDelete(); }}>
|
||||
<Trash2 className="w-3.5 h-3.5 text-red-500" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---- Task Editor Panel ----
|
||||
function TaskEditor({ task, onUpdate, onClose }: { task: Task | null; onUpdate: (t: Task) => void; onClose: () => void }) {
|
||||
if (!task) return <div className="p-6 text-center text-[var(--dg-text-secondary)] text-sm">选择一个任务开始编辑</div>;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1"><Label className="text-xs">任务名称</Label><Input value={task.name} onChange={e => onUpdate({ ...task, name: e.target.value })} className="h-8 text-sm" /></div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1"><Label className="text-xs">类型</Label>
|
||||
<Select value={task.type} onValueChange={v => onUpdate({ ...task, type: v })}><SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="build">构建</SelectItem><SelectItem value="deploy">部署</SelectItem><SelectItem value="test">测试</SelectItem><SelectItem value="custom">自定义</SelectItem></SelectContent></Select>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-xs">图标</Label>
|
||||
<Select value={task.icon} onValueChange={v => onUpdate({ ...task, icon: v })}><SelectTrigger className="h-8 text-sm"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>{Object.entries(taskIcons).map(([k]) => (<SelectItem key={k} value={k}>{k}</SelectItem>))}</SelectContent></Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-xs">脚本类型</Label>
|
||||
<div className="flex gap-2">{['shell', 'python'].map(t => (<Badge key={t} variant={task.script_type === t ? 'default' : 'outline'} className="cursor-pointer text-xs" onClick={() => onUpdate({ ...task, script_type: t })}>{t}</Badge>))}</div>
|
||||
</div>
|
||||
<div className="space-y-1"><Label className="text-xs">脚本内容</Label>
|
||||
<Textarea value={task.script} onChange={e => onUpdate({ ...task, script: e.target.value })} className="font-mono text-xs min-h-[200px] bg-slate-950 text-green-400" placeholder="#!/bin/bash\nset -e\necho 'hello'" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ====== Log Dialog ======
|
||||
function LogDialog({ open, onClose, run, logs }: { open: boolean; onClose: () => void; run: any; logs: LogEntry[] }) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader><DialogTitle className="flex items-center gap-2"><ScrollText className="w-5 h-5" />执行日志 #{run?.build_number || run?.id}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap gap-3 text-sm text-[var(--dg-text-secondary)]">
|
||||
<span>触发方式: {run?.triggered_by || 'manual'}</span>
|
||||
<span>分支: {run?.branch || '-'}</span>
|
||||
<span>耗时: {fmtDuration(run?.duration)}</span>
|
||||
<span>{run?.created_at ? new Date(run.created_at).toLocaleString('zh-CN') : '-'}</span>
|
||||
</div>
|
||||
<div className="bg-slate-950 rounded-md p-3 max-h-[50vh] overflow-y-auto font-mono text-xs">
|
||||
{logs.length === 0 ? (
|
||||
<div className="text-slate-500 py-4 text-center">暂无日志</div>
|
||||
) : (
|
||||
logs.map((l, i) => (
|
||||
<div key={i} className="flex gap-2 py-0.5">
|
||||
<span className="text-slate-500 shrink-0">[{l.time}]</span>
|
||||
<span className={
|
||||
l.level === 'error' ? 'text-red-400' :
|
||||
l.level === 'warn' ? 'text-yellow-400' :
|
||||
l.level === 'success' ? 'text-green-400' :
|
||||
'text-slate-300'
|
||||
}>
|
||||
{l.level === 'success' ? '✓' : l.level === 'error' ? '✗' : l.level === 'warn' ? '!' : '>'} {l.message}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// ====== Main Component ======
|
||||
export default function PipelineEditor() {
|
||||
const { code } = useParams<{ code: string }>();
|
||||
const pipelineCode = code || '';
|
||||
const [pipeline, setPipeline] = useState<any>(null);
|
||||
const [stages, setStages] = useState<Stage[]>([]);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [expandedStages, setExpandedStages] = useState<Record<string, boolean>>({});
|
||||
const [activeTab, setActiveTab] = useState('flow');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [runs, setRuns] = useState<any[]>([]);
|
||||
const [environments, setEnvironments] = useState<any[]>([]);
|
||||
const [members, setMembers] = useState<any[]>([]);
|
||||
const [loadingMembers, setLoadingMembers] = useState(false);
|
||||
|
||||
// trigger config
|
||||
const [triggerMode, setTriggerMode] = useState('manual');
|
||||
const [watchBranches, setWatchBranches] = useState('');
|
||||
const [cronExpr, setCronExpr] = useState('');
|
||||
const [webhookToken, setWebhookToken] = useState('');
|
||||
const [variables, setVariables] = useState<[string, string][]>([]);
|
||||
const [environmentId, setEnvironmentId] = useState<number | string>('');
|
||||
|
||||
// member add dialog
|
||||
const [newMemberUser, setNewMemberUser] = useState('');
|
||||
const [newMemberRole, setNewMemberRole] = useState('editor');
|
||||
|
||||
// log dialog
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
const [logRun, setLogRun] = useState<any>(null);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
|
||||
const _confirm = useConfirm();
|
||||
|
||||
const loadEnvironments = useCallback(async () => {
|
||||
try {
|
||||
const res = await deployApi.getEnvironments();
|
||||
setEnvironments(Array.isArray(res) ? res : (res as any)?.list || []);
|
||||
} catch { /* silent */ }
|
||||
}, []);
|
||||
|
||||
const loadMembers = useCallback(async () => {
|
||||
setLoadingMembers(true);
|
||||
try {
|
||||
const res = await pipelineApi.getMembers(pipelineCode);
|
||||
setMembers(Array.isArray(res) ? res : (res as any)?.data || []);
|
||||
} catch { setMembers([]); }
|
||||
finally { setLoadingMembers(false); }
|
||||
}, [pipelineCode]);
|
||||
|
||||
const loadRuns = useCallback(async () => {
|
||||
try {
|
||||
const res = await pipelineApi.getRuns(pipelineCode);
|
||||
setRuns(Array.isArray(res) ? res : (res as any)?.list || []);
|
||||
} catch { setRuns([]); }
|
||||
}, [pipelineCode]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const p = await pipelineApi.getPipelineDetail(pipelineCode) as any;
|
||||
// http.get 已自动解包 json.data,p 即为 pipeline 对象
|
||||
if (!p || !p.id) { setPipeline(null); setLoading(false); return; }
|
||||
setPipeline(p);
|
||||
setTriggerMode(p.trigger_mode || 'manual');
|
||||
setWatchBranches(p.watch_branches || '');
|
||||
setCronExpr(p.cron_expr || '');
|
||||
setWebhookToken(p.webhook_token || '');
|
||||
setEnvironmentId(p.environment_id || '');
|
||||
try {
|
||||
const parsed = p.variables ? JSON.parse(p.variables) : {};
|
||||
setVariables(Object.entries(parsed).map(([k, v]) => [k, String(v)]));
|
||||
} catch { setVariables([]); }
|
||||
try {
|
||||
const s = p.stages ? JSON.parse(p.stages) : [];
|
||||
const stageList = Array.isArray(s) ? s : [];
|
||||
setStages(stageList);
|
||||
// Auto-expand all stages initially
|
||||
const expanded: Record<string, boolean> = {};
|
||||
stageList.forEach((stg: Stage) => { expanded[stg.id] = true; });
|
||||
setExpandedStages(expanded);
|
||||
} catch { setStages([]); }
|
||||
} catch { toast.error('加载失败'); }
|
||||
finally { setLoading(false); }
|
||||
})();
|
||||
loadEnvironments();
|
||||
loadMembers();
|
||||
loadRuns();
|
||||
}, [pipelineCode, loadEnvironments, loadMembers, loadRuns]);
|
||||
|
||||
const selectedTask = stages.flatMap(s => s.tasks).find(t => t.id === selectedTaskId) || null;
|
||||
const handleUpdateTask = (t: Task) => { setStages(prev => prev.map(s => ({ ...s, tasks: s.tasks.map(x => x.id === t.id ? t : x) }))); };
|
||||
const handleAddTask = (stageIdx: number) => {
|
||||
const newTask: Task = { id: uid(), name: '新任务', type: 'build', icon: 'Wrench', config: {}, script: '', script_type: 'shell' };
|
||||
setStages(prev => prev.map((s, i) => i === stageIdx ? { ...s, tasks: [...s.tasks, newTask] } : s));
|
||||
};
|
||||
const handleDeleteTask = (taskId: string) => { setStages(prev => prev.map(s => ({ ...s, tasks: s.tasks.filter(t => t.id !== taskId) }))); if (selectedTaskId === taskId) setSelectedTaskId(null); };
|
||||
const handleAddStage = () => { const newStage: Stage = { id: `stg-${Date.now()}`, name: '新阶段', type: 'custom', tasks: [] }; setStages(prev => [...prev, newStage]); };
|
||||
const handleDeleteStage = (idx: number) => { setStages(prev => prev.filter((_, i) => i !== idx)); };
|
||||
const handleMoveStage = (idx: number, dir: -1 | 1) => { const nx = idx + dir; if (nx < 0 || nx >= stages.length) return; const arr = [...stages]; [arr[idx], arr[nx]] = [arr[nx], arr[idx]]; setStages(arr); };
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await pipelineApi.updatePipeline(pipelineCode, {
|
||||
stages: JSON.stringify(stages),
|
||||
trigger_mode: triggerMode,
|
||||
watch_branches: watchBranches,
|
||||
cron_expr: cronExpr,
|
||||
webhook_token: webhookToken,
|
||||
environment_id: environmentId ? Number(environmentId) : null,
|
||||
variables: JSON.stringify(Object.fromEntries(variables.filter(([k]) => k.trim()))),
|
||||
});
|
||||
toast.success('已保存');
|
||||
} catch { toast.error('保存失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleTrigger = async () => {
|
||||
try {
|
||||
await pipelineApi.triggerPipeline(pipelineCode);
|
||||
toast.success('已触发');
|
||||
loadRuns();
|
||||
} catch { toast.error('触发失败'); }
|
||||
};
|
||||
|
||||
const handleStop = async (runId: number | string) => {
|
||||
const ok = await _confirm({ title: '停止运行', description: '确定要停止此运行吗?', variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await pipelineApi.stopRun(pipelineCode, runId);
|
||||
toast.success('已停止');
|
||||
loadRuns();
|
||||
} catch { toast.error('停止失败'); }
|
||||
};
|
||||
|
||||
const handleViewLog = async (run: any) => {
|
||||
setLogRun(run);
|
||||
setLogOpen(true);
|
||||
try {
|
||||
const data = await pipelineApi.getRunLog(pipelineCode, run.id) as any;
|
||||
setLogs(data?.logs || []);
|
||||
} catch { setLogs([]); }
|
||||
};
|
||||
|
||||
const handleAddMember = async () => {
|
||||
if (!newMemberUser.trim()) { toast.error('请输入用户名'); return; }
|
||||
try {
|
||||
await pipelineApi.addMember(pipelineCode, { username: newMemberUser.trim(), role: newMemberRole });
|
||||
toast.success('已添加成员');
|
||||
setNewMemberUser('');
|
||||
loadMembers();
|
||||
} catch (e: any) { toast.error(e?.message || '添加失败'); }
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (memberId: number) => {
|
||||
const ok = await _confirm({ title: '移除成员', description: '确定要移除此成员吗?', variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try { await pipelineApi.removeMember(pipelineCode, memberId); toast.success('已移除'); loadMembers(); }
|
||||
catch { toast.error('移除失败'); }
|
||||
};
|
||||
|
||||
const envBadge = environments.find(e => String(e.id) === String(environmentId));
|
||||
|
||||
if (loading) return <div className="flex items-center justify-center py-20"><Loader2 className="w-6 h-6 animate-spin" /></div>;
|
||||
if (!pipeline) return <div className="text-center py-20 text-[var(--dg-text-secondary)]">流水线不存在</div>;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold flex items-center gap-2 text-[var(--dg-text-primary)]">
|
||||
<GitBranch className="w-5 h-5 text-blue-500" />
|
||||
{pipeline.name}
|
||||
{envBadge && <Badge style={{ backgroundColor: envBadge.color + '20', color: envBadge.color, borderColor: envBadge.color + '40' }} className="text-xs ml-2">{envBadge.display_name}</Badge>}
|
||||
</h1>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">code: {pipeline.code} · {pipeline.description}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleSave} disabled={saving}><Save className="w-4 h-4 mr-1" />{saving ? '保存中...' : '保存'}</Button>
|
||||
<Button variant="default" onClick={handleTrigger}><Play className="w-4 h-4 mr-1" />运行</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environment Selector - quick bar */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3 flex items-center gap-3">
|
||||
<Shield className="w-4 h-4 text-[var(--dg-text-secondary)]" />
|
||||
<span className="text-sm text-[var(--dg-text-secondary)]">部署环境:</span>
|
||||
<Select value={String(environmentId)} onValueChange={v => setEnvironmentId(v === '__none' ? '' : v)}>
|
||||
<SelectTrigger className="w-40 h-8 text-sm"><SelectValue placeholder="选择环境" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">未指定</SelectItem>
|
||||
{environments.map(env => (
|
||||
<SelectItem key={env.id} value={String(env.id)}>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="w-2 h-2 rounded-full inline-block" style={{ backgroundColor: env.color }} />
|
||||
{env.display_name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)] opacity-70">选择后流水线执行记录会关联到对应环境</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="flow">流程配置</TabsTrigger>
|
||||
<TabsTrigger value="triggers">触发设置</TabsTrigger>
|
||||
<TabsTrigger value="variables">变量</TabsTrigger>
|
||||
<TabsTrigger value="runs">发布记录 ({runs.length})</TabsTrigger>
|
||||
<TabsTrigger value="members">成员管理 ({members.length})</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* ====== Flow Tab ====== */}
|
||||
<TabsContent value="flow" className="mt-3">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">流水线阶段 ({stages.length})</span>
|
||||
<Button size="sm" onClick={handleAddStage}><Plus className="w-3.5 h-3.5 mr-1" />添加阶段</Button>
|
||||
</div>
|
||||
{stages.length === 0 ? (
|
||||
<div className="text-center py-12 border-2 border-dashed rounded-lg text-[var(--dg-text-secondary)]">点击「添加阶段」开始</div>
|
||||
) : (
|
||||
stages.map((stage, idx) => {
|
||||
const isExpanded = expandedStages[stage.id] !== false; // default expanded
|
||||
return (
|
||||
<div key={stage.id} className="space-y-1">
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 bg-[var(--dg-bg-tertiary)] rounded-t-md border border-b-0 border-[var(--dg-border)] cursor-pointer"
|
||||
onClick={() => setExpandedStages(prev => ({ ...prev, [stage.id]: !isExpanded }))}
|
||||
>
|
||||
<Button variant="ghost" size="icon" className="h-5 w-5 p-0" onClick={(e) => { e.stopPropagation(); setExpandedStages(prev => ({ ...prev, [stage.id]: !isExpanded })); }}>
|
||||
{isExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
</Button>
|
||||
<Input
|
||||
value={stage.name}
|
||||
onChange={e => setStages(prev => prev.map((s, i) => i === idx ? { ...s, name: e.target.value } : s))}
|
||||
className="h-7 text-sm font-medium border-none bg-transparent shadow-none flex-1 px-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">{stage.tasks.length} 任务</span>
|
||||
<Badge variant="secondary" className="text-xs">{stageTypeLabels[stage.type] || stage.type}</Badge>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleMoveStage(idx, -1); }} disabled={idx === 0}><ChevronUp className="w-3.5 h-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6" onClick={(e) => { e.stopPropagation(); handleMoveStage(idx, 1); }} disabled={idx === stages.length - 1}><ChevronDown className="w-3.5 h-3.5" /></Button>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 text-red-500" onClick={(e) => { e.stopPropagation(); handleDeleteStage(idx); }}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</div>
|
||||
{isExpanded && (
|
||||
<div className="border border-[var(--dg-border)] rounded-b-md p-3 space-y-2">
|
||||
{stage.tasks.map(t => (<TaskCard key={t.id} task={t} selected={selectedTaskId === t.id} onSelect={() => setSelectedTaskId(selectedTaskId === t.id ? null : t.id)} onDelete={() => handleDeleteTask(t.id)} />))}
|
||||
<Button variant="outline" size="sm" className="w-full h-8 text-xs" onClick={() => handleAddTask(idx)}><Plus className="w-3 h-3 mr-1" />添加任务</Button>
|
||||
</div>
|
||||
)}
|
||||
{idx < stages.length - 1 && <div className="flex justify-center py-1"><ChevronRight className="w-4 h-4 rotate-90 text-[var(--dg-text-secondary)] opacity-30" /></div>}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
<div className="w-[360px] flex-shrink-0 border border-[var(--dg-border)] rounded-lg p-4 bg-[var(--dg-bg-secondary)]">
|
||||
<div className="flex items-center justify-between mb-3"><span className="text-sm font-medium text-[var(--dg-text-primary)]">任务编辑</span>{selectedTask && <Button variant="ghost" size="sm" className="h-6 text-xs" onClick={() => setSelectedTaskId(null)}>关闭</Button>}</div>
|
||||
<TaskEditor task={selectedTask} onUpdate={handleUpdateTask} onClose={() => setSelectedTaskId(null)} />
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ====== Triggers Tab ====== */}
|
||||
<TabsContent value="triggers" className="mt-3">
|
||||
<div className="space-y-4 max-w-lg">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><GitBranch className="w-5 h-5 text-blue-500" /><span className="font-medium">Git Push 触发</span></div><Switch checked={triggerMode === 'git_push'} onCheckedChange={v => setTriggerMode(v ? 'git_push' : 'manual')} /></div>
|
||||
{triggerMode === 'git_push' && <div className="space-y-1"><Label>监听分支</Label><Input value={watchBranches} onChange={e => setWatchBranches(e.target.value)} placeholder="master, release/*" className="h-9" /></div>}
|
||||
</CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Clock className="w-5 h-5 text-blue-500" /><span className="font-medium">定时触发</span></div><Switch checked={triggerMode === 'cron'} onCheckedChange={v => setTriggerMode(v ? 'cron' : 'manual')} /></div>
|
||||
{triggerMode === 'cron' && <div className="space-y-1"><Label>Cron 表达式</Label><Input value={cronExpr} onChange={e => setCronExpr(e.target.value)} placeholder="0 2 * * *" className="h-9 font-mono" /></div>}
|
||||
</CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6 space-y-4">
|
||||
<div className="flex items-center justify-between"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Webhook className="w-5 h-5 text-blue-500" /><span className="font-medium">Webhook 触发</span></div><Switch checked={triggerMode === 'webhook'} onCheckedChange={v => setTriggerMode(v ? 'webhook' : 'manual')} /></div>
|
||||
{triggerMode === 'webhook' && <div className="space-y-1"><Label>Secret Token</Label><Input value={webhookToken} onChange={e => setWebhookToken(e.target.value)} type="password" placeholder="输入签名密钥" className="h-9" /></div>}
|
||||
</CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]"><CardContent className="pt-6"><div className="flex items-center gap-2 text-[var(--dg-text-primary)]"><Hand className="w-5 h-5 text-blue-500" /><span className="font-medium">手动触发</span><Badge variant="secondary" className="text-xs">默认启用</Badge></div></CardContent></Card>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ====== Variables Tab ====== */}
|
||||
<TabsContent value="variables" className="mt-3">
|
||||
<div className="max-w-lg space-y-3">
|
||||
{variables.map(([k, v], i) => (
|
||||
<div key={i} className="flex gap-2 items-center">
|
||||
<Input value={k} onChange={e => { const next = [...variables]; next[i][0] = e.target.value; setVariables(next); }} placeholder="KEY" className="h-9 w-40 font-mono text-sm" />
|
||||
<span className="text-[var(--dg-text-secondary)]">=</span>
|
||||
<Input value={v} onChange={e => { const next = [...variables]; next[i][1] = e.target.value; setVariables(next); }} placeholder="VALUE" className="h-9 flex-1" />
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500" onClick={() => setVariables(variables.filter((_, j) => j !== i))}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</div>
|
||||
))}
|
||||
<Button variant="outline" size="sm" onClick={() => setVariables([...variables, ['', '']])}><Plus className="w-3.5 h-3.5 mr-1" />添加变量</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* ====== Runs Tab ====== */}
|
||||
<TabsContent value="runs" className="mt-3">
|
||||
{runs.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-secondary)]">暂无执行记录</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">#</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">状态</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">触发方式</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">触发人</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">分支</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">耗时</th>
|
||||
<th className="px-3 py-2 text-left text-[var(--dg-text-secondary)]">时间</th>
|
||||
<th className="px-3 py-2 text-right text-[var(--dg-text-secondary)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map((r: any) => (
|
||||
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">#{r.build_number || r.id}</td>
|
||||
<td className="px-3 py-2">
|
||||
{r.status === 'success' ? <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30"><Check className="w-3 h-3 mr-0.5" />成功</Badge> :
|
||||
r.status === 'failed' ? <Badge className="bg-red-500/20 text-red-400 border-red-500/30"><X className="w-3 h-3 mr-0.5" />失败</Badge> :
|
||||
r.status === 'running' ? <Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30"><Loader2 className="w-3 h-3 animate-spin mr-0.5" />执行中</Badge> :
|
||||
r.status === 'cancelled' ? <Badge className="bg-yellow-500/20 text-yellow-400 border-yellow-500/30"><StopCircle className="w-3 h-3 mr-0.5" />已停止</Badge> :
|
||||
<Badge variant="secondary" className="text-[var(--dg-text-secondary)]">{r.status}</Badge>}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">
|
||||
{r.trigger_mode === 'manual' && <span className="flex items-center gap-1"><Hand className="w-3 h-3" />手动</span>}
|
||||
{r.trigger_mode === 'cron' && <span className="flex items-center gap-1"><Clock className="w-3 h-3" />定时</span>}
|
||||
{r.trigger_mode === 'webhook' && <span className="flex items-center gap-1"><Webhook className="w-3 h-3" />Webhook</span>}
|
||||
{r.trigger_mode === 'git_push' && <span className="flex items-center gap-1"><GitBranch className="w-3 h-3" />Git Push</span>}
|
||||
{!r.trigger_mode && (r.triggered_by || '手动')}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.triggered_by || 'admin'}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] font-mono text-xs">{r.branch || '-'}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)]">{fmtDuration(r.duration)}</td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)] text-xs">{r.created_at ? new Date(r.created_at).toLocaleString('zh-CN') : '-'}</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-blue-400 hover:text-blue-300" onClick={() => handleViewLog(r)}><FileText className="w-3.5 h-3.5 mr-1" />日志</Button>
|
||||
{(r.status === 'running' || r.status === 'pending') && (
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-red-400 hover:text-red-300" onClick={() => handleStop(r.id)}><StopCircle className="w-3.5 h-3.5 mr-1" />停止</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* ====== Members Tab ====== */}
|
||||
<TabsContent value="members" className="mt-3">
|
||||
<div className="max-w-xl space-y-4">
|
||||
{/* Add member form */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<UserPlus className="w-4 h-4 text-blue-500" />
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">添加用户</span>
|
||||
</div>
|
||||
<div className="flex items-end gap-3 mt-3">
|
||||
<div className="flex-1 space-y-1">
|
||||
<Label className="text-xs">用户名</Label>
|
||||
<Input value={newMemberUser} onChange={e => setNewMemberUser(e.target.value)} placeholder="输入用户名" className="h-9" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">角色</Label>
|
||||
<Select value={newMemberRole} onValueChange={setNewMemberRole}>
|
||||
<SelectTrigger className="h-9 w-28"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">拥有者</SelectItem>
|
||||
<SelectItem value="editor">编辑者</SelectItem>
|
||||
<SelectItem value="viewer">查看者</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={handleAddMember} className="h-9"><Plus className="w-4 h-4 mr-1" />添加</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Members list — split direct users vs group assignments */}
|
||||
{(() => {
|
||||
const directUsers = members.filter((m: any) => m.username);
|
||||
const groupAssignments = members.filter((m: any) => m.group_id);
|
||||
return (
|
||||
<>
|
||||
{/* Direct users */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
<div className="px-4 py-2 text-xs font-medium text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]">
|
||||
👤 直接用户授权 ({directUsers.length})
|
||||
</div>
|
||||
{loadingMembers ? (
|
||||
<div className="flex items-center justify-center py-6"><Loader2 className="w-5 h-5 animate-spin text-[var(--dg-text-secondary)]" /></div>
|
||||
) : directUsers.length === 0 ? (
|
||||
<div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">暂无直接用户</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--dg-border)]">
|
||||
{directUsers.map((m: any) => (
|
||||
<div key={m.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-blue-500/20 flex items-center justify-center"><Users className="w-4 h-4 text-blue-400" /></div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{m.username}</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)]">{m.role === 'owner' ? <span className="text-amber-400">拥有者</span> : m.role === 'editor' ? <span className="text-blue-400">编辑者</span> : <span className="text-slate-400">查看者</span>}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-red-400 hover:text-red-300" onClick={() => handleRemoveMember(m.id)}><Trash2 className="w-3.5 h-3.5 mr-1" />移除</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Group assignments */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-0">
|
||||
<div className="px-4 py-2 text-xs font-medium text-[var(--dg-text-secondary)] border-b border-[var(--dg-border)]">
|
||||
👥 用户组授权 ({groupAssignments.length})
|
||||
</div>
|
||||
{groupAssignments.length === 0 ? (
|
||||
<div className="py-4 text-center text-[var(--dg-text-secondary)] text-xs">
|
||||
暂无组授权 — 在「<a href="/deploy/groups" className="text-blue-400 underline">用户组管理</a>」页面创建组后,可在此授权
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-[var(--dg-border)]">
|
||||
{groupAssignments.map((m: any) => (
|
||||
<div key={m.id} className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-8 h-8 rounded-full bg-purple-500/20 flex items-center justify-center"><Shield className="w-4 h-4 text-purple-400" /></div>
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{m.group?.name || `组 #${m.group_id}`}</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)]">{m.role === 'owner' ? <span className="text-amber-400">拥有者</span> : m.role === 'editor' ? <span className="text-blue-400">编辑者</span> : <span className="text-slate-400">查看者</span>} · 组内全体成员</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs text-red-400 hover:text-red-300" onClick={() => handleRemoveMember(m.id)}><Trash2 className="w-3.5 h-3.5 mr-1" />撤销授权</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Log Dialog */}
|
||||
<LogDialog open={logOpen} onClose={() => setLogOpen(false)} run={logRun} logs={logs} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,473 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { Play, Square, RefreshCw, Clock, User, AlertCircle, CheckCircle2, Loader2, XCircle, History, FolderOpen, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { toast } from 'sonner';
|
||||
import http from '@/services/http';
|
||||
import { FileBrowserDialog } from './FileBrowserDialog';
|
||||
|
||||
interface PipelineRun {
|
||||
id: number;
|
||||
pipeline_id: number;
|
||||
build_number: number;
|
||||
status: string;
|
||||
branch: string;
|
||||
commit_id: string;
|
||||
commit_msg: string;
|
||||
author: string;
|
||||
triggered_by: string;
|
||||
image_tag: string;
|
||||
duration: number;
|
||||
started_at: string;
|
||||
finished_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface LogEntry {
|
||||
time: string;
|
||||
level: string;
|
||||
stage: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
const STATUS_BADGES: Record<string, string> = {
|
||||
success: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
|
||||
failed: 'bg-red-500/20 text-red-400 border-red-500/30',
|
||||
running: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
|
||||
pending: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
|
||||
cancelled: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
|
||||
};
|
||||
|
||||
const STATUS_ICONS: Record<string, any> = {
|
||||
success: CheckCircle2,
|
||||
failed: XCircle,
|
||||
running: Loader2,
|
||||
pending: Clock,
|
||||
cancelled: AlertCircle,
|
||||
};
|
||||
|
||||
const LEVEL_COLORS: Record<string, string> = {
|
||||
info: 'text-[var(--dg-text-secondary)]',
|
||||
success: 'text-emerald-400',
|
||||
error: 'text-red-400',
|
||||
warn: 'text-amber-400',
|
||||
};
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds) return '-';
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return `${m}分${s}秒`;
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
if (!ts) return '-';
|
||||
const d = new Date(ts);
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
}
|
||||
|
||||
export default function PipelineRunDetail() {
|
||||
const { code: pipelineCode } = useParams<{ code: string }>();
|
||||
const [runs, setRuns] = useState<PipelineRun[]>([]);
|
||||
const [selectedRun, setSelectedRun] = useState<PipelineRun | null>(null);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('records');
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
// 文件浏览器 dialog 状态
|
||||
const [browserOpen, setBrowserOpen] = useState(false);
|
||||
const [browserRun, setBrowserRun] = useState<{ id: number; buildNumber: number } | null>(null);
|
||||
|
||||
// 获取执行记录 (http.get 已自动解包 json.data → {list, total})
|
||||
const fetchRuns = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data: any = await http.get(`/pipelines/${pipelineCode}/runs?page_size=${pageSize}`);
|
||||
const list = data?.list || [];
|
||||
setRuns(list);
|
||||
// 自动选中最新运行
|
||||
if (!selectedRun && list.length > 0) {
|
||||
setSelectedRun(list[0]);
|
||||
}
|
||||
} catch { toast.error('获取执行记录失败'); }
|
||||
finally { setLoading(false); }
|
||||
}, [pipelineCode, pageSize]);
|
||||
|
||||
useEffect(() => { fetchRuns(); }, []);
|
||||
useEffect(() => { fetchRuns(); }, [pageSize]);
|
||||
|
||||
// 自动刷新(有运行中的记录时每5秒刷新)
|
||||
useEffect(() => {
|
||||
const hasRunning = runs.some(r => r.status === 'running');
|
||||
if (!hasRunning) return;
|
||||
const t = setInterval(fetchRuns, 5000);
|
||||
return () => clearInterval(t);
|
||||
}, [runs, fetchRuns]);
|
||||
|
||||
// 加载日志 (http.get 已自动解包 json.data)
|
||||
const fetchLogs = useCallback(async (run: PipelineRun) => {
|
||||
try {
|
||||
const data: any = await http.get(`/pipelines/runs/${run.id}/log-stream`);
|
||||
if (data?.logs) {
|
||||
const parsed = typeof data.logs === 'string' ? JSON.parse(data.logs) : data.logs;
|
||||
setLogs(Array.isArray(parsed) ? parsed : []);
|
||||
} else {
|
||||
setLogs([]);
|
||||
}
|
||||
// 如果运行中,连接 WebSocket
|
||||
if (run.status === 'running') {
|
||||
connectWS(run.id);
|
||||
}
|
||||
} catch {
|
||||
setLogs([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// WebSocket 连接
|
||||
const connectWS = (runId: number) => {
|
||||
if (wsRef.current) {
|
||||
wsRef.current.close();
|
||||
}
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const token = localStorage.getItem('token');
|
||||
const ws = new WebSocket(`${protocol}//${window.location.host}/ws/pipeline/${runId}/log?token=${token}`);
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.type === 'init' && data.logs) {
|
||||
const parsed = typeof data.logs === 'string' ? JSON.parse(data.logs) : data.logs;
|
||||
setLogs(Array.isArray(parsed) ? parsed : []);
|
||||
} else if (data.type === 'done') {
|
||||
ws.close();
|
||||
fetchRuns();
|
||||
} else if (data.time && data.level) {
|
||||
setLogs(prev => [...prev, data]);
|
||||
}
|
||||
} catch { /* binary frame, ignore */ }
|
||||
};
|
||||
|
||||
ws.onclose = () => { wsRef.current = null; };
|
||||
ws.onerror = () => { wsRef.current = null; };
|
||||
wsRef.current = ws;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedRun) {
|
||||
fetchLogs(selectedRun);
|
||||
}
|
||||
return () => {
|
||||
if (wsRef.current) wsRef.current.close();
|
||||
};
|
||||
}, [selectedRun, fetchLogs]);
|
||||
|
||||
// 自动滚动到底部
|
||||
useEffect(() => {
|
||||
logEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [logs]);
|
||||
|
||||
// 触发流水线
|
||||
const triggerRun = async () => {
|
||||
try {
|
||||
await http.post(`/pipelines/${pipelineCode}/trigger`, {});
|
||||
toast.success('已触发');
|
||||
fetchRuns();
|
||||
} catch { toast.error('触发失败'); }
|
||||
};
|
||||
|
||||
// 停止流水线
|
||||
const stopRun = async (runId: number) => {
|
||||
try {
|
||||
await http.post(`/pipelines/${pipelineCode}/runs/${runId}/stop`, {});
|
||||
toast.success('已停止');
|
||||
fetchRuns();
|
||||
} catch { toast.error('停止失败'); }
|
||||
};
|
||||
|
||||
// 批量删除
|
||||
const batchDelete = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await http.post(`/pipelines/${pipelineCode}/runs/batch-delete`, { ids: Array.from(selectedIds) });
|
||||
toast.success(`已删除 ${selectedIds.size} 条记录`);
|
||||
setSelectedIds(new Set());
|
||||
fetchRuns();
|
||||
} catch { toast.error('批量删除失败'); }
|
||||
finally { setDeleting(false); }
|
||||
};
|
||||
|
||||
const toggleSelect = (id: number) => {
|
||||
setSelectedIds(prev => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleAll = () => {
|
||||
if (selectedIds.size === runs.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(runs.map(r => r.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const triggerLabel = (run: PipelineRun) => {
|
||||
if (run.triggered_by === 'system') return 'Cron';
|
||||
if (run.triggered_by === 'git') return 'Git Push';
|
||||
return run.triggered_by || 'Manual';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4 h-full flex flex-col">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">
|
||||
<History className="w-5 h-5 inline mr-2" />
|
||||
执行记录
|
||||
</h2>
|
||||
<p className="text-sm text-[var(--dg-text-muted)] mt-0.5">流水线 #{pipelineCode}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
{selectedIds.size > 0 && (
|
||||
<Button variant="destructive" size="sm" onClick={batchDelete} disabled={deleting}>
|
||||
<Trash2 className="w-4 h-4 mr-1" />删除 ({selectedIds.size})
|
||||
</Button>
|
||||
)}
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={e => { setPageSize(Number(e.target.value)); }}
|
||||
className="h-8 px-2 text-xs rounded border border-[var(--dg-border)] bg-[var(--dg-bg-secondary)] text-[var(--dg-text-primary)]"
|
||||
>
|
||||
<option value={10}>10条/页</option>
|
||||
<option value={20}>20条/页</option>
|
||||
<option value={30}>30条/页</option>
|
||||
</select>
|
||||
<Button variant="outline" size="sm" onClick={fetchRuns}>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />刷新
|
||||
</Button>
|
||||
<Button size="sm" onClick={triggerRun}>
|
||||
<Play className="w-4 h-4 mr-1" />立即执行
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col">
|
||||
<TabsList>
|
||||
<TabsTrigger value="records">执行记录 ({runs.length})</TabsTrigger>
|
||||
<TabsTrigger value="log" disabled={!selectedRun}>执行日志</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* 执行记录列表 */}
|
||||
<TabsContent value="records" className="flex-1 mt-3">
|
||||
{loading && runs.length === 0 ? (
|
||||
<div className="text-center py-12"><Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-[var(--dg-text-muted)]" />加载中...</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="text-center py-12 border border-dashed border-[var(--dg-border)] rounded-lg">
|
||||
<History className="w-10 h-10 mx-auto mb-3 text-[var(--dg-text-muted)]" />
|
||||
<p className="text-[var(--dg-text-secondary)]">暂无执行记录,点击"立即执行"触发构建</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-2 py-2.5 w-10">
|
||||
<input type="checkbox" checked={selectedIds.size === runs.length && runs.length > 0} onChange={toggleAll} className="rounded" />
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-16">构建</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-20">状态</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">触发方式</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">用户</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">耗时</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">时间</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium text-[var(--dg-text-secondary)] w-32">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{runs.map(run => {
|
||||
const StatusIcon = STATUS_ICONS[run.status] || Clock;
|
||||
return (
|
||||
<tr key={run.id}
|
||||
className={`border-b border-[var(--dg-border)] cursor-pointer transition-colors
|
||||
${selectedRun?.id === run.id ? 'bg-[var(--dg-blue)]/10' : 'hover:bg-[var(--dg-bg-tertiary)]/50'}`}
|
||||
onClick={() => { setSelectedRun(run); setActiveTab('log'); }}>
|
||||
<td className="px-2 py-2.5" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.has(run.id)} onChange={() => toggleSelect(run.id)} className="rounded" />
|
||||
</td>
|
||||
<td className="px-3 py-2.5 font-mono font-semibold text-[var(--dg-text-primary)]">
|
||||
#{run.build_number}
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge className={`text-xs ${STATUS_BADGES[run.status] || ''} flex items-center gap-1 w-fit`}>
|
||||
<StatusIcon className={`w-3 h-3 ${run.status === 'running' ? 'animate-spin' : ''}`} />
|
||||
{run.status === 'success' ? '成功' : run.status === 'failed' ? '失败' : run.status === 'running' ? '执行中' : run.status === 'cancelled' ? '已取消' : '待执行'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge variant="outline" className="text-xs">{triggerLabel(run)}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] flex items-center gap-1">
|
||||
<User className="w-3 h-3" />
|
||||
{run.triggered_by || 'admin'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] font-mono text-xs">
|
||||
{run.status === 'running' ? (
|
||||
<span className="flex items-center gap-1 text-blue-400">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
执行中...
|
||||
</span>
|
||||
) : formatDuration(run.duration)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-muted)] text-xs hidden md:table-cell">
|
||||
{formatTime(run.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 text-right" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
||||
onClick={() => { setSelectedRun(run); setActiveTab('log'); }}>
|
||||
执行日志
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
||||
onClick={() => { setBrowserRun({ id: run.id, buildNumber: run.build_number }); setBrowserOpen(true); }}>
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1" />
|
||||
浏览
|
||||
</Button>
|
||||
{run.status === 'running' && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500"
|
||||
onClick={() => stopRun(run.id)}>
|
||||
<Square className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{/* 执行日志 - Console 输出 */}
|
||||
<TabsContent value="log" className="flex-1 mt-3">
|
||||
{!selectedRun ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-muted)]">请先选择一条执行记录</div>
|
||||
) : (
|
||||
<Card className="flex-1 flex flex-col border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
|
||||
<CardHeader className="py-3 px-4 flex flex-row items-center justify-between border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<CardTitle className="text-sm font-mono">
|
||||
aiops-backend #{selectedRun.build_number}
|
||||
</CardTitle>
|
||||
{selectedRun.status === 'running' && (
|
||||
<Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-xs">
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />实时
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
|
||||
<span>触发方式: {triggerLabel(selectedRun)} ({selectedRun.triggered_by || 'admin'})</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-0 overflow-hidden">
|
||||
<div className="h-full overflow-y-auto bg-[#0d1117] font-mono text-xs leading-5 p-4" style={{ minHeight: 400 }}>
|
||||
{logs.length === 0 && selectedRun.status !== 'running' ? (
|
||||
<div className="text-center py-12 text-slate-500">暂无日志输出</div>
|
||||
) : (
|
||||
<>
|
||||
{(() => {
|
||||
let lastStage = '';
|
||||
const stageStyles: Record<string, string> = {
|
||||
checkout: 'border-indigo-500 bg-indigo-500/10',
|
||||
build: 'border-amber-500 bg-amber-500/10',
|
||||
test: 'border-emerald-500 bg-emerald-500/10',
|
||||
image: 'border-cyan-500 bg-cyan-500/10',
|
||||
deploy: 'border-red-500 bg-red-500/10',
|
||||
verify: 'border-blue-500 bg-blue-500/10',
|
||||
};
|
||||
const stageIcons: Record<string, string> = {
|
||||
checkout: '⬇', build: '⚙', test: '✓',
|
||||
image: '📦', deploy: '🚀', verify: '🛡',
|
||||
};
|
||||
return logs.map((entry, i) => {
|
||||
const showStage = entry.stage && entry.stage !== lastStage;
|
||||
if (showStage) lastStage = entry.stage || '';
|
||||
const isError = entry.level === 'error';
|
||||
const isWarn = entry.level === 'warn';
|
||||
const isSuccess = entry.level === 'success';
|
||||
const accent = isError ? 'border-l-2 border-red-500 bg-red-500/10' :
|
||||
isWarn ? 'border-l-2 border-amber-500 bg-amber-500/10' :
|
||||
isSuccess ? 'border-l-2 border-emerald-500 bg-emerald-500/5' : '';
|
||||
const msgColor = isError ? 'text-red-200' :
|
||||
isWarn ? 'text-amber-200' :
|
||||
isSuccess ? 'text-emerald-200' : 'text-slate-300';
|
||||
const levelBadge = isError
|
||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-red-600/40 text-red-200">ERR</span>
|
||||
: isWarn
|
||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-amber-600/40 text-amber-200">WARN</span>
|
||||
: isSuccess
|
||||
? <span className="shrink-0 px-1.5 py-px rounded text-[10px] font-bold bg-emerald-600/30 text-emerald-200"> OK </span>
|
||||
: <span className="shrink-0 px-1.5 py-px rounded text-[10px] bg-slate-600/30 text-slate-400">INFO</span>;
|
||||
return (
|
||||
<React.Fragment key={i}>
|
||||
{showStage && (
|
||||
<div className={`flex items-center gap-2 my-2 py-1 px-2 border-l-4 rounded-r ${stageStyles[entry.stage || ''] || 'border-slate-500 bg-slate-500/10'}`}>
|
||||
<span className="text-xs">{stageIcons[entry.stage || ''] || '▸'}</span>
|
||||
<span className="text-[11px] font-semibold tracking-wider text-slate-200">{entry.stage}</span>
|
||||
<span className="flex-1 border-t border-slate-700/50"></span>
|
||||
</div>
|
||||
)}
|
||||
<div className={`flex items-start gap-2 px-2 py-0.5 -mx-1 rounded-sm ${accent} ${!isError && !isWarn && !isSuccess ? 'hover:bg-white/[0.03]' : ''}`}>
|
||||
<span className="text-slate-600 shrink-0 w-14 text-right text-[10px] pt-px">{entry.time}</span>
|
||||
{levelBadge}
|
||||
<span className={`break-all ${msgColor}`}>{entry.message}</span>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
});
|
||||
})()}
|
||||
{selectedRun.status === 'running' && (
|
||||
<div className="flex items-center gap-2 mt-2 py-1.5 px-3 bg-blue-500/10 border-l-4 border-blue-500 rounded-r">
|
||||
<Loader2 className="w-3 h-3 animate-spin text-blue-400 shrink-0" />
|
||||
<span className="text-[11px] text-blue-300 font-medium">流水线执行中,实时输出...</span>
|
||||
<span className="flex gap-1 ml-1">
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{animationDelay:'0ms'}}></span>
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{animationDelay:'150ms'}}></span>
|
||||
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{animationDelay:'300ms'}}></span>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* 工作区文件浏览器 */}
|
||||
<FileBrowserDialog
|
||||
open={browserOpen}
|
||||
onOpenChange={setBrowserOpen}
|
||||
runId={browserRun?.id ?? null}
|
||||
buildNumber={browserRun?.buildNumber ?? 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Trash2, Pencil, RefreshCw, Loader2, Users, UserPlus, UserX, Shield } 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 } from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { deployApi } from '@/services/api';
|
||||
|
||||
interface Group {
|
||||
id: number; name: string; description: string;
|
||||
members?: GroupMember[]; created_by: string; created_at: string;
|
||||
}
|
||||
interface GroupMember {
|
||||
id: number; group_id: number; username: string; created_at: string;
|
||||
}
|
||||
|
||||
export default function UserGroupsManager() {
|
||||
const _confirm = useConfirm();
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
|
||||
|
||||
// Group form
|
||||
const [formOpen, setFormOpen] = useState(false);
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [formName, setFormName] = useState('');
|
||||
const [formDesc, setFormDesc] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Add member — per-group input state
|
||||
const [memberUsers, setMemberUsers] = useState<Record<number, string>>({});
|
||||
|
||||
const loadGroups = async () => {
|
||||
setLoading(true);
|
||||
try { const res = await deployApi.getGroups(); setGroups((res as any)?.list || []); }
|
||||
catch { toast.error('加载失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadGroups(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formName.trim()) { toast.error('请输入组名'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editId) await deployApi.updateGroup(editId, { name: formName, description: formDesc });
|
||||
else await deployApi.createGroup({ name: formName, description: formDesc });
|
||||
toast.success(editId ? '已更新' : '已创建');
|
||||
setFormOpen(false); loadGroups();
|
||||
} catch { toast.error('操作失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (g: Group) => {
|
||||
const ok = await _confirm({ title: '删除用户组', description: `确定删除「${g.name}」?所有成员和流水线授权将同时移除。`, variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try { await deployApi.deleteGroup(g.id); toast.success('已删除'); loadGroups(); }
|
||||
catch { toast.error('删除失败'); }
|
||||
};
|
||||
|
||||
const handleAddMember = async (groupId: number) => {
|
||||
const username = (memberUsers[groupId] || '').trim();
|
||||
if (!username) { toast.error('输入用户名'); return; }
|
||||
try { await deployApi.addGroupMember(groupId, { username }); toast.success('已添加'); loadGroups(); setMemberUsers(prev => ({ ...prev, [groupId]: '' })); }
|
||||
catch { toast.error('添加失败'); }
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (groupId: number, memberId: number, username: string) => {
|
||||
const ok = await _confirm({ title: '移除成员', description: `确定从组中移除「${username}」?` });
|
||||
if (!ok) return;
|
||||
try { await deployApi.removeGroupMember(groupId, memberId); toast.success('已移除'); loadGroups(); }
|
||||
catch { toast.error('移除失败'); }
|
||||
};
|
||||
|
||||
const toggleExpand = (gid: number) => setExpanded(p => ({ ...p, [gid]: !p[gid] }));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">用户组管理</h2>
|
||||
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">创建用户组,将组授权给流水线,组内成员自动获得流水线权限</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={loadGroups}><RefreshCw className="w-4 h-4 mr-1" />刷新</Button>
|
||||
<Button size="sm" onClick={() => { setEditId(null); setFormName(''); setFormDesc(''); setFormOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建用户组
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center py-12"><Loader2 className="w-6 h-6 animate-spin text-[var(--dg-text-secondary)]" /></div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
|
||||
<Users className="w-10 h-10 mx-auto mb-3 opacity-30" />
|
||||
<p>暂无用户组,点击「新建用户组」创建</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{groups.map(g => (
|
||||
<div key={g.id} className="border border-[var(--dg-border)] rounded-lg bg-[var(--dg-bg-secondary)]">
|
||||
<div className="flex items-center justify-between p-4">
|
||||
<div className="flex items-center gap-3" style={{ cursor: 'pointer' }} onClick={() => toggleExpand(g.id)}>
|
||||
<Shield className="w-5 h-5 text-blue-400" />
|
||||
<div>
|
||||
<div className="font-medium text-[var(--dg-text-primary)]">{g.name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)]">{g.description || '暂无描述'} · {(g.members || []).length} 位成员</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => { setEditId(g.id); setFormName(g.name); setFormDesc(g.description || ''); setFormOpen(true); }}>
|
||||
<Pencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-red-500" onClick={() => handleDelete(g)}>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded[g.id] && (
|
||||
<div className="border-t border-[var(--dg-border)] px-4 py-3 space-y-3">
|
||||
{/* Add member */}
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 space-y-1"><Label className="text-xs">添加用户</Label>
|
||||
<Input value={memberUsers[g.id] || ''} onChange={e => setMemberUsers(prev => ({ ...prev, [g.id]: e.target.value }))} placeholder="输入用户名" className="h-8 text-sm" onKeyDown={e => e.key === 'Enter' && handleAddMember(g.id)} />
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={() => handleAddMember(g.id)}><UserPlus className="w-3.5 h-3.5 mr-1" />添加</Button>
|
||||
</div>
|
||||
{/* Members list */}
|
||||
{(g.members || []).length === 0 ? (
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] py-2">暂无成员</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(g.members || []).map(m => (
|
||||
<Badge key={m.id} variant="secondary" className="flex items-center gap-1 pl-2 pr-1 py-1.5">
|
||||
<Users className="w-3 h-3" />{m.username}
|
||||
<button className="ml-1 hover:text-red-400" onClick={() => handleRemoveMember(g.id, m.id, m.username)}>
|
||||
<UserX className="w-3 h-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Dialog */}
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{editId ? '编辑用户组' : '新建用户组'}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1"><Label>组名</Label><Input value={formName} onChange={e => setFormName(e.target.value)} placeholder="例如: 后端开发组" /></div>
|
||||
<div className="space-y-1"><Label>描述</Label><Input value={formDesc} onChange={e => setFormDesc(e.target.value)} placeholder="可选" /></div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-4 pt-4 border-t">
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>{saving ? '...' : '保存'}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Terminal as XTerm } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import 'xterm/css/xterm.css';
|
||||
import { Maximize2, Minimize2, X, FolderOpen } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUserStore } from '@/store';
|
||||
import http from '@/services/http';
|
||||
|
||||
interface WorkspaceTermDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
runId: number | null;
|
||||
buildNumber: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Workspace 终端 Dialog — 进入流水线构建目录查看文件
|
||||
*/
|
||||
export function WorkspaceTermDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
runId,
|
||||
buildNumber,
|
||||
}: WorkspaceTermDialogProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const termRef = useRef<XTerm | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [workspace, setWorkspace] = useState('');
|
||||
|
||||
// 获取 workspace 路径
|
||||
useEffect(() => {
|
||||
if (!open || !runId) return;
|
||||
http.get(`/pipelines/runs/${runId}/workspace`)
|
||||
.then((data: any) => setWorkspace(data?.workspace_path || ''))
|
||||
.catch(() => {});
|
||||
}, [open, runId]);
|
||||
|
||||
// 连接终端
|
||||
useEffect(() => {
|
||||
if (!open || !runId) return;
|
||||
|
||||
const term = new XTerm({
|
||||
cursorBlink: true,
|
||||
fontSize: 13,
|
||||
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||
allowProposedApi: true,
|
||||
theme: {
|
||||
background: '#0a0e14',
|
||||
foreground: '#d6d6d6',
|
||||
cursor: '#d6d6d6',
|
||||
selectionBackground: 'rgba(255,255,255,0.2)',
|
||||
},
|
||||
});
|
||||
const fit = new FitAddon();
|
||||
term.loadAddon(fit);
|
||||
termRef.current = term;
|
||||
fitRef.current = fit;
|
||||
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const token = useUserStore.getState().token || '';
|
||||
const wsUrl = `${proto}://${location.host}/ws/pipeline/${runId}/workspace-shell?token=${encodeURIComponent(token)}`;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
wsRef.current = ws;
|
||||
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ status: 0, data }));
|
||||
}
|
||||
});
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
try {
|
||||
const msg = JSON.parse(event.data);
|
||||
if (msg.status === 0 && typeof msg.data === 'string') {
|
||||
term.write(msg.data);
|
||||
}
|
||||
} catch {
|
||||
term.write(typeof event.data === 'string' ? event.data : '');
|
||||
}
|
||||
};
|
||||
|
||||
ws.onopen = () => {
|
||||
setConnected(true);
|
||||
if (containerRef.current) {
|
||||
term.open(containerRef.current);
|
||||
requestAnimationFrame(() => {
|
||||
try { fit.fit(); term.focus(); } catch { /* noop */ }
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
|
||||
};
|
||||
|
||||
return () => {
|
||||
try { ws.close(); term.dispose(); } catch { /* noop */ }
|
||||
termRef.current = null;
|
||||
wsRef.current = null;
|
||||
setConnected(false);
|
||||
};
|
||||
}, [open, runId]);
|
||||
|
||||
// ResizeObserver for fit
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const ro = new ResizeObserver(() => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
if (fitRef.current && termRef.current) {
|
||||
try {
|
||||
fitRef.current.fit();
|
||||
termRef.current.focus();
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const { cols, rows } = termRef.current;
|
||||
wsRef.current.send(JSON.stringify({ status: 1, cols, rows }));
|
||||
}
|
||||
} catch { /* noop */ }
|
||||
}
|
||||
}, 60);
|
||||
});
|
||||
ro.observe(container);
|
||||
return () => { clearTimeout(timer); ro.disconnect(); };
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className={cn(
|
||||
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
|
||||
'!duration-0 !transition-none',
|
||||
fullscreen
|
||||
? '!max-w-none !w-screen !h-screen !rounded-none'
|
||||
: ''
|
||||
)}
|
||||
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '900px', width: '90vw' }}
|
||||
>
|
||||
<DialogTitle className="sr-only">Workspace 终端 #{buildNumber}</DialogTitle>
|
||||
<DialogDescription className="sr-only">流水线构建目录终端</DialogDescription>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 text-sm min-w-0">
|
||||
<div className={cn('w-2 h-2 rounded-full shrink-0', connected ? 'bg-emerald-400' : 'bg-red-400')} />
|
||||
<FolderOpen className="h-4 w-4 text-[var(--dg-text-secondary)] shrink-0" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium truncate">
|
||||
#{buildNumber} 构建目录
|
||||
</span>
|
||||
{workspace && (
|
||||
<span className="text-[var(--dg-text-muted)] text-xs truncate hidden sm:inline">
|
||||
{workspace}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
>
|
||||
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="bg-[#0a0e14]"
|
||||
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,12 +3,12 @@ import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useThemeStore, useMenuStore } from '@/store';
|
||||
import {
|
||||
LayoutDashboard, Server, Cloud, Rocket, Bell, Monitor,
|
||||
LayoutDashboard, Server, Cloud, Bell, Monitor,
|
||||
CheckSquare, PlayCircle, Settings, ChevronDown, ChevronRight,
|
||||
Terminal, Cpu, Database, FileCode, Shield, Users,
|
||||
Menu, Network, CloudCog, Globe, MapPin, Key,
|
||||
Brain, FileTerminal, Activity, BarChart3, GitBranch,
|
||||
Bot, AlertTriangle, Container, Boxes, Clock,
|
||||
Bot, AlertTriangle, Container, Boxes, Clock, Rocket,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
@@ -73,8 +73,9 @@ const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: 'deploy', name: '应用发布', icon: Rocket,
|
||||
children: [
|
||||
{ id: 'pipelines', name: '流水线管理', icon: GitBranch, path: '/pipelines' },
|
||||
{ id: 'releases', name: '发布管理', icon: Rocket, path: '/releases' },
|
||||
{ id: 'pipelines', name: '流水线', icon: GitBranch, path: '/pipelines' },
|
||||
{ id: 'deploy-credentials', name: '发布配置', icon: Key, path: '/deploy/credentials' },
|
||||
{ id: 'deploy-rbac', name: '发布权限', icon: Shield, path: '/deploy/rbac' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -409,6 +409,27 @@ export const pipelineApi = {
|
||||
getRuns: async (id: number | string) => {
|
||||
return http.get(`/pipelines/${id}/runs`);
|
||||
},
|
||||
// 运行控制
|
||||
stopRun: async (pipelineId: number | string, runId: number | string) => {
|
||||
return http.post(`/pipelines/${pipelineId}/runs/${runId}/stop`);
|
||||
},
|
||||
getRunLog: async (pipelineId: number | string, runId: number | string) => {
|
||||
return http.get(`/pipelines/${pipelineId}/runs/${runId}/log`);
|
||||
},
|
||||
// 成员管理
|
||||
getMembers: async (pipelineId: number | string) => {
|
||||
return http.get(`/pipelines/${pipelineId}/members`);
|
||||
},
|
||||
addMember: async (pipelineId: number | string, data: { username: string; role: string }) => {
|
||||
return http.post(`/pipelines/${pipelineId}/members`, data);
|
||||
},
|
||||
removeMember: async (pipelineId: number | string, memberId: number | string) => {
|
||||
return http.delete(`/pipelines/${pipelineId}/members/${memberId}`);
|
||||
},
|
||||
// 组授权
|
||||
assignGroup: async (pipelineId: number | string, data: { group_id: number; role: string }) => {
|
||||
return http.post(`/pipelines/${pipelineId}/groups`, data);
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 应用发布 API ====================
|
||||
@@ -722,6 +743,43 @@ export const terminalSessionApi = {
|
||||
},
|
||||
};
|
||||
|
||||
// ==================== 发布系统 API(仓库/镜像仓库认证) ====================
|
||||
export const deployApi = {
|
||||
// Git 仓库认证
|
||||
getRepos: () => http.get<ListResponse>('/deploy/repos'),
|
||||
getRepo: (id: number | string) => http.get(`/deploy/repos/${id}`),
|
||||
createRepo: (data: any) => http.post('/deploy/repos', data),
|
||||
updateRepo: (id: number | string, data: any) => http.put(`/deploy/repos/${id}`, data),
|
||||
deleteRepo: (id: number | string) => http.delete(`/deploy/repos/${id}`),
|
||||
// 镜像仓库认证
|
||||
getRegistries: () => http.get<ListResponse>('/deploy/registries'),
|
||||
getRegistry: (id: number | string) => http.get(`/deploy/registries/${id}`),
|
||||
createRegistry: (data: any) => http.post('/deploy/registries', data),
|
||||
updateRegistry: (id: number | string, data: any) => http.put(`/deploy/registries/${id}`, data),
|
||||
deleteRegistry: (id: number | string) => http.delete(`/deploy/registries/${id}`),
|
||||
// 环境管理
|
||||
getEnvironments: () => http.get<ListResponse>('/deploy/environments'),
|
||||
createEnvironment: (data: any) => http.post('/deploy/environments', data),
|
||||
updateEnvironment: (id: number | string, data: any) => http.put(`/deploy/environments/${id}`, data),
|
||||
deleteEnvironment: (id: number | string) => http.delete(`/deploy/environments/${id}`),
|
||||
// Item Role 权限管理 (Jenkins Role Strategy 风格)
|
||||
getItemRoles: () => http.get<ListResponse>('/admin/item-roles'),
|
||||
createItemRole: (data: any) => http.post('/admin/item-roles', data),
|
||||
updateItemRole: (id: number | string, data: any) => http.put(`/admin/item-roles/${id}`, data),
|
||||
deleteItemRole: (id: number | string) => http.delete(`/admin/item-roles/${id}`),
|
||||
getUserItemRoles: (userId: number | string) => http.get(`/admin/users/${userId}/item-roles`),
|
||||
assignItemRole: (userId: number | string, data: { user_id: number; item_role_id: number }) => http.post(`/admin/users/${userId}/item-roles`, data),
|
||||
removeItemRole: (userId: number | string, roleId: number | string) => http.delete(`/admin/users/${userId}/item-roles/${roleId}`),
|
||||
// 用户组管理
|
||||
getGroups: () => http.get<ListResponse>('/deploy/groups'),
|
||||
createGroup: (data: any) => http.post('/deploy/groups', data),
|
||||
updateGroup: (id: number | string, data: any) => http.put(`/deploy/groups/${id}`, data),
|
||||
deleteGroup: (id: number | string) => http.delete(`/deploy/groups/${id}`),
|
||||
getGroupMembers: (id: number | string) => http.get(`/deploy/groups/${id}/members`),
|
||||
addGroupMember: (id: number | string, data: { username: string }) => http.post(`/deploy/groups/${id}/members`, data),
|
||||
removeGroupMember: (id: number | string, memberId: number | string) => http.delete(`/deploy/groups/${id}/members/${memberId}`),
|
||||
};
|
||||
|
||||
// ==================== Ansible 自动化 API ====================
|
||||
export const ansibleApi = {
|
||||
// Playbook 管理
|
||||
|
||||
Reference in new issue
Block a user