feat: FlowStack 发布系统首个可运行版本 (gin + React)

- Go gin 后端:流水线/节点/连线/运行/日志/GitOps/插件 REST API,
  SQLite(GORM) 自动建表 + seed,模拟执行引擎 + SSE 实时日志推送,
  前端产物 go:embed 嵌入单二进制,一个端口同时服务 API 与页面
- React 19 前端:画布式流水线编辑器(React Flow + Tekton 任务库)、
  GitOps 部署、批量发布、发布记录等 14 个模块页面,
  tRPC 全部替换为 REST(TanStack Query)
- 首次启动生成随机密码管理员账号(日志 + data/initial-admin-password.txt)
- run.sh 一键构建启动(默认端口 9527,支持 start/stop/restart/build/dev)
This commit is contained in:
Logan.Li committed 2026-10-27 02:44:10 +08:00
commit 04d1342b27
115 files changed
+23268

No files matched your search

+154
View File
@@ -0,0 +1,154 @@
import { useState, useCallback } from 'react';
import { Toaster } from 'sonner';
import Sidebar from '@/components/Sidebar';
import TopBar from '@/components/TopBar';
import PipelineFlowEditor from '@/components/flow/PipelineFlowEditor';
import BasicInfoPanel from '@/components/BasicInfoPanel';
import TriggerPanel from '@/components/TriggerPanel';
import VariablesPanel from '@/components/VariablesPanel';
import ReleasePanel from '@/components/ReleasePanel';
import PipelineList from '@/pages/PipelineList';
import EnvironmentManager from '@/pages/EnvironmentManager';
import CredentialManager from '@/pages/CredentialManager';
import BuildNodeManager from '@/pages/BuildNodeManager';
import ToolConfig from '@/pages/ToolConfig';
import JenkinsImport from '@/pages/JenkinsImport';
import BatchRelease from '@/pages/BatchRelease';
import ReleaseRecords from '@/pages/ReleaseRecords';
import WebhookConfig from '@/pages/WebhookConfig';
import GitOpsDeploy from '@/pages/GitOpsDeploy';
import PluginManager from '@/pages/PluginManager';
import SystemSettings from '@/pages/SystemSettings';
import { pipelineScenarios } from '@/data/pipelineData';
export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings';
export type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
export default function App() {
// Module navigation
const [activeModule, setActiveModule] = useState<ModuleType>('pipelines');
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
// Pipeline list/editor view toggle
const [pipelineView, setPipelineView] = useState<'list' | 'editor'>('list');
// Current editing pipeline ID for flow editor
const [currentPipelineId, setCurrentPipelineId] = useState<number>(1);
// Pipeline editor state (only used when in pipeline editor mode)
const [activeScenario, setActiveScenario] = useState<'go-backend' | 'vue-frontend'>('go-backend');
const [activeTab, setActiveTab] = useState<EditorTab>('流程配置');
const [isSimulating, setIsSimulating] = useState(false);
const [executionStatus, setExecutionStatus] = useState<'idle' | 'running' | 'success' | 'failed'>('idle');
const scenario = pipelineScenarios[activeScenario];
const handleScenarioChange = (key: 'go-backend' | 'vue-frontend') => {
setActiveScenario(key);
};
const handleSelectPipeline = useCallback((id: string) => {
setCurrentPipelineId(Number(id) || 1);
setPipelineView('editor');
setActiveTab('流程配置');
}, []);
const handleSimulateRun = useCallback(() => {
setIsSimulating(true);
setExecutionStatus('running');
setTimeout(() => {
setIsSimulating(false);
setExecutionStatus('success');
}, 3000);
}, []);
// Render content based on active module
const renderContent = () => {
switch (activeModule) {
case 'pipelines':
if (pipelineView === 'list') {
return <PipelineList onSelectPipeline={handleSelectPipeline} />;
}
// Pipeline editor mode
return (
<div className="h-full flex flex-col">
<TopBar
pipelineName={scenario.name}
activeTab={activeTab}
onTabChange={setActiveTab}
onSimulateRun={handleSimulateRun}
isSimulating={isSimulating}
scenarioKey={activeScenario}
onScenarioChange={handleScenarioChange}
executionStatus={executionStatus}
onBack={() => setPipelineView('list')}
/>
<div className="flex-1 flex overflow-hidden">
{activeTab === '流程配置' && (
<div className="flex-1 overflow-hidden">
<PipelineFlowEditor pipelineId={currentPipelineId} />
</div>
)}
{activeTab === '基本信息' && (
<div className="flex-1 overflow-y-auto">
<BasicInfoPanel />
</div>
)}
{activeTab === '触发设置' && (
<div className="flex-1 overflow-y-auto">
<TriggerPanel />
</div>
)}
{activeTab === '变量和缓存' && (
<div className="flex-1 overflow-y-auto">
<VariablesPanel />
</div>
)}
{activeTab === '发布' && (
<div className="flex-1 overflow-hidden">
<ReleasePanel />
</div>
)}
</div>
</div>
);
case 'environments':
return <EnvironmentManager />;
case 'credentials':
return <CredentialManager />;
case 'build-nodes':
return <BuildNodeManager />;
case 'tools':
return <ToolConfig />;
case 'jenkins-import':
return <JenkinsImport />;
case 'batch-release':
return <BatchRelease />;
case 'release-records':
return <ReleaseRecords />;
case 'webhooks':
return <WebhookConfig />;
case 'gitops':
return <GitOpsDeploy />;
case 'plugins':
return <PluginManager />;
case 'settings':
return <SystemSettings />;
default:
return <PipelineList onSelectPipeline={handleSelectPipeline} />;
}
};
return (
<div className="h-screen w-screen flex overflow-hidden" style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }}>
<Toaster position="top-right" richColors />
<Sidebar
activeModule={activeModule}
onModuleChange={setActiveModule}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
{renderContent()}
</div>
</div>
);
}
+272
View File
@@ -0,0 +1,272 @@
import { useState } from 'react';
import { Bell, Info, TestTube } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
export default function BasicInfoPanel() {
const [pipelineName, setPipelineName] = useState('gateway-pre-serverless');
const [description, setDescription] = useState('');
// Feishu notification
const [feishuEnabled, setFeishuEnabled] = useState(false);
const [feishuWebhook, setFeishuWebhook] = useState('');
const [feishuTiming, setFeishuTiming] = useState('all');
// DingTalk notification
const [dingtalkEnabled, setDingtalkEnabled] = useState(false);
const [dingtalkWebhook, setDingtalkWebhook] = useState('');
const [dingtalkTiming, setDingtalkTiming] = useState('all');
const handleTestConnection = (type: 'feishu' | 'dingtalk') => {
toast.success(`${type === 'feishu' ? '飞书' : '钉钉'} Webhook 连接测试成功`, {
description: '通知服务可正常访问',
});
};
const timingOptions = [
{ value: 'all', label: '全部事件' },
{ value: 'success', label: '仅成功' },
{ value: 'failure', label: '仅失败' },
];
return (
<div className="tab-content-fade space-y-6 w-full px-6 py-6">
{/* Basic Information */}
<div
className="rounded-lg border p-6 shadow-sm"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--border-color)',
}}
>
<div className="flex items-center gap-2 mb-5">
<Info className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<h3
className="text-base font-semibold"
style={{ color: 'var(--text-primary)' }}
>
基本信息
</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
<div>
<Label className="text-sm font-medium mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
流水线名称
</Label>
<Input
value={pipelineName}
onChange={(e) => setPipelineName(e.target.value)}
placeholder="输入流水线名称"
className="h-9"
/>
</div>
<div>
<Label className="text-sm font-medium mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
描述
</Label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="输入流水线描述(可选)"
rows={1}
className="w-full px-3 py-2 text-sm rounded-md border outline-none focus:ring-1 focus:ring-[var(--accent-blue)] focus:border-transparent transition-all resize-none h-9"
style={{
background: 'var(--canvas-bg)',
borderColor: 'var(--border-color)',
color: 'var(--text-primary)',
}}
/>
</div>
<div>
<Label className="text-xs mb-1 block" style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
创建人
</Label>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
admin
</span>
</div>
<div>
<Label className="text-xs mb-1 block" style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
创建时间
</Label>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
2024-06-20
</span>
</div>
<div>
<Label className="text-xs mb-1 block" style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
最近执行
</Label>
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
未执行
</span>
</div>
</div>
</div>
{/* Notification Settings */}
<div
className="rounded-lg border p-6 shadow-sm"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--border-color)',
}}
>
<div className="flex items-center gap-2 mb-5">
<Bell className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<h3
className="text-base font-semibold"
style={{ color: 'var(--text-primary)' }}
>
通知设置
</h3>
</div>
<div className="space-y-6">
{/* Feishu */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
飞书通知
</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>通过飞书群机器人发送流水线执行通知</p>
</TooltipContent>
</Tooltip>
</div>
<Switch checked={feishuEnabled} onCheckedChange={setFeishuEnabled} />
</div>
{feishuEnabled && (
<div className="tab-content-fade pl-0 space-y-3">
<div>
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
Webhook 地址
</Label>
<Input
value={feishuWebhook}
onChange={(e) => setFeishuWebhook(e.target.value)}
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
className="h-9"
/>
</div>
<div>
<Label className="text-xs mb-2 block" style={{ color: 'var(--text-secondary)' }}>
通知时机
</Label>
<RadioGroup
value={feishuTiming}
onValueChange={setFeishuTiming}
className="flex items-center gap-4"
>
{timingOptions.map((opt) => (
<div key={opt.value} className="flex items-center space-x-2">
<RadioGroupItem value={opt.value} id={`feishu-${opt.value}`} />
<Label htmlFor={`feishu-${opt.value}`} className="text-sm cursor-pointer">
{opt.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className="pt-1">
<Button
variant="outline"
size="sm"
onClick={() => handleTestConnection('feishu')}
className="h-8"
>
<TestTube className="w-3.5 h-3.5 mr-1.5" />
测试连接
</Button>
</div>
</div>
)}
</div>
<Separator style={{ backgroundColor: 'var(--border-color)' }} />
{/* DingTalk */}
<div>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
钉钉通知
</span>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>通过钉钉群机器人发送流水线执行通知</p>
</TooltipContent>
</Tooltip>
</div>
<Switch checked={dingtalkEnabled} onCheckedChange={setDingtalkEnabled} />
</div>
{dingtalkEnabled && (
<div className="tab-content-fade pl-0 space-y-3">
<div>
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
Webhook 地址
</Label>
<Input
value={dingtalkWebhook}
onChange={(e) => setDingtalkWebhook(e.target.value)}
placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
className="h-9"
/>
</div>
<div>
<Label className="text-xs mb-2 block" style={{ color: 'var(--text-secondary)' }}>
通知时机
</Label>
<RadioGroup
value={dingtalkTiming}
onValueChange={setDingtalkTiming}
className="flex items-center gap-4"
>
{timingOptions.map((opt) => (
<div key={opt.value} className="flex items-center space-x-2">
<RadioGroupItem value={opt.value} id={`dingtalk-${opt.value}`} />
<Label htmlFor={`dingtalk-${opt.value}`} className="text-sm cursor-pointer">
{opt.label}
</Label>
</div>
))}
</RadioGroup>
</div>
<div className="pt-1">
<Button
variant="outline"
size="sm"
onClick={() => handleTestConnection('dingtalk')}
className="h-8"
>
<TestTube className="w-3.5 h-3.5 mr-1.5" />
测试连接
</Button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,287 @@
import { useState } from 'react';
import { Plus, ChevronDown, ChevronRight, ChevronLeft, Box, Layers, GitBranch, Container, Terminal, Shield, Package, BookOpen, X } from 'lucide-react';
import type { PipelineStage } from '@/types/pipeline';
import StageCard from './StageCard';
import { toast } from 'sonner';
interface TektonTask {
id: string;
name: string;
description: string;
icon: typeof Box;
category: 'source' | 'build' | 'test' | 'security' | 'deploy' | 'gitops';
params: { name: string; type: string; default?: string; description: string }[];
workspaces: string[];
results?: string[];
}
const taskCatalog: TektonTask[] = [
{ id: 'tk-git-clone', name: 'git-clone', description: '从 Git 仓库克隆代码', icon: GitBranch, category: 'source', params: [{ name: 'url', type: 'string', description: 'Git 仓库 URL' }, { name: 'revision', type: 'string', default: 'main', description: '分支或标签' }], workspaces: ['source'] },
{ id: 'tk-maven', name: 'maven', description: 'Maven 构建 Java 项目', icon: Package, category: 'build', params: [{ name: 'goals', type: 'array', default: 'clean package', description: 'Maven 目标' }, { name: 'settings', type: 'string', description: 'settings.xml 路径' }], workspaces: ['source', 'm2-cache'] },
{ id: 'tk-npm', name: 'npm', description: 'npm install + build', icon: Package, category: 'build', params: [{ name: 'script', type: 'string', default: 'build', description: 'npm 脚本' }], workspaces: ['source'] },
{ id: 'tk-go-build', name: 'go-build', description: 'Go 编译', icon: Package, category: 'build', params: [{ name: 'package', type: 'string', description: '包路径' }, { name: 'flags', type: 'string', description: '编译参数' }], workspaces: ['source'] },
{ id: 'tk-kaniko', name: 'kaniko-build', description: '无需特权构建 Docker 镜像', icon: Container, category: 'build', params: [{ name: 'dockerfile', type: 'string', default: 'Dockerfile', description: 'Dockerfile 路径' }, { name: 'image', type: 'string', description: '目标镜像地址' }], workspaces: ['source'], results: ['IMAGE_DIGEST'] },
{ id: 'tk-trivy', name: 'trivy-scan', description: '容器镜像漏洞扫描', icon: Shield, category: 'security', params: [{ name: 'image', type: 'string', description: '待扫描镜像' }, { name: 'severity', type: 'string', default: 'HIGH,CRITICAL', description: '严重等级' }], workspaces: [] },
{ id: 'tk-unit-test', name: 'unit-test', description: '运行单元测试', icon: Terminal, category: 'test', params: [{ name: 'test-command', type: 'string', description: '测试命令' }], workspaces: ['source'] },
{ id: 'tk-kubectl', name: 'kubectl-deploy', description: 'kubectl apply 部署', icon: Layers, category: 'deploy', params: [{ name: 'manifest', type: 'string', description: 'manifest 路径' }, { name: 'namespace', type: 'string', description: '目标命名空间' }], workspaces: ['source'] },
{ id: 'tk-update-manifest', name: 'update-manifest', description: '更新 GitOps 仓库镜像标签', icon: BookOpen, category: 'gitops', params: [{ name: 'repo', type: 'string', description: 'GitOps 仓库' }, { name: 'image', type: 'string', description: '新镜像地址' }, { name: 'path', type: 'string', description: 'manifest 路径' }], workspaces: ['source'] },
{ id: 'tk-helm-upgrade', name: 'helm-upgrade', description: 'Helm 部署/升级', icon: Layers, category: 'deploy', params: [{ name: 'release', type: 'string', description: 'Release 名称' }, { name: 'chart', type: 'string', description: 'Chart 路径' }, { name: 'namespace', type: 'string', description: '命名空间' }], workspaces: ['source'] },
];
const categoryConfig: Record<string, { label: string; color: string }> = {
source: { label: '源码', color: '#0969da' },
build: { label: '构建', color: '#1a7f37' },
test: { label: '测试', color: '#9a6700' },
security: { label: '安全', color: '#cf222e' },
deploy: { label: '部署', color: '#8250df' },
gitops: { label: 'GitOps', color: '#0e7c7b' },
};
interface PipelineStageEditorProps {
stages: PipelineStage[];
selectedTaskId: string | null;
onSelectTask: (taskId: string) => void;
onUpdateStages: (stages: PipelineStage[]) => void;
}
export default function PipelineStageEditor({
stages,
selectedTaskId,
onSelectTask,
onUpdateStages,
}: PipelineStageEditorProps) {
const [catalogOpen, setCatalogOpen] = useState(true);
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [showWorkspaceConfig, setShowWorkspaceConfig] = useState(false);
const handleMoveStage = (index: number, direction: 'up' | 'down') => {
const newIndex = direction === 'up' ? index - 1 : index + 1;
if (newIndex < 0 || newIndex >= stages.length) return;
const newStages = [...stages];
[newStages[index], newStages[newIndex]] = [newStages[newIndex], newStages[index]];
onUpdateStages(newStages);
};
const handleDeleteStage = (index: number) => {
const newStages = stages.filter((_, i) => i !== index);
onUpdateStages(newStages);
};
const handleUpdateStageName = (index: number, name: string) => {
const newStages = stages.map((s, i) => (i === index ? { ...s, name } : s));
onUpdateStages(newStages);
};
const handleAddStage = () => {
const newStage: PipelineStage = {
id: `stage-${Date.now()}`,
name: '新阶段',
type: 'custom',
tasks: [],
};
onUpdateStages([...stages, newStage]);
};
const addTaskFromCatalog = (task: TektonTask) => {
// Add to last stage or create new stage
if (stages.length === 0) {
handleAddStage();
return;
}
const lastStageIndex = stages.length - 1;
const newTask: any = {
id: `${task.id}-${Date.now()}`,
name: task.name,
type: (task.category === 'deploy' || task.category === 'gitops' ? 'deploy' : task.category === 'build' ? 'build' : 'custom') as 'build' | 'deploy' | 'test' | 'custom',
icon: task.category,
config: {
params: task.params.reduce((acc, p) => ({ ...acc, [p.name]: p.default || '' }), {} as Record<string, string>),
workspaces: task.workspaces.reduce((acc, w) => ({ ...acc, [w]: w }), {} as Record<string, string>),
results: task.results || [],
},
};
const newStages = stages.map((s, i) =>
i === lastStageIndex ? { ...s, tasks: [...s.tasks, newTask] } : s
);
onUpdateStages(newStages);
toast.success(`已添加 Task: ${task.name}`, { description: `${task.workspaces.length} 个 Workspace` });
};
const filteredTasks = selectedCategory === 'all' ? taskCatalog : taskCatalog.filter(t => t.category === selectedCategory);
return (
<div className="min-h-full flex" style={{ background: 'var(--canvas-bg)' }}>
{/* Task Catalog Sidebar */}
<div
className="border-r flex-shrink-0 transition-all duration-300 overflow-hidden"
style={{
width: catalogOpen ? '260px' : '0px',
borderColor: 'var(--border-color)',
background: 'var(--panel-bg)',
}}
>
<div className="w-[260px] h-full flex flex-col">
<div className="px-3 py-2.5 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>Task 任务库</span>
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>Tekton</span>
</div>
<div className="p-2 border-b" style={{ borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-1 flex-wrap">
<button onClick={() => setSelectedCategory('all')}
className="text-[10px] px-2 py-[3px] rounded font-medium transition-colors"
style={{ background: selectedCategory === 'all' ? 'var(--accent-blue)' : 'var(--panel-bg-secondary)', color: selectedCategory === 'all' ? '#fff' : 'var(--text-tertiary)' }}>
全部
</button>
{Object.entries(categoryConfig).map(([key, cfg]) => (
<button key={key} onClick={() => setSelectedCategory(key)}
className="text-[10px] px-2 py-[3px] rounded font-medium transition-colors"
style={{ background: selectedCategory === key ? cfg.color : 'var(--panel-bg-secondary)', color: selectedCategory === key ? '#fff' : 'var(--text-tertiary)' }}>
{cfg.label}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
{filteredTasks.map(task => {
const Icon = task.icon;
const cat = categoryConfig[task.category];
return (
<div key={task.id}
onClick={() => addTaskFromCatalog(task)}
className="p-2.5 rounded-md border cursor-pointer hover:bg-black/[0.02] transition-colors group"
style={{ borderColor: 'var(--border-color-light)' }}>
<div className="flex items-center gap-2 mb-1">
<Icon className="w-3.5 h-3.5" style={{ color: cat.color }} />
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{task.name}</span>
<span className="text-[9px] px-1 py-[1px] rounded font-medium ml-auto" style={{ background: cat.color + '15', color: cat.color }}>{cat.label}</span>
</div>
<p className="text-[10px] mb-1" style={{ color: 'var(--text-secondary)' }}>{task.description}</p>
<div className="flex items-center gap-1.5">
{task.workspaces.length > 0 && (
<span className="text-[9px] flex items-center gap-0.5" style={{ color: 'var(--text-tertiary)' }}>
<Box className="w-2.5 h-2.5" />{task.workspaces.length} ws
</span>
)}
{task.params.length > 0 && (
<span className="text-[9px]" style={{ color: 'var(--text-tertiary)' }}>{task.params.length} params</span>
)}
{task.results && (
<span className="text-[9px]" style={{ color: 'var(--text-tertiary)' }}>{task.results.length} results</span>
)}
</div>
</div>
);
})}
</div>
<div className="px-3 py-2 border-t text-[10px]" style={{ borderColor: 'var(--border-color)', color: 'var(--text-tertiary)' }}>
点击 Task 添加到流水线末尾阶段
</div>
</div>
</div>
{/* Main Editor */}
<div className="flex-1 min-w-0">
<div className="px-6 py-5">
{/* Pipeline metadata bar */}
<div className="flex items-center justify-between mb-5 w-full" style={{ maxWidth: '720px', marginLeft: 'auto', marginRight: 'auto' }}>
<div className="flex items-center gap-3">
<button
onClick={() => setCatalogOpen(!catalogOpen)}
className="flex items-center gap-1 px-2 py-[5px] text-[11px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
title={catalogOpen ? '收起任务库' : '展开任务库'}
>
{catalogOpen ? <ChevronLeft className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
Task 库
</button>
<div className="flex items-center gap-2">
<span className="text-[13px] font-semibold tracking-tight" style={{ color: 'var(--text-primary)' }}>
流水线阶段
</span>
<span className="text-[11px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
{stages.length}
</span>
</div>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowWorkspaceConfig(!showWorkspaceConfig)}
className="flex items-center gap-1 px-2 py-[5px] text-[11px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
>
<Box className="w-3 h-3" />Workspace
</button>
<button
onClick={handleAddStage}
className="flex items-center gap-1.5 px-3 py-[6px] text-[12px] font-medium rounded-md text-white transition-all hover:brightness-110 active:scale-[0.98]"
style={{ background: 'var(--accent-blue)' }}
>
<Plus className="w-3.5 h-3.5" />添加阶段
</button>
</div>
</div>
{/* Workspace config panel */}
{showWorkspaceConfig && (
<div className="mb-4 rounded-lg border p-3 mx-auto" style={{ maxWidth: '720px', borderColor: 'var(--border-color)', background: 'var(--panel-bg)' }}>
<div className="flex items-center justify-between mb-2">
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>Pipeline Workspace 配置</span>
<button onClick={() => setShowWorkspaceConfig(false)} className="p-0.5 rounded hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }}><X className="w-3.5 h-3.5" /></button>
</div>
<div className="grid grid-cols-3 gap-2">
{['source', 'm2-cache', 'docker-config'].map(ws => (
<div key={ws} className="flex items-center gap-2 p-2 rounded-md border text-[11px]" style={{ borderColor: 'var(--border-color-light)' }}>
<Box className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
<span style={{ color: 'var(--text-primary)' }}>{ws}</span>
<select className="ml-auto text-[10px] rounded border outline-none px-1 py-[2px]" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
<option>EmptyDir</option><option>PVC</option><option>ConfigMap</option><option>Secret</option>
</select>
</div>
))}
</div>
</div>
)}
{/* Stage list */}
<div className="flex flex-col items-center" style={{ maxWidth: '720px', marginLeft: 'auto', marginRight: 'auto' }}>
{stages.map((stage, index) => (
<div key={stage.id} className="w-full flex flex-col items-center">
<StageCard
stage={stage}
index={index}
isFirst={index === 0}
isLast={index === stages.length - 1}
selectedTaskId={selectedTaskId}
onSelectTask={onSelectTask}
onMoveUp={() => handleMoveStage(index, 'up')}
onMoveDown={() => handleMoveStage(index, 'down')}
onDelete={() => handleDeleteStage(index)}
onUpdateName={(name) => handleUpdateStageName(index, name)}
/>
{index < stages.length - 1 && (
<div className="flex flex-col items-center py-2">
<div className="flex flex-col items-center gap-[2px]">
<div className="w-px h-3" style={{ background: 'var(--border-color)' }} />
<ChevronDown className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<div className="w-px h-3" style={{ background: 'var(--border-color)' }} />
</div>
</div>
)}
</div>
))}
</div>
{stages.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 rounded-lg border-2 border-dashed mx-auto" style={{ maxWidth: '720px', borderColor: 'var(--border-color-light)', background: 'var(--panel-bg)' }}>
<div className="w-12 h-12 rounded-full flex items-center justify-center mb-3" style={{ background: 'var(--panel-bg-secondary)' }}>
<Plus className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
</div>
<p className="text-[13px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>暂无阶段</p>
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>从左侧 Task 库选择任务,或添加阶段</p>
</div>
)}
</div>
</div>
</div>
);
}
+401
View File
@@ -0,0 +1,401 @@
import { useState, useEffect, useRef } from 'react';
import {
CheckCircle2,
XCircle,
Loader2,
Clock,
RotateCcw,
Eye,
GitCommit,
Hand,
Timer,
} from 'lucide-react';
import type { ExecutionRecord } from '@/types/pipeline';
const mockExecutions: ExecutionRecord[] = [
{
id: 5,
status: 'running',
trigger: 'Manual',
triggerBy: 'admin',
duration: '1分12秒',
timestamp: '2024-06-20 15:45:00',
pipelineName: 'aiops-backend',
},
{
id: 4,
status: 'success',
trigger: 'Git Push',
triggerBy: 'system',
duration: '3分22秒',
timestamp: '2024-06-20 14:30:15',
pipelineName: 'aiops-backend',
},
{
id: 3,
status: 'failed',
trigger: 'Manual',
triggerBy: 'admin',
duration: '45秒',
timestamp: '2024-06-20 13:20:00',
pipelineName: 'aiops-backend',
},
{
id: 2,
status: 'success',
trigger: 'Cron',
triggerBy: 'system',
duration: '5分10秒',
timestamp: '2024-06-20 02:00:00',
pipelineName: 'aiops-backend',
},
{
id: 1,
status: 'success',
trigger: 'Git Push',
triggerBy: 'system',
duration: '4分55秒',
timestamp: '2024-06-19 18:45:30',
pipelineName: 'aiops-backend',
},
];
const fullLogLines: string[] = [
'[15:45:00] ===== 流水线开始执行: aiops-backend =====',
'[15:45:00] 触发方式: 手动 (admin)',
'',
'[15:45:01] ===== 阶段: 拉取代码 =====',
'[15:45:01] > Git 拉取',
'[15:45:03] Cloning into \'aiops\'...',
'[15:45:08] Already up to date. commit: 7a3f2e1',
'[15:45:08] \u2713 Git 拉取 完成 (7s)',
'',
'[15:45:08] ===== 阶段: 构建镜像 =====',
'[15:45:09] > Docker 构建 & 推送',
'[15:45:10] === 开始构建 Docker 镜像 ===',
'[15:45:10] 镜像地址: registry.example.com/aiops/aiops-backend:202506201545',
'[15:45:11] Docker登录...',
'[15:45:12] Login Succeeded',
'[15:45:15] Building image...',
'[15:45:45] Successfully built 9a8b7c6d5e4f',
'[15:45:46] 推送镜像...',
'[15:46:02] latest: digest: sha256:abc123... size: 2847',
'[15:46:02] \u2713 Docker 构建 & 推送 完成 (53s)',
'[15:46:02] > 单元测试',
'[15:46:03] go test -v -race -coverprofile=coverage.out ./...',
'[15:46:15] PASS: TestUserLogin (0.23s)',
'[15:46:15] PASS: TestDataQuery (1.12s)',
'[15:46:18] PASS: TestWebhookHandler (0.45s)',
'[15:46:20] coverage: 82.3% of statements',
'[15:46:20] \u2713 单元测试 完成 (18s) \u2014 覆盖率: 82.3%',
'',
'[15:46:20] ===== 阶段: K8s 部署 =====',
'[15:46:21] > K8s 滚动更新',
'[15:46:22] 更新镜像: registry.example.com/aiops/aiops-backend:202506201545',
'[15:46:25] deployment.apps/aiops-backend image updated',
'[15:46:25] 等待滚动更新...',
'[15:46:30] [=====================> ] 2/3 pods ready',
'[15:46:35] [========================>] 3/3 pods ready',
'[15:46:36] deployment "aiops-backend" successfully rolled out',
'[15:46:36] Pod状态:',
'[15:46:36] NAME READY STATUS',
'[15:46:36] aiops-backend-7a3f2e1-x1 1/1 Running',
'[15:46:36] aiops-backend-7a3f2e1-x2 1/1 Running',
'[15:46:36] aiops-backend-7a3f2e1-x3 1/1 Running',
'[15:46:36] \u2713 K8s 滚动更新 完成 (15s)',
'',
'[15:46:37] ===== 阶段: 后置操作 =====',
'[15:46:37] > 刷新CDN',
'[15:46:37] 刷新CDN缓存...',
'[15:46:38] {"code": 200, "message": "refresh submitted"}',
'[15:46:38] \u2713 刷新CDN 完成 (1s)',
'',
'[15:46:38] ===== 流水线执行完成 =====',
'[15:46:38] 总耗时: 1分38秒',
'[15:46:38] 结果: \u2713 成功',
];
function getStatusIcon(status: string, className = 'w-5 h-5') {
switch (status) {
case 'success':
return <CheckCircle2 className={`${className} text-green-500`} />;
case 'failed':
return <XCircle className={`${className} text-red-500`} />;
case 'running':
return <Loader2 className={`${className} text-blue-500 animate-spin`} />;
default:
return <Clock className={`${className} text-gray-400`} />;
}
}
function getStatusText(status: string) {
switch (status) {
case 'success':
return '成功';
case 'failed':
return '失败';
case 'running':
return '正在执行';
default:
return '等待中';
}
}
function getTriggerIcon(trigger: string, className = 'w-3.5 h-3.5') {
switch (trigger) {
case 'Git Push':
return <GitCommit className={className} />;
case 'Manual':
return <Hand className={className} />;
case 'Cron':
return <Timer className={className} />;
default:
return <Clock className={className} />;
}
}
function LogLine({ line }: { line: string }) {
if (!line) {
return <div className="h-4" />;
}
const timestamp = line.match(/^\[(\d{2}:\d{2}:\d{2})\]/)?.[0] || '';
const content = line.slice(timestamp.length);
// Determine color based on content
let contentColor = '#e6e6e6';
if (content.includes('\u2713') || content.includes('成功') || content.includes('PASS')) {
contentColor = '#7ee787';
} else if (content.includes('失败') || content.includes('error') || content.includes('Error')) {
contentColor = '#f85149';
} else if (content.includes('警告') || content.includes('warn') || content.includes('Waiting')) {
contentColor = '#d29922';
} else if (content.startsWith(' =====')) {
contentColor = '#79c0ff';
} else if (content.startsWith(' > ')) {
contentColor = '#d2a8ff';
}
return (
<div className="font-mono text-sm leading-relaxed whitespace-pre-wrap">
{timestamp && (
<span style={{ color: '#8b949e' }}>{timestamp}</span>
)}
<span style={{ color: contentColor }}>{content}</span>
</div>
);
}
export default function ReleasePanel() {
const [selectedExecutionId, setSelectedExecutionId] = useState(5);
const [displayedLines, setDisplayedLines] = useState<string[]>([]);
const [isAnimating, setIsAnimating] = useState(false);
const logEndRef = useRef<HTMLDivElement>(null);
const animatingRef = useRef(false);
const selectedExecution = mockExecutions.find((e) => e.id === selectedExecutionId);
const isRunning = selectedExecution?.status === 'running';
// Animate log lines for running execution
useEffect(() => {
if (isRunning) {
setDisplayedLines([]);
setIsAnimating(true);
animatingRef.current = true;
let index = 0;
const interval = setInterval(() => {
if (!animatingRef.current) {
clearInterval(interval);
return;
}
if (index < fullLogLines.length) {
setDisplayedLines((prev) => [...prev, fullLogLines[index]]);
index++;
} else {
setIsAnimating(false);
animatingRef.current = false;
clearInterval(interval);
}
}, 80); // Each line appears every 80ms
return () => {
animatingRef.current = false;
clearInterval(interval);
};
} else {
// Show full log immediately for completed executions
setDisplayedLines(fullLogLines);
setIsAnimating(false);
}
}, [selectedExecutionId, isRunning]);
// Auto-scroll to bottom
useEffect(() => {
if (logEndRef.current) {
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
}
}, [displayedLines]);
return (
<div className="flex h-full" style={{ background: 'var(--panel-bg)' }}>
{/* Left: Execution History (40%) */}
<div
className="w-[40%] border-r flex flex-col overflow-hidden"
style={{
borderColor: 'var(--border-color)',
minWidth: '340px',
}}
>
{/* Header */}
<div
className="flex items-center justify-between px-4 py-3 border-b"
style={{ borderColor: 'var(--border-color)' }}
>
<span
className="text-sm font-semibold"
style={{ color: 'var(--text-primary)' }}
>
执行记录
</span>
<span className="text-xs px-2 py-0.5 rounded-full" style={{ color: 'var(--text-secondary)', background: 'var(--panel-bg-secondary)' }}>
{mockExecutions.length} 条记录
</span>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{mockExecutions.map((execution) => (
<div
key={execution.id}
onClick={() => setSelectedExecutionId(execution.id)}
className={`px-4 py-3 border-b cursor-pointer transition-colors ${
selectedExecutionId === execution.id ? 'bg-blue-50/50' : 'hover:bg-[var(--panel-bg-secondary)]'
}`}
style={{ borderColor: 'var(--border-color-light)' }}
>
<div className="flex items-start gap-3">
{getStatusIcon(execution.status)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span
className="text-sm font-semibold"
style={{ color: 'var(--text-primary)' }}
>
#{execution.id}
</span>
<span
className="text-xs px-1.5 py-0.5 rounded"
style={{
background:
execution.status === 'success'
? '#dafbe1'
: execution.status === 'failed'
? '#ffebe9'
: execution.status === 'running'
? '#ddf4ff'
: '#f6f8fa',
color:
execution.status === 'success'
? '#1a7f37'
: execution.status === 'failed'
? '#cf222e'
: execution.status === 'running'
? '#0969da'
: '#6e7781',
}}
>
{getStatusText(execution.status)}
</span>
</div>
<div className="flex items-center gap-3 text-xs" style={{ color: 'var(--text-secondary)' }}>
<span className="flex items-center gap-1">
{getTriggerIcon(execution.trigger)}
{execution.trigger}
</span>
<span>{execution.triggerBy}</span>
</div>
<div className="flex items-center justify-between mt-1.5">
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{execution.duration}
</span>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{execution.timestamp}
</span>
</div>
</div>
<div className="flex flex-col gap-1">
<button
className="p-1 rounded hover:bg-[var(--panel-bg-secondary)] transition-colors"
title="重新执行"
onClick={(e) => {
e.stopPropagation();
}}
>
<RotateCcw className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
</button>
<button
className="p-1 rounded hover:bg-[var(--panel-bg-secondary)] transition-colors"
title="查看日志"
onClick={(e) => {
e.stopPropagation();
setSelectedExecutionId(execution.id);
}}
>
<Eye className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
</button>
</div>
</div>
</div>
))}
</div>
</div>
{/* Right: Log Viewer (60%) */}
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
{/* Log header */}
<div
className="flex items-center justify-between px-4 py-3 border-b"
style={{ borderColor: 'var(--border-color)' }}
>
<div className="flex items-center gap-2">
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
执行日志
</span>
{isRunning && isAnimating && (
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 animate-pulse">
实时输出中...
</span>
)}
</div>
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
{selectedExecution?.pipelineName} #{selectedExecutionId}
</span>
</div>
{/* Log content */}
<div
className="flex-1 overflow-y-auto custom-scrollbar px-4 py-3"
style={{ background: '#1e1e1e' }}
>
{displayedLines.map((line, index) => (
<LogLine key={`${selectedExecutionId}-${index}`} line={line} />
))}
{isAnimating && (
<div className="flex items-center gap-2 mt-2">
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
<span className="text-xs" style={{ color: '#8b949e' }}>
等待新输出...
</span>
</div>
)}
<div ref={logEndRef} />
</div>
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
+237
View File
@@ -0,0 +1,237 @@
import { useState, useCallback, useRef, useEffect } from 'react';
interface ScriptEditorProps {
value: string;
onChange: (value: string) => void;
language: 'shell' | 'python';
theme?: 'light' | 'dark';
}
// Basic syntax highlighting patterns
const shellKeywords = ['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'case', 'esac', 'function', 'return', 'exit', 'echo', 'printf', 'read', 'source', 'export', 'unset', 'local', 'shift', 'break', 'continue', 'in', 'trap'];
const pythonKeywords = ['and', 'as', 'assert', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'False', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'None', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'True', 'try', 'while', 'with', 'yield'];
const builtinFunctions = ['print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple', 'type', 'isinstance', 'hasattr', 'getattr', 'open', 'input', 'map', 'filter', 'zip', 'enumerate', 'sorted', 'reversed', 'sum', 'min', 'max', 'abs', 'round', 'pow', 'divmod', 'chr', 'ord', 'hex', 'bin', 'oct', 'format'];
// Dark theme syntax colors
const darkSyntaxColors = {
comment: '#6a9955',
string: '#ce9178',
keyword: '#569cd6',
function: '#dcdcaa',
number: '#b5cea8',
default: '#d4d4d4',
};
// Light theme syntax colors
const lightSyntaxColors = {
comment: '#1a7f37',
string: '#cf222e',
keyword: '#8250df',
function: '#8250df',
number: '#9a6700',
default: '#1f2328',
};
function highlightLine(line: string, language: 'shell' | 'python', isDark: boolean): string {
let html = '';
let remaining = line;
const colors = isDark ? darkSyntaxColors : lightSyntaxColors;
const keywords = language === 'shell' ? shellKeywords : pythonKeywords;
const kwPattern = new RegExp(`^(\\s*)(${keywords.join('|')})(?![a-zA-Z0-9_])`);
const funcPattern = /^(\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*\()/;
const stringPattern = /^(["'])(.*?)(\1)/;
const commentPattern = language === 'shell' ? /^(#.*)/ : /^(#.*)/;
const numberPattern = /^([0-9]+\.?[0-9]*)/;
const wordPattern = /^([a-zA-Z_][a-zA-Z0-9_]*)/;
const spacePattern = /^(\s+)/;
const otherPattern = /^(.)/;
while (remaining.length > 0) {
// Check for comment first
const commentMatch = remaining.match(commentPattern);
if (commentMatch) {
html += `<span style="color: ${colors.comment}; font-style: italic;">${escapeHtml(commentMatch[0])}</span>`;
remaining = remaining.slice(commentMatch[0].length);
continue;
}
// String literals
const stringMatch = remaining.match(stringPattern);
if (stringMatch) {
html += `<span style="color: ${colors.string};">${escapeHtml(stringMatch[0])}</span>`;
remaining = remaining.slice(stringMatch[0].length);
continue;
}
// Function calls
const funcMatch = remaining.match(funcPattern);
if (funcMatch && language === 'python' && builtinFunctions.includes(funcMatch[2])) {
html += `${escapeHtml(funcMatch[1])}<span style="color: ${colors.function};">${escapeHtml(funcMatch[2])}</span>${escapeHtml(funcMatch[3])}`;
remaining = remaining.slice(funcMatch[0].length);
continue;
}
// Keywords
const kwMatch = remaining.match(kwPattern);
if (kwMatch) {
html += `${escapeHtml(kwMatch[1])}<span style="color: ${colors.keyword}; font-weight: 600;">${escapeHtml(kwMatch[2])}</span>`;
remaining = remaining.slice(kwMatch[0].length);
continue;
}
// Numbers
const numMatch = remaining.match(numberPattern);
if (numMatch) {
html += `<span style="color: ${colors.number};">${escapeHtml(numMatch[0])}</span>`;
remaining = remaining.slice(numMatch[0].length);
continue;
}
// Words
const wordMatch = remaining.match(wordPattern);
if (wordMatch) {
html += escapeHtml(wordMatch[0]);
remaining = remaining.slice(wordMatch[0].length);
continue;
}
// Whitespace
const spaceMatch = remaining.match(spacePattern);
if (spaceMatch) {
html += escapeHtml(spaceMatch[0]);
remaining = remaining.slice(spaceMatch[0].length);
continue;
}
// Other characters
const otherMatch = remaining.match(otherPattern);
if (otherMatch) {
html += escapeHtml(otherMatch[0]);
remaining = remaining.slice(otherMatch[0].length);
continue;
}
break;
}
return html || '&nbsp;';
}
function escapeHtml(text: string): string {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
export default function ScriptEditor({ value, onChange, language, theme = 'light' }: ScriptEditorProps) {
const textareaRef = useRef<HTMLTextAreaElement>(null);
const highlightRef = useRef<HTMLDivElement>(null);
const [focused, setFocused] = useState(false);
const isDark = theme === 'dark';
const lines = value.split('\n');
const lineNumbers = lines.map((_, i) => i + 1).join('\n');
const highlightedHtml = lines
.map((line) => highlightLine(line, language, isDark))
.join('<br>');
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Tab') {
e.preventDefault();
const target = e.currentTarget;
const start = target.selectionStart;
const end = target.selectionEnd;
const newValue = value.substring(0, start) + ' ' + value.substring(end);
onChange(newValue);
requestAnimationFrame(() => {
target.selectionStart = target.selectionEnd = start + 2;
});
}
},
[value, onChange]
);
const handleScroll = useCallback(() => {
if (textareaRef.current && highlightRef.current) {
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
}
}, []);
useEffect(() => {
handleScroll();
}, [value, handleScroll]);
return (
<div
className={`relative rounded-md border overflow-hidden script-editor ${
focused ? 'ring-1' : ''
}`}
style={{
borderColor: isDark ? '#333333' : 'var(--border-color)',
minHeight: '200px',
'--tw-ring-color': 'rgba(9, 105, 218, 0.25)',
background: isDark ? '#1e1e1e' : 'var(--panel-bg)',
} as React.CSSProperties}
>
<div className="flex" style={{ minHeight: '200px' }}>
{/* Line numbers */}
<div
className="flex-shrink-0 py-3 px-2 text-right select-none"
style={{
background: isDark ? '#1e1e1e' : 'var(--panel-bg-secondary)',
color: isDark ? '#858585' : 'var(--text-tertiary)',
fontSize: '13px',
lineHeight: '1.6',
minWidth: '40px',
borderRight: `1px solid ${isDark ? '#333333' : 'var(--border-color-light)'}`,
}}
>
{lineNumbers.split('\n').map((num, i) => (
<div key={i}>{num}</div>
))}
</div>
{/* Editor area */}
<div className="relative flex-1" style={{ minHeight: '200px' }}>
{/* Highlight layer */}
<div
ref={highlightRef}
className="absolute inset-0 py-3 px-3 pointer-events-none overflow-hidden whitespace-pre"
style={{
color: isDark ? '#d4d4d4' : 'var(--text-primary)',
fontSize: '13px',
lineHeight: '1.6',
}}
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
/>
{/* Textarea layer */}
<textarea
ref={textareaRef}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onScroll={handleScroll}
spellCheck={false}
className="absolute inset-0 w-full h-full py-3 px-3 resize-none bg-transparent script-editor"
style={{
color: 'transparent',
caretColor: isDark ? '#d4d4d4' : 'var(--text-primary)',
fontSize: '13px',
lineHeight: '1.6',
outline: 'none',
}}
/>
</div>
</div>
</div>
);
}
+167
View File
@@ -0,0 +1,167 @@
import {
GitBranch,
Server,
Key,
Cpu,
Wrench,
Download,
ChevronLeft,
ChevronRight,
Rocket,
Zap,
History,
Webhook,
CloudSync,
Puzzle,
Settings
} from 'lucide-react';
type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings';
interface SidebarProps {
activeModule: ModuleType;
onModuleChange: (module: ModuleType) => void;
collapsed: boolean;
onToggleCollapse: () => void;
}
const menuGroups: { label: string; items: { id: ModuleType; label: string; icon: typeof GitBranch; badge: string | null }[] }[] = [
{
label: '发布',
items: [
{ id: 'pipelines', label: '流水线', icon: GitBranch, badge: null },
{ id: 'gitops', label: 'GitOps 部署', icon: CloudSync, badge: 'New' },
{ id: 'batch-release', label: '批量发布', icon: Zap, badge: null },
{ id: 'release-records', label: '发布记录', icon: History, badge: null },
],
},
{
label: '资源',
items: [
{ id: 'environments', label: '环境管理', icon: Server, badge: null },
{ id: 'credentials', label: '凭证管理', icon: Key, badge: null },
{ id: 'build-nodes', label: '构建节点', icon: Cpu, badge: '3' },
],
},
{
label: '集成',
items: [
{ id: 'jenkins-import', label: 'Jenkins导入', icon: Download, badge: null },
{ id: 'webhooks', label: 'Webhook', icon: Webhook, badge: null },
{ id: 'tools', label: '工具配置', icon: Wrench, badge: null },
],
},
{
label: '系统',
items: [
{ id: 'plugins', label: '插件市场', icon: Puzzle, badge: '12' },
{ id: 'settings', label: '系统设置', icon: Settings, badge: null },
],
},
];
export default function Sidebar({ activeModule, onModuleChange, collapsed, onToggleCollapse }: SidebarProps) {
return (
<div
className="flex flex-col border-r transition-all duration-300 ease-in-out h-full"
style={{
width: collapsed ? '56px' : '200px',
background: 'var(--panel-bg)',
borderColor: 'var(--border-color)',
}}
>
{/* Logo area */}
<div
className="flex items-center gap-2 px-3 h-12 border-b flex-shrink-0"
style={{ borderColor: 'var(--border-color)' }}
>
<div
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
style={{ background: 'var(--accent-blue)' }}
>
<Rocket className="w-4 h-4 text-white" />
</div>
{!collapsed && (
<span className="text-[13px] font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
应用发布
</span>
)}
</div>
{/* Menu groups */}
<div className="flex-1 py-2 overflow-y-auto custom-scrollbar">
{menuGroups.map((group) => (
<div key={group.label} className="mb-2">
{!collapsed && (
<div
className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider"
style={{ color: 'var(--text-tertiary)' }}
>
{group.label}
</div>
)}
<div className="px-2 space-y-[2px]">
{group.items.map((item) => {
const Icon = item.icon;
const isActive = activeModule === item.id;
return (
<button
key={item.id}
onClick={() => onModuleChange(item.id)}
className="w-full flex items-center gap-2.5 px-2.5 py-[7px] rounded-md transition-all duration-150 group relative"
style={{
background: isActive ? 'var(--accent-blue-light)' : 'transparent',
color: isActive ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}
title={collapsed ? item.label : undefined}
>
<Icon className="w-[18px] h-[18px] flex-shrink-0" />
{!collapsed && (
<>
<span className="text-[13px] font-medium truncate flex-1 text-left">
{item.label}
</span>
{item.badge && (
<span
className="text-[9px] px-1 py-[1px] rounded font-medium"
style={{
background: item.badge === 'New' ? '#ddf4ff' : 'var(--panel-bg-secondary)',
color: item.badge === 'New' ? 'var(--accent-blue)' : 'var(--text-tertiary)',
}}
>
{item.badge}
</span>
)}
</>
)}
{isActive && !collapsed && (
<div
className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full"
style={{ background: 'var(--accent-blue)' }}
/>
)}
</button>
);
})}
</div>
</div>
))}
</div>
{/* Collapse toggle */}
<div
className="px-2 py-2 border-t flex justify-center"
style={{ borderColor: 'var(--border-color)' }}
>
<button
onClick={onToggleCollapse}
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
style={{ color: 'var(--text-tertiary)' }}
title={collapsed ? '展开' : '收起'}
>
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
</button>
</div>
</div>
);
}
+190
View File
@@ -0,0 +1,190 @@
import { useState } from 'react';
import {
ChevronUp,
ChevronDown,
X,
Plus,
GitBranch,
Wrench,
Server,
FlaskConical,
Settings,
} from 'lucide-react';
import type { PipelineStage } from '@/types/pipeline';
import TaskCard from './TaskCard';
interface StageCardProps {
stage: PipelineStage;
index: number;
isFirst: boolean;
isLast: boolean;
selectedTaskId: string | null;
onSelectTask: (taskId: string) => void;
onMoveUp: () => void;
onMoveDown: () => void;
onDelete: () => void;
onUpdateName: (name: string) => void;
}
// Only use color for the LEFT BORDER — keep everything else neutral
const stageTypeConfig: Record<string, { color: string; icon: typeof GitBranch }> = {
source: { color: '#0969da', icon: GitBranch },
build: { color: '#9a6700', icon: Wrench },
deploy: { color: '#1a7f37', icon: Server },
test: { color: '#8250df', icon: FlaskConical },
custom: { color: '#6e7781', icon: Settings },
};
export default function StageCard({
stage,
index,
isFirst,
isLast,
selectedTaskId,
onSelectTask,
onMoveUp,
onMoveDown,
onDelete,
onUpdateName,
}: StageCardProps) {
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState(stage.name);
const config = stageTypeConfig[stage.type] || stageTypeConfig.custom;
const IconComponent = config.icon;
const handleNameSubmit = () => {
if (editName.trim()) {
onUpdateName(editName.trim());
} else {
setEditName(stage.name);
}
setIsEditing(false);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') handleNameSubmit();
else if (e.key === 'Escape') {
setEditName(stage.name);
setIsEditing(false);
}
};
return (
<div className="w-full">
<div
className="rounded-lg overflow-hidden transition-shadow duration-200 hover:shadow-sm"
style={{
background: 'var(--panel-bg)',
border: '1px solid var(--border-color)',
borderLeft: `3px solid ${config.color}`,
}}
>
{/* Header — unified neutral background, NO colorful bg */}
<div
className="flex items-center justify-between px-4 py-2.5 border-b"
style={{
borderColor: 'var(--border-color-light)',
background: '#f8f9fa',
}}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<IconComponent className="w-3.5 h-3.5 flex-shrink-0" style={{ color: config.color }} />
{isEditing ? (
<input
value={editName}
onChange={(e) => setEditName(e.target.value)}
onBlur={handleNameSubmit}
onKeyDown={handleKeyDown}
autoFocus
className="text-[13px] font-semibold bg-white border rounded px-2 py-0.5 outline-none"
style={{
color: 'var(--text-primary)',
borderColor: config.color,
minWidth: '140px',
fontSize: '13px',
}}
/>
) : (
<button
onClick={() => setIsEditing(true)}
className="text-[13px] font-semibold hover:opacity-70 transition-opacity truncate text-left"
style={{ color: 'var(--text-primary)' }}
title="点击编辑"
>
阶段 {index + 1}: {stage.name}
</button>
)}
</div>
{/* Refined action buttons — smaller, tighter */}
<div className="flex items-center gap-[2px] flex-shrink-0">
<button
onClick={onMoveUp}
disabled={isFirst}
className="p-1 rounded transition-all hover:bg-black/5 disabled:opacity-25 disabled:cursor-not-allowed disabled:hover:bg-transparent"
title="上移"
>
<ChevronUp className="w-3 h-3" style={{ color: 'var(--text-secondary)' }} />
</button>
<button
onClick={onMoveDown}
disabled={isLast}
className="p-1 rounded transition-all hover:bg-black/5 disabled:opacity-25 disabled:cursor-not-allowed disabled:hover:bg-transparent"
title="下移"
>
<ChevronDown className="w-3 h-3" style={{ color: 'var(--text-secondary)' }} />
</button>
<button
onClick={onDelete}
className="p-1 rounded transition-all hover:bg-red-50"
title="删除阶段"
>
<X className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</div>
{/* Body: Task list */}
<div className="px-2 py-1.5">
{stage.tasks.map((task) => (
<TaskCard
key={task.id}
task={task}
isSelected={selectedTaskId === task.id}
onSelect={() => onSelectTask(task.id)}
onDelete={() => {/* TODO */}}
/>
))}
{stage.tasks.length === 0 && (
<div
className="text-center py-4 rounded-md"
style={{ color: 'var(--text-tertiary)' }}
>
<p className="text-[12px]">暂无任务</p>
<p className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
点击下方按钮添加
</p>
</div>
)}
</div>
{/* Footer: Add task — refined style */}
<div
className="px-2 py-1.5 border-t"
style={{ borderColor: 'var(--border-color-light)' }}
>
<button
className="flex items-center justify-center gap-1.5 w-full py-[6px] text-[12px] rounded-md border border-dashed transition-all hover:border-solid hover:bg-[var(--panel-bg-secondary)]"
style={{
borderColor: 'var(--border-color-light)',
color: 'var(--text-tertiary)',
}}
>
<Plus className="w-3 h-3" />
添加任务
</button>
</div>
</div>
</div>
);
}
+88
View File
@@ -0,0 +1,88 @@
import { Pencil, Trash2 } from 'lucide-react';
import { iconMap } from '@/data/pipelineData';
import type { PipelineTask } from '@/types/pipeline';
interface TaskCardProps {
task: PipelineTask;
isSelected: boolean;
onSelect: () => void;
onDelete: () => void;
}
// Subtle type indicator colors — just for the small dot/icon, NOT background
const typeConfig: Record<string, { color: string; label: string }> = {
build: { color: '#9a6700', label: '构建' },
deploy: { color: '#1a7f37', label: '部署' },
test: { color: '#8250df', label: '测试' },
custom: { color: '#6e7781', label: '自定义' },
};
export default function TaskCard({ task, isSelected, onSelect, onDelete }: TaskCardProps) {
const config = typeConfig[task.type] || typeConfig.custom;
const IconComponent = iconMap[task.icon] || iconMap.Settings;
const handleDelete = (e: React.MouseEvent) => {
e.stopPropagation();
onDelete();
};
return (
<div
onClick={onSelect}
className="flex items-center gap-2.5 px-3 py-[10px] rounded-md cursor-pointer transition-all duration-150 group mb-[2px] last:mb-0"
style={{
background: isSelected ? '#f0f5ff' : 'transparent',
border: isSelected ? '1px solid #b7d4f0' : '1px solid transparent',
...(isSelected
? { boxShadow: '0 0 0 1px rgba(9,105,218,0.08)' }
: {}),
}}
>
{/* Type icon — small colored dot with icon inside */}
<div
className="w-6 h-6 rounded-[6px] flex items-center justify-center flex-shrink-0"
style={{
background: `${config.color}10`,
border: `1px solid ${config.color}25`,
}}
>
<IconComponent className="w-3 h-3" style={{ color: config.color }} />
</div>
{/* Task info */}
<div className="flex-1 min-w-0">
<div
className="text-[13px] font-medium truncate leading-tight"
style={{ color: isSelected ? 'var(--text-primary)' : 'var(--text-primary)' }}
>
{task.name}
</div>
<div
className="text-[11px] truncate leading-tight mt-[1px]"
style={{ color: 'var(--text-tertiary)' }}
>
{config.label}
{task.scriptType && ` · ${task.scriptType}`}
</div>
</div>
{/* Actions — hover reveal */}
<div className="flex items-center gap-[2px] opacity-0 group-hover:opacity-100 transition-opacity duration-150 flex-shrink-0">
<button
onClick={(e) => { e.stopPropagation(); onSelect(); }}
className="p-1 rounded transition-colors hover:bg-black/5"
title="编辑"
>
<Pencil className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
</button>
<button
onClick={handleDelete}
className="p-1 rounded transition-colors hover:bg-red-50"
title="删除"
>
<Trash2 className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</div>
);
}
+178
View File
@@ -0,0 +1,178 @@
import { ChevronLeft, Save, Play, MonitorPlay, ChevronDown } from 'lucide-react';
import { useState } from 'react';
type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
interface TopBarProps {
pipelineName: string;
activeTab: EditorTab;
onTabChange: (tab: EditorTab) => void;
onSimulateRun: () => void;
isSimulating: boolean;
scenarioKey: 'go-backend' | 'vue-frontend';
onScenarioChange: (key: 'go-backend' | 'vue-frontend') => void;
executionStatus: 'idle' | 'running' | 'success' | 'failed';
onBack?: () => void;
}
const tabs: EditorTab[] = ['基本信息', '流程配置', '触发设置', '变量和缓存', '发布'];
const scenarios = [
{ key: 'go-backend' as const, label: 'aiops-backend (Go + K8s)' },
{ key: 'vue-frontend' as const, label: 'aiops-web (Vue + Host)' },
];
export default function TopBar({
pipelineName,
activeTab,
onTabChange,
onSimulateRun,
isSimulating,
scenarioKey,
onScenarioChange,
executionStatus,
onBack,
}: TopBarProps) {
const [pipelineDropdownOpen, setPipelineDropdownOpen] = useState(false);
const statusConfig = {
idle: null,
running: { color: '#0969da', bg: '#ddf4ff', label: '执行中' },
success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' },
failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' },
};
const status = statusConfig[executionStatus];
return (
<div
className="flex items-center justify-between px-4 border-b flex-shrink-0"
style={{
height: '44px',
background: 'var(--panel-bg)',
borderColor: 'var(--border-color)',
}}
>
{/* Left: Back + Pipeline Name + Switcher */}
<div className="flex items-center gap-3 min-w-[280px]">
<button
onClick={onBack}
className="p-1 rounded-md hover:bg-[var(--panel-bg-secondary)] transition-colors"
title="返回列表"
>
<ChevronLeft className="w-[18px] h-[18px]" style={{ color: 'var(--text-primary)' }} />
</button>
{/* Pipeline name with dropdown switcher */}
<div className="relative">
<button
onClick={() => setPipelineDropdownOpen(!pipelineDropdownOpen)}
className="flex items-center gap-1 px-1.5 py-[3px] rounded-md hover:bg-[var(--panel-bg-secondary)] transition-colors"
>
<span className="text-[13px] font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
{pipelineName}
</span>
<ChevronDown
className={`w-3.5 h-3.5 transition-transform ${pipelineDropdownOpen ? 'rotate-180' : ''}`}
style={{ color: 'var(--text-secondary)' }}
/>
</button>
{pipelineDropdownOpen && (
<>
<div className="fixed inset-0 z-40" onClick={() => setPipelineDropdownOpen(false)} />
<div
className="absolute top-full left-0 mt-1 py-1 rounded-md border shadow-lg z-50 min-w-[200px]"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
{scenarios.map((s) => (
<button
key={s.key}
onClick={() => {
onScenarioChange(s.key);
setPipelineDropdownOpen(false);
}}
className="w-full text-left px-3 py-2 text-[13px] transition-colors hover:bg-[var(--panel-bg-secondary)]"
style={{
color: scenarioKey === s.key ? 'var(--accent-blue)' : 'var(--text-primary)',
fontWeight: scenarioKey === s.key ? 600 : 400,
}}
>
{s.label}
{scenarioKey === s.key && (
<span className="ml-2 text-[11px]" style={{ color: 'var(--accent-blue)' }}>✓</span>
)}
</button>
))}
</div>
</>
)}
</div>
{/* Execution status badge */}
{status && (
<span
className="text-[10px] px-1.5 py-[2px] rounded-full font-medium"
style={{ background: status.bg, color: status.color }}
>
{status.label}
</span>
)}
</div>
{/* Center: Tabs */}
<div className="flex items-center gap-0">
{tabs.map((tab) => (
<button
key={tab}
onClick={() => onTabChange(tab)}
className="relative px-3 py-1.5 text-[12px] font-medium transition-colors"
style={{
color: tab === activeTab ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}
>
{tab}
{tab === activeTab && (
<span className="absolute bottom-0 left-1 right-1 h-0.5 rounded-full" style={{ background: 'var(--accent-blue)' }} />
)}
</button>
))}
</div>
{/* Right: Action Buttons */}
<div className="flex items-center gap-2 min-w-[280px] justify-end">
<button
onClick={onSimulateRun}
disabled={isSimulating}
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors disabled:opacity-50"
style={{
color: 'var(--accent-blue)',
background: 'var(--accent-blue-light)',
border: '1px solid rgba(9, 105, 218, 0.2)',
}}
>
<MonitorPlay className="w-4 h-4" />
{isSimulating ? '运行中...' : '模拟运行'}
</button>
<button
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors"
style={{
color: 'var(--text-secondary)',
background: 'transparent',
border: '1px solid var(--border-color)',
}}
>
<Save className="w-4 h-4" />
仅保存
</button>
<button
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md text-white transition-colors"
style={{ background: 'var(--accent-blue)' }}
>
<Play className="w-4 h-4" />
保存并运行
</button>
</div>
</div>
);
}
+257
View File
@@ -0,0 +1,257 @@
import { useState } from 'react';
import { GitCommit, Clock, Webhook, Hand, Info, Copy, Check } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { toast } from 'sonner';
export default function TriggerPanel() {
// Code commit trigger
const [codeCommitEnabled, setCodeCommitEnabled] = useState(true);
const [watchBranch, setWatchBranch] = useState('');
const [excludeBranch, setExcludeBranch] = useState('');
// Cron trigger
const [cronEnabled, setCronEnabled] = useState(false);
const [cronExpression, setCronExpression] = useState('');
const [nextExecution, setNextExecution] = useState('2024-06-21 02:00:00');
// Webhook trigger
const [webhookEnabled, setWebhookEnabled] = useState(false);
const [webhookSecret, setWebhookSecret] = useState('');
const [copied, setCopied] = useState(false);
const webhookUrl = 'https://api.example.com/webhook/pipeline/gateway-pre-serverless';
const handleCopyWebhook = () => {
navigator.clipboard.writeText(webhookUrl).then(() => {
setCopied(true);
toast.success('Webhook URL 已复制');
setTimeout(() => setCopied(false), 2000);
});
};
const cronPresets = [
{ label: '每天凌晨2点', value: '0 2 * * *' },
{ label: '每小时', value: '0 * * * *' },
{ label: '自定义', value: '' },
];
const handleCronPreset = (value: string) => {
setCronExpression(value);
if (value === '0 2 * * *') {
setNextExecution('2024-06-21 02:00:00');
} else if (value === '0 * * * *') {
setNextExecution('2024-06-20 15:00:00');
}
};
return (
<div className="tab-content-fade space-y-4 w-full px-6 py-6">
{/* Code Commit Trigger */}
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<GitCommit className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<CardTitle className="text-base">代码提交触发</CardTitle>
</div>
<Switch checked={codeCommitEnabled} onCheckedChange={setCodeCommitEnabled} />
</div>
<CardDescription>
当指定分支有新的代码提交时自动触发流水线
</CardDescription>
</CardHeader>
{codeCommitEnabled && (
<CardContent className="tab-content-fade space-y-4">
<div>
<div className="flex items-center gap-2 mb-1.5">
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>监听分支</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>支持正则表达式,如 release/*</p>
</TooltipContent>
</Tooltip>
</div>
<Input
value={watchBranch}
onChange={(e) => setWatchBranch(e.target.value)}
placeholder="支持正则,如 release/*"
className="h-9"
/>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
多个分支用逗号分隔,支持 * 通配符
</p>
</div>
<div>
<Label className="text-sm mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
排除分支
</Label>
<Input
value={excludeBranch}
onChange={(e) => setExcludeBranch(e.target.value)}
placeholder="如 hotfix/*"
className="h-9"
/>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
匹配的分支不会触发流水线
</p>
</div>
</CardContent>
)}
</Card>
{/* Cron Trigger */}
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Clock className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<CardTitle className="text-base">定时触发</CardTitle>
</div>
<Switch checked={cronEnabled} onCheckedChange={setCronEnabled} />
</div>
<CardDescription>
按照 Cron 表达式定时触发流水线
</CardDescription>
</CardHeader>
{cronEnabled && (
<CardContent className="tab-content-fade space-y-4">
<div className="flex flex-wrap gap-2">
{cronPresets.map((preset) => (
<Badge
key={preset.label}
variant={cronExpression === preset.value && preset.value !== '' ? 'default' : 'outline'}
className="cursor-pointer h-7 px-3"
onClick={() => handleCronPreset(preset.value)}
>
{preset.label}
</Badge>
))}
</div>
<div>
<Label className="text-sm mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
Cron 表达式
</Label>
<Input
value={cronExpression}
onChange={(e) => setCronExpression(e.target.value)}
placeholder="0 2 * * *"
className="h-9 font-mono"
/>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
格式:分 时 日 月 周
</p>
</div>
{cronExpression && (
<div
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm"
style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }}
>
<Clock className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
下次执行: {nextExecution}
</div>
)}
</CardContent>
)}
</Card>
{/* Webhook Trigger */}
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Webhook className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<CardTitle className="text-base">Webhook 触发</CardTitle>
</div>
<Switch checked={webhookEnabled} onCheckedChange={setWebhookEnabled} />
</div>
<CardDescription>
通过外部系统的 Webhook 调用触发流水线
</CardDescription>
</CardHeader>
{webhookEnabled && (
<CardContent className="tab-content-fade space-y-4">
<div>
<Label className="text-sm mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
Webhook URL
</Label>
<div className="flex gap-2">
<div
className="flex-1 px-3 py-2 text-sm rounded-md border font-mono"
style={{
background: 'var(--canvas-bg)',
borderColor: 'var(--border-color)',
color: 'var(--text-primary)',
}}
>
{webhookUrl}
</div>
<Button variant="outline" size="icon" className="h-9 w-9" onClick={handleCopyWebhook}>
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
</Button>
</div>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
将此 URL 配置到外部系统的 Webhook 中
</p>
</div>
<div>
<div className="flex items-center gap-2 mb-1.5">
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Secret Token</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>用于验证 Webhook 请求的签名</p>
</TooltipContent>
</Tooltip>
</div>
<Input
value={webhookSecret}
onChange={(e) => setWebhookSecret(e.target.value)}
placeholder="输入 Secret Token"
type="password"
className="h-9"
/>
</div>
</CardContent>
)}
</Card>
{/* Manual Trigger - always on */}
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Hand className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<CardTitle className="text-base">手动触发</CardTitle>
<Badge variant="secondary" className="h-5 text-xs">默认启用</Badge>
</div>
<CardDescription>
支持通过控制台手动触发或 API 调用触发
</CardDescription>
</CardHeader>
<CardContent>
<div
className="px-3 py-2.5 rounded-md text-sm"
style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }}
>
<span style={{ color: 'var(--text-secondary)' }}>
API 调用示例:
</span>
<code className="ml-2 text-xs font-mono" style={{ color: 'var(--accent-blue)' }}>
POST /api/v1/pipelines/gateway-pre-serverless/run
</code>
</div>
</CardContent>
</Card>
</div>
);
}
+261
View File
@@ -0,0 +1,261 @@
import { useState } from 'react';
import { Variable, Database, Trash2, Plus, Info, Eye, EyeOff } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
interface EnvVar {
id: string;
name: string;
value: string;
description: string;
}
export default function VariablesPanel() {
const [envVars, setEnvVars] = useState<EnvVar[]>([
{ id: '1', name: 'REGISTRY_URL', value: 'registry.cn-hangzhou.aliyuncs.com', description: '镜像仓库地址' },
{ id: '2', name: 'NAMESPACE', value: 'gateway-ns', description: 'K8s 命名空间' },
{ id: '3', name: 'APP_NAME', value: 'gateway-serverless', description: '应用名称' },
{ id: '4', name: 'ENV', value: 'pre', description: '环境标识' },
]);
const [showAddForm, setShowAddForm] = useState(false);
const [newVarName, setNewVarName] = useState('');
const [newVarValue, setNewVarValue] = useState('');
const [newVarDesc, setNewVarDesc] = useState('');
const [visibleValues, setVisibleValues] = useState<Record<string, boolean>>({});
// Cache config
const [cacheEnabled, setCacheEnabled] = useState(true);
const [cachePath, setCachePath] = useState('/root/.m2');
const [cacheKey, setCacheKey] = useState('${BRANCH_NAME}-${BUILD_ID}');
const handleAddVar = () => {
if (!newVarName.trim() || !newVarValue.trim()) return;
const newVar: EnvVar = {
id: Date.now().toString(),
name: newVarName.trim(),
value: newVarValue.trim(),
description: newVarDesc.trim(),
};
setEnvVars([...envVars, newVar]);
setNewVarName('');
setNewVarValue('');
setNewVarDesc('');
setShowAddForm(false);
};
const handleDeleteVar = (id: string) => {
setEnvVars(envVars.filter((v) => v.id !== id));
};
const toggleVisible = (id: string) => {
setVisibleValues((prev) => ({ ...prev, [id]: !prev[id] }));
};
return (
<div className="tab-content-fade space-y-4 w-full px-6 py-6">
{/* Environment Variables */}
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
<CardHeader className="pb-3">
<div className="flex items-center gap-2">
<Variable className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<CardTitle className="text-base">环境变量</CardTitle>
</div>
<CardDescription className="flex items-center gap-1">
在流水线各阶段可通过
<code className="px-1 py-0.5 rounded text-xs font-mono" style={{ background: 'var(--canvas-bg)' }}>
${'{VAR_NAME}'}
</code>
引用
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-color)' }}>
<Table>
<TableHeader>
<TableRow style={{ background: 'var(--canvas-bg)' }}>
<TableHead className="text-xs font-semibold h-9">变量名</TableHead>
<TableHead className="text-xs font-semibold h-9">变量值</TableHead>
<TableHead className="text-xs font-semibold h-9">说明</TableHead>
<TableHead className="text-xs font-semibold h-9 w-16">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{envVars.map((envVar) => (
<TableRow key={envVar.id} className="group">
<TableCell className="font-mono text-sm py-2.5">{envVar.name}</TableCell>
<TableCell className="py-2.5">
<div className="flex items-center gap-2">
<span className="font-mono text-sm">
{visibleValues[envVar.id] ? envVar.value : '••••••••'}
</span>
<button
onClick={() => toggleVisible(envVar.id)}
className="opacity-0 group-hover:opacity-100 transition-opacity"
>
{visibleValues[envVar.id] ? (
<EyeOff className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
) : (
<Eye className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
)}
</button>
</div>
</TableCell>
<TableCell className="text-sm py-2.5" style={{ color: 'var(--text-secondary)' }}>
{envVar.description}
</TableCell>
<TableCell className="py-2.5">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={() => handleDeleteVar(envVar.id)}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: '#EF4444' }} />
</Button>
</TableCell>
</TableRow>
))}
{envVars.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-center py-6 text-sm" style={{ color: 'var(--text-secondary)' }}>
暂无环境变量
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
{showAddForm ? (
<div className="tab-content-fade space-y-3 p-4 rounded-lg border" style={{ borderColor: 'var(--border-color)', background: 'var(--canvas-bg)' }}>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>变量名</Label>
<Input
value={newVarName}
onChange={(e) => setNewVarName(e.target.value)}
placeholder="如 BUILD_ARGS"
className="h-8 font-mono"
/>
</div>
<div>
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>变量值</Label>
<Input
value={newVarValue}
onChange={(e) => setNewVarValue(e.target.value)}
placeholder="变量值"
className="h-8"
/>
</div>
</div>
<div>
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>说明</Label>
<Input
value={newVarDesc}
onChange={(e) => setNewVarDesc(e.target.value)}
placeholder="变量说明(可选)"
className="h-8"
/>
</div>
<div className="flex gap-2 pt-1">
<Button size="sm" className="h-8" onClick={handleAddVar}>
确认添加
</Button>
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowAddForm(false)}>
取消
</Button>
</div>
</div>
) : (
<Button
variant="outline"
size="sm"
className="h-8"
onClick={() => setShowAddForm(true)}
>
<Plus className="w-3.5 h-3.5 mr-1.5" />
添加变量
</Button>
)}
</CardContent>
</Card>
{/* Cache Config */}
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Database className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<CardTitle className="text-base">缓存配置</CardTitle>
</div>
<Switch checked={cacheEnabled} onCheckedChange={setCacheEnabled} />
</div>
<CardDescription>
配置构建缓存可显著加速后续构建速度
</CardDescription>
</CardHeader>
{cacheEnabled && (
<CardContent className="tab-content-fade space-y-4">
<div>
<div className="flex items-center gap-2 mb-1.5">
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>缓存路径</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>需要缓存的目录路径,多个路径用逗号分隔</p>
</TooltipContent>
</Tooltip>
</div>
<Input
value={cachePath}
onChange={(e) => setCachePath(e.target.value)}
placeholder="/root/.m2, node_modules"
className="h-9"
/>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
多个路径用逗号分隔
</p>
</div>
<div>
<div className="flex items-center gap-2 mb-1.5">
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>缓存 Key</Label>
<Tooltip>
<TooltipTrigger asChild>
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
</TooltipTrigger>
<TooltipContent>
<p>支持使用变量,用于区分不同场景的缓存</p>
</TooltipContent>
</Tooltip>
</div>
<Input
value={cacheKey}
onChange={(e) => setCacheKey(e.target.value)}
placeholder="${BRANCH_NAME}-${BUILD_ID}"
className="h-9 font-mono"
/>
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
支持变量引用,相同的 Key 会命中已有缓存
</p>
</div>
</CardContent>
)}
</Card>
</div>
);
}
@@ -0,0 +1,410 @@
import { useCallback, useState, useEffect, useRef } from 'react';
import {
ReactFlow,
Background,
Controls,
MiniMap,
useNodesState,
useEdgesState,
addEdge,
type Connection,
type Edge,
type Node,
ReactFlowProvider,
Panel,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import {
ChevronLeft, ChevronRight, Play, Save,
GitBranch, Package, Shield, Layers,
Terminal, BookOpen, Box, X, CircleDot
} from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import TektonNode, { type TektonNodeData } from './TektonNode';
import { apiClient } from '@/lib/api';
interface FlowEditorProps {
pipelineId: number;
}
interface CatalogTask {
id: string;
name: string;
category: string;
description: string;
icon: typeof GitBranch;
params: { name: string; type: string; default?: string }[];
workspaces: string[];
}
const catalogTasks: CatalogTask[] = [
{ id: 'git-clone', name: 'git-clone', category: 'source', description: 'Clone Git repo', icon: GitBranch, params: [{ name: 'url', type: 'string' }, { name: 'revision', type: 'string', default: 'main' }], workspaces: ['source'] },
{ id: 'maven', name: 'maven', category: 'build', description: 'Maven build', icon: Package, params: [{ name: 'goals', type: 'string', default: 'clean package' }], workspaces: ['source', 'm2-cache'] },
{ id: 'npm', name: 'npm', category: 'build', description: 'npm install + build', icon: Package, params: [{ name: 'script', type: 'string', default: 'build' }], workspaces: ['source'] },
{ id: 'go-build', name: 'go-build', category: 'build', description: 'Go compile', icon: Package, params: [{ name: 'package', type: 'string' }, { name: 'flags', type: 'string' }], workspaces: ['source'] },
{ id: 'kaniko', name: 'kaniko-build', category: 'build', description: 'Docker build (rootless)', icon: Package, params: [{ name: 'dockerfile', type: 'string', default: 'Dockerfile' }, { name: 'image', type: 'string' }], workspaces: ['source'] },
{ id: 'trivy', name: 'trivy-scan', category: 'security', description: 'Vulnerability scan', icon: Shield, params: [{ name: 'image', type: 'string' }, { name: 'severity', type: 'string', default: 'HIGH,CRITICAL' }], workspaces: [] },
{ id: 'unit-test', name: 'unit-test', category: 'test', description: 'Run unit tests', icon: Terminal, params: [{ name: 'test-command', type: 'string' }], workspaces: ['source'] },
{ id: 'kubectl', name: 'kubectl-deploy', category: 'deploy', description: 'kubectl apply', icon: Layers, params: [{ name: 'manifest', type: 'string' }, { name: 'namespace', type: 'string' }], workspaces: ['source'] },
{ id: 'update-manifest', name: 'update-manifest', category: 'gitops', description: 'Update GitOps repo', icon: BookOpen, params: [{ name: 'repo', type: 'string' }, { name: 'image', type: 'string' }, { name: 'path', type: 'string' }], workspaces: ['source'] },
{ id: 'helm', name: 'helm-upgrade', category: 'deploy', description: 'Helm deploy', icon: Layers, params: [{ name: 'release', type: 'string' }, { name: 'chart', type: 'string' }, { name: 'namespace', type: 'string' }], workspaces: ['source'] },
];
const categoryColors: Record<string, string> = {
source: '#0969da', build: '#1a7f37', test: '#9a6700',
security: '#cf222e', deploy: '#8250df', gitops: '#0e7c7b', custom: '#6e7781',
};
const nodeTypes = { tektonNode: TektonNode };
function FlowEditorInner({ pipelineId }: FlowEditorProps) {
const [nodes, setNodes, onNodesChange] = useNodesState<Node<TektonNodeData>>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [catalogOpen, setCatalogOpen] = useState(true);
const [selectedCategory, setSelectedCategory] = useState('all');
const [selectedNode, setSelectedNode] = useState<Node<TektonNodeData> | null>(null);
const [isRunning, setIsRunning] = useState(false);
const [logOpen, setLogOpen] = useState(false);
const [logs, setLogs] = useState<{ level: string; message: string; timestamp: string }[]>([]);
const logBoxRef = useRef<HTMLDivElement>(null);
const queryClient = useQueryClient();
const { data: pipelineData } = useQuery({
queryKey: ['pipeline', pipelineId],
queryFn: () => apiClient.getPipeline(pipelineId),
});
useEffect(() => {
if (pipelineData?.nodes) {
const loadedNodes: Node<TektonNodeData>[] = (pipelineData.nodes as any[]).map((n: any) => ({
id: n.nodeId,
type: 'tektonNode',
position: { x: n.positionX, y: n.positionY },
data: { label: n.name, category: n.category, config: n.config } as TektonNodeData,
width: n.width || 220,
height: n.height || 120,
}));
setNodes(loadedNodes);
}
if (pipelineData?.edges) {
const loadedEdges: Edge[] = (pipelineData.edges as any[]).map((e: any) => ({
id: e.edgeId,
source: e.source,
target: e.target,
label: e.label,
animated: e.animated,
}));
setEdges(loadedEdges);
}
}, [pipelineData]);
const handleSave = useCallback(async () => {
const nodeData = nodes.map((n) => ({
nodeId: n.id,
type: n.data?.category || 'custom',
name: n.data?.label || n.id,
positionX: n.position.x,
positionY: n.position.y,
width: n.width || 220,
height: n.height || 120,
config: n.data?.config || {},
category: n.data?.category || 'custom',
}));
const edgeData = edges.map((e) => ({
edgeId: e.id,
source: e.source,
target: e.target,
label: typeof e.label === 'string' ? e.label : '',
animated: e.animated || false,
}));
try {
await apiClient.saveNodes(pipelineId, nodeData);
await apiClient.saveEdges(pipelineId, edgeData);
toast.success('流水线已保存', { description: `${nodeData.length} 个节点、${edgeData.length} 条连线` });
queryClient.invalidateQueries({ queryKey: ['pipeline', pipelineId] });
} catch (err) {
toast.error('保存失败', { description: (err as Error).message });
}
}, [nodes, edges, pipelineId, queryClient]);
const setAllNodeStatus = useCallback((status: TektonNodeData['status']) => {
setNodes((nds) => nds.map((n) => ({ ...n, data: { ...n.data!, status } })));
}, [setNodes]);
const setNodeStatus = useCallback((nodeId: string, status: TektonNodeData['status']) => {
setNodes((nds) => nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data!, status } } : n)));
}, [setNodes]);
// 执行:创建运行 → SSE 订阅日志与阶段状态 → 画布节点实时推进
const handleRun = useCallback(async () => {
if (isRunning) return;
if (nodes.length === 0) {
toast.error('画布为空', { description: '请先从左侧任务库添加节点并保存' });
return;
}
try {
await apiClient.saveNodes(pipelineId, nodes.map((n) => ({
nodeId: n.id, type: n.data?.category || 'custom', name: n.data?.label || n.id,
positionX: n.position.x, positionY: n.position.y, width: n.width || 220, height: n.height || 120,
config: n.data?.config || {}, category: n.data?.category || 'custom',
})));
await apiClient.saveEdges(pipelineId, edges.map((e) => ({
edgeId: e.id, source: e.source, target: e.target,
label: typeof e.label === 'string' ? e.label : '', animated: e.animated || false,
})));
} catch (err) {
toast.error('保存失败', { description: (err as Error).message });
return;
}
let run;
try {
run = await apiClient.createRun(pipelineId);
} catch (err) {
toast.error('触发运行失败', { description: (err as Error).message });
return;
}
setIsRunning(true);
setLogs([]);
setLogOpen(true);
setAllNodeStatus('pending');
toast.success(`运行已触发 ${run.runId}`, { description: `${nodes.length} 个节点开始执行` });
const es = new EventSource(`/api/runs/${run.runId}/logs/stream`);
es.addEventListener('log', (evt) => {
try {
const entry = JSON.parse((evt as MessageEvent).data);
setLogs((prev) => [...prev, entry]);
} catch { /* ignore */ }
});
es.addEventListener('stage', (evt) => {
try {
const stage = JSON.parse((evt as MessageEvent).data);
setNodeStatus(stage.nodeId, stage.status);
} catch { /* ignore */ }
});
es.addEventListener('run', (evt) => {
try {
const result = JSON.parse((evt as MessageEvent).data);
if (result.status === 'success') {
toast.success('执行成功', { description: `耗时 ${(result.durationMs / 1000).toFixed(1)}s` });
} else {
toast.error(`执行${result.status === 'failed' ? '失败' : '已取消'}`);
}
} catch { /* ignore */ }
es.close();
setIsRunning(false);
queryClient.invalidateQueries({ queryKey: ['runs'] });
});
es.onerror = () => {
// 服务端结束推送(run 完成)时浏览器会触发 error,此时若已收到 run 事件则忽略
es.close();
setIsRunning(false);
};
}, [isRunning, nodes, edges, pipelineId, setAllNodeStatus, setNodeStatus, queryClient]);
// 日志自动滚动到底部
useEffect(() => {
if (logBoxRef.current) {
logBoxRef.current.scrollTop = logBoxRef.current.scrollHeight;
}
}, [logs, logOpen]);
const onConnect = useCallback(
(params: Connection) => setEdges((eds) => addEdge({ ...params, animated: true, style: { stroke: '#0969da' } }, eds)),
[setEdges]
);
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
setSelectedNode(node as Node<TektonNodeData>);
}, []);
const addNode = useCallback((task: CatalogTask) => {
const newNode: Node<TektonNodeData> = {
id: `${task.id}-${Date.now()}`,
type: 'tektonNode',
position: { x: 100 + nodes.length * 40, y: 100 + nodes.length * 40 },
data: {
label: task.name,
category: task.category,
config: task.params.reduce((acc, p) => ({ ...acc, [p.name]: p.default || '' }), {}),
},
};
setNodes((nds) => [...nds, newNode]);
}, [nodes.length, setNodes]);
const filteredTasks = selectedCategory === 'all'
? catalogTasks
: catalogTasks.filter((t) => t.category === selectedCategory);
return (
<div className="flex h-full" style={{ background: 'var(--canvas-bg)' }}>
<div
className="border-r flex-shrink-0 transition-all duration-300 overflow-hidden flex flex-col"
style={{ width: catalogOpen ? 260 : 0, borderColor: 'var(--border-color)', background: 'var(--panel-bg)' }}
>
<div className="px-3 py-2.5 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>Task 任务库</span>
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>Tekton</span>
</div>
<div className="p-2 border-b" style={{ borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-1 flex-wrap">
<button onClick={() => setSelectedCategory('all')} className="text-[10px] px-2 py-[3px] rounded font-medium transition-colors" style={{ background: selectedCategory === 'all' ? 'var(--accent-blue)' : 'var(--panel-bg-secondary)', color: selectedCategory === 'all' ? '#fff' : 'var(--text-tertiary)' }}>全部</button>
{Object.entries(categoryColors).map(([key, color]) => (
<button key={key} onClick={() => setSelectedCategory(key)} className="text-[10px] px-2 py-[3px] rounded font-medium transition-colors" style={{ background: selectedCategory === key ? color : 'var(--panel-bg-secondary)', color: selectedCategory === key ? '#fff' : 'var(--text-tertiary)' }}>
{key === 'source' ? '源码' : key === 'build' ? '构建' : key === 'test' ? '测试' : key === 'security' ? '安全' : key === 'deploy' ? '部署' : key === 'gitops' ? 'GitOps' : '其他'}
</button>
))}
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
{filteredTasks.map((task) => {
const Icon = task.icon;
const color = categoryColors[task.category];
return (
<div key={task.id} onClick={() => addNode(task)} className="p-2.5 rounded-md border cursor-pointer hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
<div className="flex items-center gap-2 mb-1">
<Icon className="w-3.5 h-3.5" style={{ color }} />
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{task.name}</span>
<span className="text-[9px] px-1 py-[1px] rounded font-medium ml-auto" style={{ background: color + '15', color }}>
{task.category}
</span>
</div>
<p className="text-[10px] mb-1" style={{ color: 'var(--text-secondary)' }}>{task.description}</p>
<div className="flex items-center gap-1.5">
{task.workspaces.length > 0 && <span className="text-[9px] flex items-center gap-0.5" style={{ color: 'var(--text-tertiary)' }}><Box className="w-2.5 h-2.5" />{task.workspaces.length} ws</span>}
{task.params.length > 0 && <span className="text-[9px]" style={{ color: 'var(--text-tertiary)' }}>{task.params.length} params</span>}
</div>
</div>
);
})}
</div>
</div>
<div className="flex-1 relative flex flex-col">
<div className="flex-1 relative min-h-0">
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onNodeClick={onNodeClick}
nodeTypes={nodeTypes}
fitView
style={{ background: 'var(--canvas-bg)' }}
>
<Background gap={16} size={1} color="#d0d7de" />
<Controls />
<MiniMap nodeStrokeWidth={3} zoomable pannable style={{ background: 'var(--panel-bg)' }} />
<Panel position="top-left">
<div className="flex items-center gap-2">
<button onClick={() => setCatalogOpen(!catalogOpen)} className="flex items-center gap-1 px-2 py-[5px] text-[11px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
{catalogOpen ? <ChevronLeft className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}Task 库
</button>
<div className="flex items-center gap-1.5">
<button onClick={handleSave} className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<Save className="w-3 h-3" />保存
</button>
<button
onClick={handleRun}
disabled={isRunning}
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md text-white disabled:opacity-60"
style={{ background: isRunning ? '#6e7781' : 'var(--accent-blue)' }}
>
{isRunning
? <><CircleDot className="w-3 h-3 animate-spin" />执行中…</>
: <><Play className="w-3 h-3" />执行</>}
</button>
</div>
</div>
</Panel>
</ReactFlow>
{selectedNode && (
<div className="absolute right-4 top-4 w-64 rounded-lg border shadow-lg p-3 z-10" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center justify-between mb-2">
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>节点配置</span>
<button onClick={() => setSelectedNode(null)} className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]">
<X className="w-3 h-3" />
</button>
</div>
<div className="text-[11px] font-mono mb-2" style={{ color: 'var(--text-tertiary)' }}>{selectedNode.id}</div>
<div className="text-[12px] font-medium mb-1" style={{ color: 'var(--text-primary)' }}>{selectedNode.data?.label}</div>
<div className="text-[10px] mb-2" style={{ color: 'var(--text-secondary)' }}>Category: {selectedNode.data?.category}</div>
{selectedNode.data?.config && Object.keys(selectedNode.data.config).length > 0 && (
<div className="space-y-1.5">
{Object.entries(selectedNode.data.config).map(([k, v]) => (
<div key={k}>
<label className="block text-[10px] mb-0.5" style={{ color: 'var(--text-tertiary)' }}>{k}</label>
<input
value={String(v)}
onChange={(e) => {
setNodes((nds) =>
nds.map((n) =>
n.id === selectedNode.id
? { ...n, data: { ...n.data, config: { ...n.data?.config, [k]: e.target.value } } }
: n
)
);
}}
className="w-full px-2 py-[3px] text-[11px] rounded border outline-none"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
/>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* 底部日志控制台:执行时实时滚动 */}
{logOpen && (
<div className="border-t flex flex-col" style={{ height: 200, borderColor: 'var(--border-color)', background: 'var(--panel-bg)' }}>
<div className="flex items-center justify-between px-3 py-1.5 border-b" style={{ borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-2">
<Terminal className="w-3 h-3" style={{ color: 'var(--accent-blue)' }} />
<span className="text-[11px] font-semibold" style={{ color: 'var(--text-primary)' }}>执行日志</span>
{isRunning && (
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium animate-pulse" style={{ background: '#fff8c5', color: '#9a6700' }}>
RUNNING
</span>
)}
<span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>{logs.length} 行</span>
</div>
<button onClick={() => setLogOpen(false)} className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]">
<X className="w-3.5 h-3.5" />
</button>
</div>
<div ref={logBoxRef} className="flex-1 overflow-y-auto custom-scrollbar px-3 py-1.5 font-mono text-[11px] leading-5">
{logs.length === 0 && (
<div style={{ color: 'var(--text-tertiary)' }}>等待日志输出…</div>
)}
{logs.map((entry, i) => (
<div key={i} className="flex gap-2">
<span style={{ color: 'var(--text-tertiary)' }}>
{entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : ''}
</span>
<span style={{ color: entry.level === 'error' ? '#cf222e' : entry.level === 'warn' ? '#9a6700' : 'var(--text-secondary)' }}>
{entry.message}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
export default function PipelineFlowEditor(props: FlowEditorProps) {
return (
<ReactFlowProvider>
<FlowEditorInner {...props} />
</ReactFlowProvider>
);
}
@@ -0,0 +1,90 @@
import { memo } from 'react';
import { Handle, Position } from '@xyflow/react';
import type { NodeProps } from '@xyflow/react';
import {
GitBranch, Package, Shield, Layers,
Terminal, BookOpen, Play, CheckCircle2, XCircle, Clock, Loader2
} from 'lucide-react';
export interface TektonNodeData {
[key: string]: unknown;
label: string;
category: string;
status?: 'pending' | 'running' | 'success' | 'failed' | 'skipped';
duration?: string;
config?: Record<string, any>;
}
const categoryConfig: Record<string, { color: string; bg: string; icon: typeof GitBranch }> = {
source: { color: '#0969da', bg: '#ddf4ff', icon: GitBranch },
build: { color: '#1a7f37', bg: '#dafbe1', icon: Package },
test: { color: '#9a6700', bg: '#fff8c5', icon: Terminal },
security: { color: '#cf222e', bg: '#ffebe9', icon: Shield },
deploy: { color: '#8250df', bg: '#fbefff', icon: Layers },
gitops: { color: '#0e7c7b', bg: '#d5f5f6', icon: BookOpen },
custom: { color: '#6e7781', bg: '#f6f8fa', icon: Play },
};
const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string }> = {
pending: { icon: Clock, color: '#6e7781' },
running: { icon: Loader2, color: '#9a6700' },
success: { icon: CheckCircle2, color: '#1a7f37' },
failed: { icon: XCircle, color: '#cf222e' },
skipped: { icon: Clock, color: '#6e7781' },
};
const TektonNode = memo((props: NodeProps) => {
const data = props.data as TektonNodeData;
const selected = props.selected;
const cat = categoryConfig[data?.category] || categoryConfig.custom;
const Icon = cat.icon;
const st = data?.status ? statusConfig[data.status] : null;
const StatusIcon = st?.icon;
return (
<div
className="rounded-lg border shadow-sm transition-all"
style={{
width: 220,
background: selected ? cat.bg : 'var(--panel-bg)',
borderColor: selected ? cat.color : 'var(--border-color)',
borderWidth: selected ? 2 : 1,
}}
>
<div
className="flex items-center gap-2 px-3 py-2 rounded-t-lg"
style={{ background: cat.bg }}
>
<Icon className="w-4 h-4 flex-shrink-0" style={{ color: cat.color }} />
<span className="text-[12px] font-semibold truncate flex-1" style={{ color: cat.color }}>
{data?.label}
</span>
{StatusIcon && (
<StatusIcon className={`w-3.5 h-3.5 ${data?.status === 'running' ? 'animate-spin' : ''}`} style={{ color: st!.color }} />
)}
</div>
<div className="px-3 py-2">
{data?.duration && (
<div className="text-[10px] mb-1" style={{ color: 'var(--text-tertiary)' }}>
耗时: {data.duration}
</div>
)}
{data?.config && Object.keys(data.config).length > 0 && (
<div className="space-y-1">
{Object.entries(data.config).slice(0, 3).map(([k, v]) => (
<div key={k} className="flex items-center gap-1 text-[10px]">
<span style={{ color: 'var(--text-tertiary)' }}>{k}:</span>
<span className="font-mono truncate" style={{ color: 'var(--text-secondary)' }}>{String(v)}</span>
</div>
))}
</div>
)}
</div>
<Handle type="target" position={Position.Top} style={{ width: 8, height: 8, background: cat.color }} />
<Handle type="source" position={Position.Bottom} style={{ width: 8, height: 8, background: cat.color }} />
</div>
);
});
TektonNode.displayName = 'TektonNode';
export default TektonNode;
+64
View File
@@ -0,0 +1,64 @@
import * as React from "react"
import * as AccordionPrimitive from "@radix-ui/react-accordion"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Accordion({
...props
}: React.ComponentProps<typeof AccordionPrimitive.Root>) {
return <AccordionPrimitive.Root data-slot="accordion" {...props} />
}
function AccordionItem({
className,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Item>) {
return (
<AccordionPrimitive.Item
data-slot="accordion-item"
className={cn("border-b last:border-b-0", className)}
{...props}
/>
)
}
function AccordionTrigger({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Trigger>) {
return (
<AccordionPrimitive.Header className="flex">
<AccordionPrimitive.Trigger
data-slot="accordion-trigger"
className={cn(
"focus-visible:border-ring focus-visible:ring-ring/50 flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline focus-visible:ring-[3px] disabled:pointer-events-none disabled:opacity-50 [&[data-state=open]>svg]:rotate-180",
className
)}
{...props}
>
{children}
<ChevronDownIcon className="text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200" />
</AccordionPrimitive.Trigger>
</AccordionPrimitive.Header>
)
}
function AccordionContent({
className,
children,
...props
}: React.ComponentProps<typeof AccordionPrimitive.Content>) {
return (
<AccordionPrimitive.Content
data-slot="accordion-content"
className="data-[state=closed]:animate-accordion-up data-[state=open]:animate-accordion-down overflow-hidden text-sm"
{...props}
>
<div className={cn("pt-0 pb-4", className)}>{children}</div>
</AccordionPrimitive.Content>
)
}
export { Accordion, AccordionItem, AccordionTrigger, AccordionContent }
+155
View File
@@ -0,0 +1,155 @@
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
function AlertDialog({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Root>) {
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
}
function AlertDialogTrigger({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Trigger>) {
return (
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
)
}
function AlertDialogPortal({
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Portal>) {
return (
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
)
}
function AlertDialogOverlay({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Overlay>) {
return (
<AlertDialogPrimitive.Overlay
data-slot="alert-dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function AlertDialogContent({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Content>) {
return (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
data-slot="alert-dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
)
}
function AlertDialogHeader({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function AlertDialogFooter({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function AlertDialogTitle({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
return (
<AlertDialogPrimitive.Title
data-slot="alert-dialog-title"
className={cn("text-lg font-semibold", className)}
{...props}
/>
)
}
function AlertDialogDescription({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
return (
<AlertDialogPrimitive.Description
data-slot="alert-dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function AlertDialogAction({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Action>) {
return (
<AlertDialogPrimitive.Action
className={cn(buttonVariants(), className)}
{...props}
/>
)
}
function AlertDialogCancel({
className,
...props
}: React.ComponentProps<typeof AlertDialogPrimitive.Cancel>) {
return (
<AlertDialogPrimitive.Cancel
className={cn(buttonVariants({ variant: "outline" }), className)}
{...props}
/>
)
}
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}
+66
View File
@@ -0,0 +1,66 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
{
variants: {
variant: {
default: "bg-card text-card-foreground",
destructive:
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Alert({
className,
variant,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
return (
<div
data-slot="alert"
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
)
}
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-title"
className={cn(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
className
)}
{...props}
/>
)
}
function AlertDescription({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="alert-description"
className={cn(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
className
)}
{...props}
/>
)
}
export { Alert, AlertTitle, AlertDescription }
@@ -0,0 +1,11 @@
"use client"
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
function AspectRatio({
...props
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
}
export { AspectRatio }
+51
View File
@@ -0,0 +1,51 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
function Avatar({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
className={cn(
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
)
}
function AvatarImage({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full", className)}
{...props}
/>
)
}
function AvatarFallback({
className,
...props
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"bg-muted flex size-full items-center justify-center rounded-full",
className
)}
{...props}
/>
)
}
export { Avatar, AvatarImage, AvatarFallback }
+46
View File
@@ -0,0 +1,46 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant,
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "span"
return (
<Comp
data-slot="badge"
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }
+109
View File
@@ -0,0 +1,109 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { ChevronRight, MoreHorizontal } from "lucide-react"
import { cn } from "@/lib/utils"
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
}
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
return (
<ol
data-slot="breadcrumb-list"
className={cn(
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
className
)}
{...props}
/>
)
}
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-item"
className={cn("inline-flex items-center gap-1.5", className)}
{...props}
/>
)
}
function BreadcrumbLink({
asChild,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="breadcrumb-link"
className={cn("hover:text-foreground transition-colors", className)}
{...props}
/>
)
}
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-page"
role="link"
aria-disabled="true"
aria-current="page"
className={cn("text-foreground font-normal", className)}
{...props}
/>
)
}
function BreadcrumbSeparator({
children,
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="breadcrumb-separator"
role="presentation"
aria-hidden="true"
className={cn("[&>svg]:size-3.5", className)}
{...props}
>
{children ?? <ChevronRight />}
</li>
)
}
function BreadcrumbEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="breadcrumb-ellipsis"
role="presentation"
aria-hidden="true"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontal className="size-4" />
<span className="sr-only">More</span>
</span>
)
}
export {
Breadcrumb,
BreadcrumbList,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbPage,
BreadcrumbSeparator,
BreadcrumbEllipsis,
}
@@ -0,0 +1,83 @@
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
const buttonGroupVariants = cva(
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
{
variants: {
orientation: {
horizontal:
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
vertical:
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
},
},
defaultVariants: {
orientation: "horizontal",
},
}
)
function ButtonGroup({
className,
orientation,
...props
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
return (
<div
role="group"
data-slot="button-group"
data-orientation={orientation}
className={cn(buttonGroupVariants({ orientation }), className)}
{...props}
/>
)
}
function ButtonGroupText({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "div"
return (
<Comp
className={cn(
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ButtonGroupSeparator({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="button-group-separator"
orientation={orientation}
className={cn(
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
className
)}
{...props}
/>
)
}
export {
ButtonGroup,
ButtonGroupSeparator,
ButtonGroupText,
buttonGroupVariants,
}
+62
View File
@@ -0,0 +1,62 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }
+220
View File
@@ -0,0 +1,220 @@
"use client"
import * as React from "react"
import {
ChevronDownIcon,
ChevronLeftIcon,
ChevronRightIcon,
} from "lucide-react"
import {
DayPicker,
getDefaultClassNames,
type DayButton,
} from "react-day-picker"
import { cn } from "@/lib/utils"
import { Button, buttonVariants } from "@/components/ui/button"
function Calendar({
className,
classNames,
showOutsideDays = true,
captionLayout = "label",
buttonVariant = "ghost",
formatters,
components,
...props
}: React.ComponentProps<typeof DayPicker> & {
buttonVariant?: React.ComponentProps<typeof Button>["variant"]
}) {
const defaultClassNames = getDefaultClassNames()
return (
<DayPicker
showOutsideDays={showOutsideDays}
className={cn(
"bg-background group/calendar p-3 [--cell-size:--spacing(8)] [[data-slot=card-content]_&]:bg-transparent [[data-slot=popover-content]_&]:bg-transparent",
String.raw`rtl:**:[.rdp-button\_next>svg]:rotate-180`,
String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`,
className
)}
captionLayout={captionLayout}
formatters={{
formatMonthDropdown: (date) =>
date.toLocaleString("default", { month: "short" }),
...formatters,
}}
classNames={{
root: cn("w-fit", defaultClassNames.root),
months: cn(
"flex gap-4 flex-col md:flex-row relative",
defaultClassNames.months
),
month: cn("flex flex-col w-full gap-4", defaultClassNames.month),
nav: cn(
"flex items-center gap-1 w-full absolute top-0 inset-x-0 justify-between",
defaultClassNames.nav
),
button_previous: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_previous
),
button_next: cn(
buttonVariants({ variant: buttonVariant }),
"size-(--cell-size) aria-disabled:opacity-50 p-0 select-none",
defaultClassNames.button_next
),
month_caption: cn(
"flex items-center justify-center h-(--cell-size) w-full px-(--cell-size)",
defaultClassNames.month_caption
),
dropdowns: cn(
"w-full flex items-center text-sm font-medium justify-center h-(--cell-size) gap-1.5",
defaultClassNames.dropdowns
),
dropdown_root: cn(
"relative has-focus:border-ring border border-input shadow-xs has-focus:ring-ring/50 has-focus:ring-[3px] rounded-md",
defaultClassNames.dropdown_root
),
dropdown: cn(
"absolute bg-popover inset-0 opacity-0",
defaultClassNames.dropdown
),
caption_label: cn(
"select-none font-medium",
captionLayout === "label"
? "text-sm"
: "rounded-md pl-2 pr-1 flex items-center gap-1 text-sm h-8 [&>svg]:text-muted-foreground [&>svg]:size-3.5",
defaultClassNames.caption_label
),
table: "w-full border-collapse",
weekdays: cn("flex", defaultClassNames.weekdays),
weekday: cn(
"text-muted-foreground rounded-md flex-1 font-normal text-[0.8rem] select-none",
defaultClassNames.weekday
),
week: cn("flex w-full mt-2", defaultClassNames.week),
week_number_header: cn(
"select-none w-(--cell-size)",
defaultClassNames.week_number_header
),
week_number: cn(
"text-[0.8rem] select-none text-muted-foreground",
defaultClassNames.week_number
),
day: cn(
"relative w-full h-full p-0 text-center [&:last-child[data-selected=true]_button]:rounded-r-md group/day aspect-square select-none",
props.showWeekNumber
? "[&:nth-child(2)[data-selected=true]_button]:rounded-l-md"
: "[&:first-child[data-selected=true]_button]:rounded-l-md",
defaultClassNames.day
),
range_start: cn(
"rounded-l-md bg-accent",
defaultClassNames.range_start
),
range_middle: cn("rounded-none", defaultClassNames.range_middle),
range_end: cn("rounded-r-md bg-accent", defaultClassNames.range_end),
today: cn(
"bg-accent text-accent-foreground rounded-md data-[selected=true]:rounded-none",
defaultClassNames.today
),
outside: cn(
"text-muted-foreground aria-selected:text-muted-foreground",
defaultClassNames.outside
),
disabled: cn(
"text-muted-foreground opacity-50",
defaultClassNames.disabled
),
hidden: cn("invisible", defaultClassNames.hidden),
...classNames,
}}
components={{
Root: ({ className, rootRef, ...props }) => {
return (
<div
data-slot="calendar"
ref={rootRef}
className={cn(className)}
{...props}
/>
)
},
Chevron: ({ className, orientation, ...props }) => {
if (orientation === "left") {
return (
<ChevronLeftIcon className={cn("size-4", className)} {...props} />
)
}
if (orientation === "right") {
return (
<ChevronRightIcon
className={cn("size-4", className)}
{...props}
/>
)
}
return (
<ChevronDownIcon className={cn("size-4", className)} {...props} />
)
},
DayButton: CalendarDayButton,
WeekNumber: ({ children, ...props }) => {
return (
<td {...props}>
<div className="flex size-(--cell-size) items-center justify-center text-center">
{children}
</div>
</td>
)
},
...components,
}}
{...props}
/>
)
}
function CalendarDayButton({
className,
day,
modifiers,
...props
}: React.ComponentProps<typeof DayButton>) {
const defaultClassNames = getDefaultClassNames()
const ref = React.useRef<HTMLButtonElement>(null)
React.useEffect(() => {
if (modifiers.focused) ref.current?.focus()
}, [modifiers.focused])
return (
<Button
ref={ref}
variant="ghost"
size="icon"
data-day={day.date.toLocaleDateString()}
data-selected-single={
modifiers.selected &&
!modifiers.range_start &&
!modifiers.range_end &&
!modifiers.range_middle
}
data-range-start={modifiers.range_start}
data-range-end={modifiers.range_end}
data-range-middle={modifiers.range_middle}
className={cn(
"data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[range-middle=true]:bg-accent data-[range-middle=true]:text-accent-foreground data-[range-start=true]:bg-primary data-[range-start=true]:text-primary-foreground data-[range-end=true]:bg-primary data-[range-end=true]:text-primary-foreground group-data-[focused=true]/day:border-ring group-data-[focused=true]/day:ring-ring/50 dark:hover:text-accent-foreground flex aspect-square size-auto w-full min-w-(--cell-size) flex-col gap-1 leading-none font-normal group-data-[focused=true]/day:relative group-data-[focused=true]/day:z-10 group-data-[focused=true]/day:ring-[3px] data-[range-end=true]:rounded-md data-[range-end=true]:rounded-r-md data-[range-middle=true]:rounded-none data-[range-start=true]:rounded-md data-[range-start=true]:rounded-l-md [&>span]:text-xs [&>span]:opacity-70",
defaultClassNames.day,
className
)}
{...props}
/>
)
}
export { Calendar, CalendarDayButton }
+92
View File
@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+239
View File
@@ -0,0 +1,239 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}
+357
View File
@@ -0,0 +1,357 @@
"use client"
import * as React from "react"
import * as RechartsPrimitive from "recharts"
import { cn } from "@/lib/utils"
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode
icon?: React.ComponentType
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
)
}
type ChartContextProps = {
config: ChartConfig
}
const ChartContext = React.createContext<ChartContextProps | null>(null)
function useChart() {
const context = React.useContext(ChartContext)
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />")
}
return context
}
function ChartContainer({
id,
className,
children,
config,
...props
}: React.ComponentProps<"div"> & {
config: ChartConfig
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"]
}) {
const uniqueId = React.useId()
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`
return (
<ChartContext.Provider value={{ config }}>
<div
data-slot="chart"
data-chart={chartId}
className={cn(
"[&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border flex aspect-video justify-center text-xs [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-hidden [&_.recharts-sector]:outline-hidden [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-surface]:outline-hidden",
className
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
)
}
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color
)
if (!colorConfig.length) {
return null
}
return (
<style
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color
return color ? ` --color-${key}: ${color};` : null
})
.join("\n")}
}
`
)
.join("\n"),
}}
/>
)
}
const ChartTooltip = RechartsPrimitive.Tooltip
function ChartTooltipContent({
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
}: React.ComponentProps<typeof RechartsPrimitive.Tooltip> &
React.ComponentProps<"div"> & {
hideLabel?: boolean
hideIndicator?: boolean
indicator?: "line" | "dot" | "dashed"
nameKey?: string
labelKey?: string
}) {
const { config } = useChart()
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null
}
const [item] = payload
const key = `${labelKey || item?.dataKey || item?.name || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
)
}
if (!value) {
return null
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
])
if (!active || !payload?.length) {
return null
}
const nestLabel = payload.length === 1 && indicator !== "dot"
return (
<div
className={cn(
"border-border/50 bg-background grid min-w-[8rem] items-start gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs shadow-xl",
className
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item) => item.type !== "none")
.map((item, index) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
const indicatorColor = color || item.payload.fill || item.color
return (
<div
key={item.dataKey}
className={cn(
"[&>svg]:text-muted-foreground flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5",
indicator === "dot" && "items-center"
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-(--color-border) bg-(--color-bg)",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
}
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center"
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="text-foreground font-mono font-medium tabular-nums">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
)
})}
</div>
</div>
)
}
const ChartLegend = RechartsPrimitive.Legend
function ChartLegendContent({
className,
hideIcon = false,
payload,
verticalAlign = "bottom",
nameKey,
}: React.ComponentProps<"div"> &
Pick<RechartsPrimitive.LegendProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean
nameKey?: string
}) {
const { config } = useChart()
if (!payload?.length) {
return null
}
return (
<div
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className
)}
>
{payload
.filter((item) => item.type !== "none")
.map((item) => {
const key = `${nameKey || item.dataKey || "value"}`
const itemConfig = getPayloadConfigFromPayload(config, item, key)
return (
<div
key={item.value}
className={cn(
"[&>svg]:text-muted-foreground flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3"
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
)
})}
</div>
)
}
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string
) {
if (typeof payload !== "object" || payload === null) {
return undefined
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined
let configLabelKey: string = key
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config]
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
}
+32
View File
@@ -0,0 +1,32 @@
"use client"
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="grid place-content-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }
@@ -0,0 +1,31 @@
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
function Collapsible({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
}
function CollapsibleTrigger({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
return (
<CollapsiblePrimitive.CollapsibleTrigger
data-slot="collapsible-trigger"
{...props}
/>
)
}
function CollapsibleContent({
...props
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
return (
<CollapsiblePrimitive.CollapsibleContent
data-slot="collapsible-content"
{...props}
/>
)
}
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
+182
View File
@@ -0,0 +1,182 @@
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={cn(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
className,
showCloseButton = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
className?: string
showCloseButton?: boolean
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
>
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={cn(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={cn(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={cn(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={cn("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={cn(
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}
+252
View File
@@ -0,0 +1,252 @@
"use client"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function ContextMenu({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Root>) {
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />
}
function ContextMenuTrigger({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
return (
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
)
}
function ContextMenuGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
return (
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
)
}
function ContextMenuPortal({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
return (
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
)
}
function ContextMenuSub({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />
}
function ContextMenuRadioGroup({
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
return (
<ContextMenuPrimitive.RadioGroup
data-slot="context-menu-radio-group"
{...props}
/>
)
}
function ContextMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.SubTrigger
data-slot="context-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto" />
</ContextMenuPrimitive.SubTrigger>
)
}
function ContextMenuSubContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
return (
<ContextMenuPrimitive.SubContent
data-slot="context-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
function ContextMenuContent({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
return (
<ContextMenuPrimitive.Portal>
<ContextMenuPrimitive.Content
data-slot="context-menu-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</ContextMenuPrimitive.Portal>
)
}
function ContextMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<ContextMenuPrimitive.Item
data-slot="context-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function ContextMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
return (
<ContextMenuPrimitive.CheckboxItem
data-slot="context-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.CheckboxItem>
)
}
function ContextMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
return (
<ContextMenuPrimitive.RadioItem
data-slot="context-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<ContextMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</ContextMenuPrimitive.ItemIndicator>
</span>
{children}
</ContextMenuPrimitive.RadioItem>
)
}
function ContextMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<ContextMenuPrimitive.Label
data-slot="context-menu-label"
data-inset={inset}
className={cn(
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function ContextMenuSeparator({
className,
...props
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
return (
<ContextMenuPrimitive.Separator
data-slot="context-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function ContextMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="context-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
ContextMenu,
ContextMenuTrigger,
ContextMenuContent,
ContextMenuItem,
ContextMenuCheckboxItem,
ContextMenuRadioItem,
ContextMenuLabel,
ContextMenuSeparator,
ContextMenuShortcut,
ContextMenuGroup,
ContextMenuPortal,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuRadioGroup,
}
+141
View File
@@ -0,0 +1,141 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean
}) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
className
)}
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
data-slot="dialog-close"
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
>
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
{...props}
/>
)
}
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="dialog-footer"
className={cn(
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
className
)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("text-lg leading-none font-semibold", className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+135
View File
@@ -0,0 +1,135 @@
"use client"
import * as React from "react"
import { Drawer as DrawerPrimitive } from "vaul"
import { cn } from "@/lib/utils"
function Drawer({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
}
function DrawerTrigger({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
}
function DrawerPortal({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
}
function DrawerClose({
...props
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
}
function DrawerOverlay({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
return (
<DrawerPrimitive.Overlay
data-slot="drawer-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function DrawerContent({
className,
children,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
return (
<DrawerPortal data-slot="drawer-portal">
<DrawerOverlay />
<DrawerPrimitive.Content
data-slot="drawer-content"
className={cn(
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
className
)}
{...props}
>
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-header"
className={cn(
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
className
)}
{...props}
/>
)
}
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="drawer-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function DrawerTitle({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
return (
<DrawerPrimitive.Title
data-slot="drawer-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function DrawerDescription({
className,
...props
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
return (
<DrawerPrimitive.Description
data-slot="drawer-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
@@ -0,0 +1,255 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+104
View File
@@ -0,0 +1,104 @@
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
function Empty({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty"
className={cn(
"flex min-w-0 flex-1 flex-col items-center justify-center gap-6 rounded-lg border-dashed p-6 text-center text-balance md:p-12",
className
)}
{...props}
/>
)
}
function EmptyHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-header"
className={cn(
"flex max-w-sm flex-col items-center gap-2 text-center",
className
)}
{...props}
/>
)
}
const emptyMediaVariants = cva(
"flex shrink-0 items-center justify-center mb-2 [&_svg]:pointer-events-none [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-transparent",
icon: "bg-muted text-foreground flex size-10 shrink-0 items-center justify-center rounded-lg [&_svg:not([class*='size-'])]:size-6",
},
},
defaultVariants: {
variant: "default",
},
}
)
function EmptyMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof emptyMediaVariants>) {
return (
<div
data-slot="empty-icon"
data-variant={variant}
className={cn(emptyMediaVariants({ variant, className }))}
{...props}
/>
)
}
function EmptyTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-title"
className={cn("text-lg font-medium tracking-tight", className)}
{...props}
/>
)
}
function EmptyDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<div
data-slot="empty-description"
className={cn(
"text-muted-foreground [&>a:hover]:text-primary text-sm/relaxed [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function EmptyContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="empty-content"
className={cn(
"flex w-full max-w-sm min-w-0 flex-col items-center gap-4 text-sm text-balance",
className
)}
{...props}
/>
)
}
export {
Empty,
EmptyHeader,
EmptyTitle,
EmptyDescription,
EmptyContent,
EmptyMedia,
}
+246
View File
@@ -0,0 +1,246 @@
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
import { Separator } from "@/components/ui/separator"
function FieldSet({ className, ...props }: React.ComponentProps<"fieldset">) {
return (
<fieldset
data-slot="field-set"
className={cn(
"flex flex-col gap-6",
"has-[>[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3",
className
)}
{...props}
/>
)
}
function FieldLegend({
className,
variant = "legend",
...props
}: React.ComponentProps<"legend"> & { variant?: "legend" | "label" }) {
return (
<legend
data-slot="field-legend"
data-variant={variant}
className={cn(
"mb-3 font-medium",
"data-[variant=legend]:text-base",
"data-[variant=label]:text-sm",
className
)}
{...props}
/>
)
}
function FieldGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-group"
className={cn(
"group/field-group @container/field-group flex w-full flex-col gap-7 data-[slot=checkbox-group]:gap-3 [&>[data-slot=field-group]]:gap-4",
className
)}
{...props}
/>
)
}
const fieldVariants = cva(
"group/field flex w-full gap-3 data-[invalid=true]:text-destructive",
{
variants: {
orientation: {
vertical: ["flex-col [&>*]:w-full [&>.sr-only]:w-auto"],
horizontal: [
"flex-row items-center",
"[&>[data-slot=field-label]]:flex-auto",
"has-[>[data-slot=field-content]]:items-start has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
responsive: [
"flex-col [&>*]:w-full [&>.sr-only]:w-auto @md/field-group:flex-row @md/field-group:items-center @md/field-group:[&>*]:w-auto",
"@md/field-group:[&>[data-slot=field-label]]:flex-auto",
"@md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px",
],
},
},
defaultVariants: {
orientation: "vertical",
},
}
)
function Field({
className,
orientation = "vertical",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof fieldVariants>) {
return (
<div
role="group"
data-slot="field"
data-orientation={orientation}
className={cn(fieldVariants({ orientation }), className)}
{...props}
/>
)
}
function FieldContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-content"
className={cn(
"group/field-content flex flex-1 flex-col gap-1.5 leading-snug",
className
)}
{...props}
/>
)
}
function FieldLabel({
className,
...props
}: React.ComponentProps<typeof Label>) {
return (
<Label
data-slot="field-label"
className={cn(
"group/field-label peer/field-label flex w-fit gap-2 leading-snug group-data-[disabled=true]/field:opacity-50",
"has-[>[data-slot=field]]:w-full has-[>[data-slot=field]]:flex-col has-[>[data-slot=field]]:rounded-md has-[>[data-slot=field]]:border [&>*]:data-[slot=field]:p-4",
"has-data-[state=checked]:bg-primary/5 has-data-[state=checked]:border-primary dark:has-data-[state=checked]:bg-primary/10",
className
)}
{...props}
/>
)
}
function FieldTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="field-label"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium group-data-[disabled=true]/field:opacity-50",
className
)}
{...props}
/>
)
}
function FieldDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="field-description"
className={cn(
"text-muted-foreground text-sm leading-normal font-normal group-has-[[data-orientation=horizontal]]/field:text-balance",
"last:mt-0 nth-last-2:-mt-1 [[data-variant=legend]+&]:-mt-1.5",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function FieldSeparator({
children,
className,
...props
}: React.ComponentProps<"div"> & {
children?: React.ReactNode
}) {
return (
<div
data-slot="field-separator"
data-content={!!children}
className={cn(
"relative -my-2 h-5 text-sm group-data-[variant=outline]/field-group:-mb-2",
className
)}
{...props}
>
<Separator className="absolute inset-0 top-1/2" />
{children && (
<span
className="bg-background text-muted-foreground relative mx-auto block w-fit px-2"
data-slot="field-separator-content"
>
{children}
</span>
)}
</div>
)
}
function FieldError({
className,
children,
errors,
...props
}: React.ComponentProps<"div"> & {
errors?: Array<{ message?: string } | undefined>
}) {
const content = useMemo(() => {
if (children) {
return children
}
if (!errors?.length) {
return null
}
const uniqueErrors = [
...new Map(errors.map((error) => [error?.message, error])).values(),
]
if (uniqueErrors?.length == 1) {
return uniqueErrors[0]?.message
}
return (
<ul className="ml-4 flex list-disc flex-col gap-1">
{uniqueErrors.map(
(error, index) =>
error?.message && <li key={index}>{error.message}</li>
)}
</ul>
)
}, [children, errors])
if (!content) {
return null
}
return (
<div
role="alert"
data-slot="field-error"
className={cn("text-destructive text-sm font-normal", className)}
{...props}
>
{content}
</div>
)
}
export {
Field,
FieldLabel,
FieldDescription,
FieldError,
FieldGroup,
FieldLegend,
FieldSeparator,
FieldSet,
FieldContent,
FieldTitle,
}
+167
View File
@@ -0,0 +1,167 @@
"use client"
import * as React from "react"
import type * as LabelPrimitive from "@radix-ui/react-label"
import { Slot } from "@radix-ui/react-slot"
import {
Controller,
FormProvider,
useFormContext,
useFormState,
type ControllerProps,
type FieldPath,
type FieldValues,
} from "react-hook-form"
import { cn } from "@/lib/utils"
import { Label } from "@/components/ui/label"
const Form = FormProvider
type FormFieldContextValue<
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
> = {
name: TName
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
{} as FormFieldContextValue
)
const FormField = <
TFieldValues extends FieldValues = FieldValues,
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>,
>({
...props
}: ControllerProps<TFieldValues, TName>) => {
return (
<FormFieldContext.Provider value={{ name: props.name }}>
<Controller {...props} />
</FormFieldContext.Provider>
)
}
const useFormField = () => {
const fieldContext = React.useContext(FormFieldContext)
const itemContext = React.useContext(FormItemContext)
const { getFieldState } = useFormContext()
const formState = useFormState({ name: fieldContext.name })
const fieldState = getFieldState(fieldContext.name, formState)
if (!fieldContext) {
throw new Error("useFormField should be used within <FormField>")
}
const { id } = itemContext
return {
id,
name: fieldContext.name,
formItemId: `${id}-form-item`,
formDescriptionId: `${id}-form-item-description`,
formMessageId: `${id}-form-item-message`,
...fieldState,
}
}
type FormItemContextValue = {
id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
{} as FormItemContextValue
)
function FormItem({ className, ...props }: React.ComponentProps<"div">) {
const id = React.useId()
return (
<FormItemContext.Provider value={{ id }}>
<div
data-slot="form-item"
className={cn("grid gap-2", className)}
{...props}
/>
</FormItemContext.Provider>
)
}
function FormLabel({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
const { error, formItemId } = useFormField()
return (
<Label
data-slot="form-label"
data-error={!!error}
className={cn("data-[error=true]:text-destructive", className)}
htmlFor={formItemId}
{...props}
/>
)
}
function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
return (
<Slot
data-slot="form-control"
id={formItemId}
aria-describedby={
!error
? `${formDescriptionId}`
: `${formDescriptionId} ${formMessageId}`
}
aria-invalid={!!error}
{...props}
/>
)
}
function FormDescription({ className, ...props }: React.ComponentProps<"p">) {
const { formDescriptionId } = useFormField()
return (
<p
data-slot="form-description"
id={formDescriptionId}
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function FormMessage({ className, ...props }: React.ComponentProps<"p">) {
const { error, formMessageId } = useFormField()
const body = error ? String(error?.message ?? "") : props.children
if (!body) {
return null
}
return (
<p
data-slot="form-message"
id={formMessageId}
className={cn("text-destructive text-sm", className)}
{...props}
>
{body}
</p>
)
}
export {
useFormField,
Form,
FormItem,
FormLabel,
FormControl,
FormDescription,
FormMessage,
FormField,
}
+44
View File
@@ -0,0 +1,44 @@
"use client"
import * as React from "react"
import * as HoverCardPrimitive from "@radix-ui/react-hover-card"
import { cn } from "@/lib/utils"
function HoverCard({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Root>) {
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />
}
function HoverCardTrigger({
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
return (
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
)
}
function HoverCardContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
return (
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
<HoverCardPrimitive.Content
data-slot="hover-card-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</HoverCardPrimitive.Portal>
)
}
export { HoverCard, HoverCardTrigger, HoverCardContent }
+170
View File
@@ -0,0 +1,170 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-group"
role="group"
className={cn(
"group/input-group border-input dark:bg-input/30 relative flex w-full items-center rounded-md border shadow-xs transition-[color,box-shadow] outline-none",
"h-9 min-w-0 has-[>textarea]:h-auto",
// Variants based on alignment.
"has-[>[data-align=inline-start]]:[&>input]:pl-2",
"has-[>[data-align=inline-end]]:[&>input]:pr-2",
"has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3",
"has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3",
// Focus state.
"has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot=input-group-control]:focus-visible]:ring-[3px]",
// Error state.
"has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40",
className
)}
{...props}
/>
)
}
const inputGroupAddonVariants = cva(
"text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50",
{
variants: {
align: {
"inline-start":
"order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]",
"inline-end":
"order-last pr-3 has-[>button]:mr-[-0.45rem] has-[>kbd]:mr-[-0.35rem]",
"block-start":
"order-first w-full justify-start px-3 pt-3 [.border-b]:pb-3 group-has-[>input]/input-group:pt-2.5",
"block-end":
"order-last w-full justify-start px-3 pb-3 [.border-t]:pt-3 group-has-[>input]/input-group:pb-2.5",
},
},
defaultVariants: {
align: "inline-start",
},
}
)
function InputGroupAddon({
className,
align = "inline-start",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof inputGroupAddonVariants>) {
return (
<div
role="group"
data-slot="input-group-addon"
data-align={align}
className={cn(inputGroupAddonVariants({ align }), className)}
onClick={(e) => {
if ((e.target as HTMLElement).closest("button")) {
return
}
e.currentTarget.parentElement?.querySelector("input")?.focus()
}}
{...props}
/>
)
}
const inputGroupButtonVariants = cva(
"text-sm shadow-none flex gap-2 items-center",
{
variants: {
size: {
xs: "h-6 gap-1 px-2 rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-3.5 has-[>svg]:px-2",
sm: "h-8 px-2.5 gap-1.5 rounded-md has-[>svg]:px-2.5",
"icon-xs":
"size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0",
"icon-sm": "size-8 p-0 has-[>svg]:p-0",
},
},
defaultVariants: {
size: "xs",
},
}
)
function InputGroupButton({
className,
type = "button",
variant = "ghost",
size = "xs",
...props
}: Omit<React.ComponentProps<typeof Button>, "size"> &
VariantProps<typeof inputGroupButtonVariants>) {
return (
<Button
type={type}
data-size={size}
variant={variant}
className={cn(inputGroupButtonVariants({ size }), className)}
{...props}
/>
)
}
function InputGroupText({ className, ...props }: React.ComponentProps<"span">) {
return (
<span
className={cn(
"text-muted-foreground flex items-center gap-2 text-sm [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function InputGroupInput({
className,
...props
}: React.ComponentProps<"input">) {
return (
<Input
data-slot="input-group-control"
className={cn(
"flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
function InputGroupTextarea({
className,
...props
}: React.ComponentProps<"textarea">) {
return (
<Textarea
data-slot="input-group-control"
className={cn(
"flex-1 resize-none rounded-none border-0 bg-transparent py-3 shadow-none focus-visible:ring-0 dark:bg-transparent",
className
)}
{...props}
/>
)
}
export {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupText,
InputGroupInput,
InputGroupTextarea,
}
+75
View File
@@ -0,0 +1,75 @@
import * as React from "react"
import { OTPInput, OTPInputContext } from "input-otp"
import { MinusIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function InputOTP({
className,
containerClassName,
...props
}: React.ComponentProps<typeof OTPInput> & {
containerClassName?: string
}) {
return (
<OTPInput
data-slot="input-otp"
containerClassName={cn(
"flex items-center gap-2 has-disabled:opacity-50",
containerClassName
)}
className={cn("disabled:cursor-not-allowed", className)}
{...props}
/>
)
}
function InputOTPGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="input-otp-group"
className={cn("flex items-center", className)}
{...props}
/>
)
}
function InputOTPSlot({
index,
className,
...props
}: React.ComponentProps<"div"> & {
index: number
}) {
const inputOTPContext = React.useContext(OTPInputContext)
const { char, hasFakeCaret, isActive } = inputOTPContext?.slots[index] ?? {}
return (
<div
data-slot="input-otp-slot"
data-active={isActive}
className={cn(
"data-[active=true]:border-ring data-[active=true]:ring-ring/50 data-[active=true]:aria-invalid:ring-destructive/20 dark:data-[active=true]:aria-invalid:ring-destructive/40 aria-invalid:border-destructive data-[active=true]:aria-invalid:border-destructive dark:bg-input/30 border-input relative flex h-9 w-9 items-center justify-center border-y border-r text-sm shadow-xs transition-all outline-none first:rounded-l-md first:border-l last:rounded-r-md data-[active=true]:z-10 data-[active=true]:ring-[3px]",
className
)}
{...props}
>
{char}
{hasFakeCaret && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<div className="animate-caret-blink bg-foreground h-4 w-px duration-1000" />
</div>
)}
</div>
)
}
function InputOTPSeparator({ ...props }: React.ComponentProps<"div">) {
return (
<div data-slot="input-otp-separator" role="separator" {...props}>
<MinusIcon />
</div>
)
}
export { InputOTP, InputOTPGroup, InputOTPSlot, InputOTPSeparator }
+21
View File
@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }
+193
View File
@@ -0,0 +1,193 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"
function ItemGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
role="list"
data-slot="item-group"
className={cn("group/item-group flex flex-col", className)}
{...props}
/>
)
}
function ItemSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="item-separator"
orientation="horizontal"
className={cn("my-0", className)}
{...props}
/>
)
}
const itemVariants = cva(
"group/item flex items-center border border-transparent text-sm rounded-md transition-colors [a]:hover:bg-accent/50 [a]:transition-colors duration-100 flex-wrap outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
{
variants: {
variant: {
default: "bg-transparent",
outline: "border-border",
muted: "bg-muted/50",
},
size: {
default: "p-4 gap-4 ",
sm: "py-3 px-4 gap-2.5",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Item({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"div"> &
VariantProps<typeof itemVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="item"
data-variant={variant}
data-size={size}
className={cn(itemVariants({ variant, size, className }))}
{...props}
/>
)
}
const itemMediaVariants = cva(
"flex shrink-0 items-center justify-center gap-2 group-has-[[data-slot=item-description]]/item:self-start [&_svg]:pointer-events-none group-has-[[data-slot=item-description]]/item:translate-y-0.5",
{
variants: {
variant: {
default: "bg-transparent",
icon: "size-8 border rounded-sm bg-muted [&_svg:not([class*='size-'])]:size-4",
image:
"size-10 rounded-sm overflow-hidden [&_img]:size-full [&_img]:object-cover",
},
},
defaultVariants: {
variant: "default",
},
}
)
function ItemMedia({
className,
variant = "default",
...props
}: React.ComponentProps<"div"> & VariantProps<typeof itemMediaVariants>) {
return (
<div
data-slot="item-media"
data-variant={variant}
className={cn(itemMediaVariants({ variant, className }))}
{...props}
/>
)
}
function ItemContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-content"
className={cn(
"flex flex-1 flex-col gap-1 [&+[data-slot=item-content]]:flex-none",
className
)}
{...props}
/>
)
}
function ItemTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-title"
className={cn(
"flex w-fit items-center gap-2 text-sm leading-snug font-medium",
className
)}
{...props}
/>
)
}
function ItemDescription({ className, ...props }: React.ComponentProps<"p">) {
return (
<p
data-slot="item-description"
className={cn(
"text-muted-foreground line-clamp-2 text-sm leading-normal font-normal text-balance",
"[&>a:hover]:text-primary [&>a]:underline [&>a]:underline-offset-4",
className
)}
{...props}
/>
)
}
function ItemActions({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-actions"
className={cn("flex items-center gap-2", className)}
{...props}
/>
)
}
function ItemHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-header"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
function ItemFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="item-footer"
className={cn(
"flex basis-full items-center justify-between gap-2",
className
)}
{...props}
/>
)
}
export {
Item,
ItemMedia,
ItemContent,
ItemActions,
ItemGroup,
ItemSeparator,
ItemTitle,
ItemDescription,
ItemHeader,
ItemFooter,
}
+28
View File
@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils"
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"bg-muted text-muted-foreground pointer-events-none inline-flex h-5 w-fit min-w-5 items-center justify-center gap-1 rounded-sm px-1 font-sans text-xs font-medium select-none",
"[&_svg:not([class*='size-'])]:size-3",
"[[data-slot=tooltip-content]_&]:bg-background/20 [[data-slot=tooltip-content]_&]:text-background dark:[[data-slot=tooltip-content]_&]:bg-background/10",
className
)}
{...props}
/>
)
}
function KbdGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<kbd
data-slot="kbd-group"
className={cn("inline-flex items-center gap-1", className)}
{...props}
/>
)
}
export { Kbd, KbdGroup }
+24
View File
@@ -0,0 +1,24 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cn } from "@/lib/utils"
function Label({
className,
...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) {
return (
<LabelPrimitive.Root
data-slot="label"
className={cn(
"flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
className
)}
{...props}
/>
)
}
export { Label }
+274
View File
@@ -0,0 +1,274 @@
import * as React from "react"
import * as MenubarPrimitive from "@radix-ui/react-menubar"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Menubar({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
return (
<MenubarPrimitive.Root
data-slot="menubar"
className={cn(
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
className
)}
{...props}
/>
)
}
function MenubarMenu({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
}
function MenubarGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
}
function MenubarPortal({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
}
function MenubarRadioGroup({
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
return (
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
)
}
function MenubarTrigger({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
return (
<MenubarPrimitive.Trigger
data-slot="menubar-trigger"
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
className
)}
{...props}
/>
)
}
function MenubarContent({
className,
align = "start",
alignOffset = -4,
sideOffset = 8,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
return (
<MenubarPortal>
<MenubarPrimitive.Content
data-slot="menubar-content"
align={align}
alignOffset={alignOffset}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
className
)}
{...props}
/>
</MenubarPortal>
)
}
function MenubarItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<MenubarPrimitive.Item
data-slot="menubar-item"
data-inset={inset}
data-variant={variant}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function MenubarCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
return (
<MenubarPrimitive.CheckboxItem
data-slot="menubar-checkbox-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.CheckboxItem>
)
}
function MenubarRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
return (
<MenubarPrimitive.RadioItem
data-slot="menubar-radio-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<MenubarPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</MenubarPrimitive.ItemIndicator>
</span>
{children}
</MenubarPrimitive.RadioItem>
)
}
function MenubarLabel({
className,
inset,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.Label
data-slot="menubar-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function MenubarSeparator({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
return (
<MenubarPrimitive.Separator
data-slot="menubar-separator"
className={cn("bg-border -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function MenubarShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="menubar-shortcut"
className={cn(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
function MenubarSub({
...props
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
}
function MenubarSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<MenubarPrimitive.SubTrigger
data-slot="menubar-sub-trigger"
data-inset={inset}
className={cn(
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto h-4 w-4" />
</MenubarPrimitive.SubTrigger>
)
}
function MenubarSubContent({
className,
...props
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
return (
<MenubarPrimitive.SubContent
data-slot="menubar-sub-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
className
)}
{...props}
/>
)
}
export {
Menubar,
MenubarPortal,
MenubarMenu,
MenubarTrigger,
MenubarContent,
MenubarGroup,
MenubarSeparator,
MenubarLabel,
MenubarItem,
MenubarShortcut,
MenubarCheckboxItem,
MenubarRadioGroup,
MenubarRadioItem,
MenubarSub,
MenubarSubTrigger,
MenubarSubContent,
}
@@ -0,0 +1,168 @@
import * as React from "react"
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
import { cva } from "class-variance-authority"
import { ChevronDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function NavigationMenu({
className,
children,
viewport = true,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
viewport?: boolean
}) {
return (
<NavigationMenuPrimitive.Root
data-slot="navigation-menu"
data-viewport={viewport}
className={cn(
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
className
)}
{...props}
>
{children}
{viewport && <NavigationMenuViewport />}
</NavigationMenuPrimitive.Root>
)
}
function NavigationMenuList({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
return (
<NavigationMenuPrimitive.List
data-slot="navigation-menu-list"
className={cn(
"group flex flex-1 list-none items-center justify-center gap-1",
className
)}
{...props}
/>
)
}
function NavigationMenuItem({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
return (
<NavigationMenuPrimitive.Item
data-slot="navigation-menu-item"
className={cn("relative", className)}
{...props}
/>
)
}
const navigationMenuTriggerStyle = cva(
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
)
function NavigationMenuTrigger({
className,
children,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
return (
<NavigationMenuPrimitive.Trigger
data-slot="navigation-menu-trigger"
className={cn(navigationMenuTriggerStyle(), "group", className)}
{...props}
>
{children}{" "}
<ChevronDownIcon
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
)
}
function NavigationMenuContent({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
return (
<NavigationMenuPrimitive.Content
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className
)}
{...props}
/>
)
}
function NavigationMenuViewport({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
return (
<div
className={cn(
"absolute top-full left-0 isolate z-50 flex justify-center"
)}
>
<NavigationMenuPrimitive.Viewport
data-slot="navigation-menu-viewport"
className={cn(
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
className
)}
{...props}
/>
</div>
)
}
function NavigationMenuLink({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
return (
<NavigationMenuPrimitive.Link
data-slot="navigation-menu-link"
className={cn(
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function NavigationMenuIndicator({
className,
...props
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
return (
<NavigationMenuPrimitive.Indicator
data-slot="navigation-menu-indicator"
className={cn(
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
className
)}
{...props}
>
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
</NavigationMenuPrimitive.Indicator>
)
}
export {
NavigationMenu,
NavigationMenuList,
NavigationMenuItem,
NavigationMenuContent,
NavigationMenuTrigger,
NavigationMenuLink,
NavigationMenuIndicator,
NavigationMenuViewport,
navigationMenuTriggerStyle,
}
+127
View File
@@ -0,0 +1,127 @@
import * as React from "react"
import {
ChevronLeftIcon,
ChevronRightIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { cn } from "@/lib/utils"
import { buttonVariants, type Button } from "@/components/ui/button"
function Pagination({ className, ...props }: React.ComponentProps<"nav">) {
return (
<nav
role="navigation"
aria-label="pagination"
data-slot="pagination"
className={cn("mx-auto flex w-full justify-center", className)}
{...props}
/>
)
}
function PaginationContent({
className,
...props
}: React.ComponentProps<"ul">) {
return (
<ul
data-slot="pagination-content"
className={cn("flex flex-row items-center gap-1", className)}
{...props}
/>
)
}
function PaginationItem({ ...props }: React.ComponentProps<"li">) {
return <li data-slot="pagination-item" {...props} />
}
type PaginationLinkProps = {
isActive?: boolean
} & Pick<React.ComponentProps<typeof Button>, "size"> &
React.ComponentProps<"a">
function PaginationLink({
className,
isActive,
size = "icon",
...props
}: PaginationLinkProps) {
return (
<a
aria-current={isActive ? "page" : undefined}
data-slot="pagination-link"
data-active={isActive}
className={cn(
buttonVariants({
variant: isActive ? "outline" : "ghost",
size,
}),
className
)}
{...props}
/>
)
}
function PaginationPrevious({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to previous page"
size="default"
className={cn("gap-1 px-2.5 sm:pl-2.5", className)}
{...props}
>
<ChevronLeftIcon />
<span className="hidden sm:block">Previous</span>
</PaginationLink>
)
}
function PaginationNext({
className,
...props
}: React.ComponentProps<typeof PaginationLink>) {
return (
<PaginationLink
aria-label="Go to next page"
size="default"
className={cn("gap-1 px-2.5 sm:pr-2.5", className)}
{...props}
>
<span className="hidden sm:block">Next</span>
<ChevronRightIcon />
</PaginationLink>
)
}
function PaginationEllipsis({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
aria-hidden
data-slot="pagination-ellipsis"
className={cn("flex size-9 items-center justify-center", className)}
{...props}
>
<MoreHorizontalIcon className="size-4" />
<span className="sr-only">More pages</span>
</span>
)
}
export {
Pagination,
PaginationContent,
PaginationLink,
PaginationItem,
PaginationPrevious,
PaginationNext,
PaginationEllipsis,
}
+48
View File
@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }
+29
View File
@@ -0,0 +1,29 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
function Progress({
className,
value,
...props
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
return (
<ProgressPrimitive.Root
data-slot="progress"
className={cn(
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
data-slot="progress-indicator"
className="bg-primary h-full w-full flex-1 transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
)
}
export { Progress }
@@ -0,0 +1,45 @@
"use client"
import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { CircleIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function RadioGroup({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
return (
<RadioGroupPrimitive.Root
data-slot="radio-group"
className={cn("grid gap-3", className)}
{...props}
/>
)
}
function RadioGroupItem({
className,
...props
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
return (
<RadioGroupPrimitive.Item
data-slot="radio-group-item"
className={cn(
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<RadioGroupPrimitive.Indicator
data-slot="radio-group-indicator"
className="relative flex items-center justify-center"
>
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
</RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item>
)
}
export { RadioGroup, RadioGroupItem }
+54
View File
@@ -0,0 +1,54 @@
import * as React from "react"
import { GripVerticalIcon } from "lucide-react"
import * as ResizablePrimitive from "react-resizable-panels"
import { cn } from "@/lib/utils"
function ResizablePanelGroup({
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Group>) {
return (
<ResizablePrimitive.Group
data-slot="resizable-panel-group"
className={cn(
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
className
)}
{...props}
/>
)
}
function ResizablePanel({
...props
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
}
function ResizableHandle({
withHandle,
className,
...props
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
withHandle?: boolean
}) {
return (
<ResizablePrimitive.Separator
data-slot="resizable-handle"
className={cn(
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
className
)}
{...props}
>
{withHandle && (
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
<GripVerticalIcon className="size-2.5" />
</div>
)}
</ResizablePrimitive.Separator>
)
}
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
@@ -0,0 +1,58 @@
"use client"
import * as React from "react"
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
import { cn } from "@/lib/utils"
function ScrollArea({
className,
children,
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
return (
<ScrollAreaPrimitive.Root
data-slot="scroll-area"
className={cn("relative", className)}
{...props}
>
<ScrollAreaPrimitive.Viewport
data-slot="scroll-area-viewport"
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
>
{children}
</ScrollAreaPrimitive.Viewport>
<ScrollBar />
<ScrollAreaPrimitive.Corner />
</ScrollAreaPrimitive.Root>
)
}
function ScrollBar({
className,
orientation = "vertical",
...props
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
return (
<ScrollAreaPrimitive.ScrollAreaScrollbar
data-slot="scroll-area-scrollbar"
orientation={orientation}
className={cn(
"flex touch-none p-px transition-colors select-none",
orientation === "vertical" &&
"h-full w-2.5 border-l border-l-transparent",
orientation === "horizontal" &&
"h-2.5 flex-col border-t border-t-transparent",
className
)}
{...props}
>
<ScrollAreaPrimitive.ScrollAreaThumb
data-slot="scroll-area-thumb"
className="bg-border relative flex-1 rounded-full"
/>
</ScrollAreaPrimitive.ScrollAreaScrollbar>
)
}
export { ScrollArea, ScrollBar }
+188
View File
@@ -0,0 +1,188 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+28
View File
@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"bg-border shrink-0 data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }
+137
View File
@@ -0,0 +1,137 @@
import * as React from "react"
import * as SheetPrimitive from "@radix-ui/react-dialog"
import { XIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
side === "right" &&
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
side === "left" &&
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
side === "top" &&
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
side === "bottom" &&
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
className
)}
{...props}
>
{children}
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("text-foreground font-semibold", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}
+726
View File
@@ -0,0 +1,726 @@
"use client"
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { PanelLeftIcon } from "lucide-react"
import { useIsMobile } from "@/hooks/use-mobile"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Separator } from "@/components/ui/separator"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { Skeleton } from "@/components/ui/skeleton"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
type SidebarContextProps = {
state: "expanded" | "collapsed"
open: boolean
setOpen: (open: boolean) => void
openMobile: boolean
setOpenMobile: (open: boolean) => void
isMobile: boolean
toggleSidebar: () => void
}
const SidebarContext = React.createContext<SidebarContextProps | null>(null)
function useSidebar() {
const context = React.useContext(SidebarContext)
if (!context) {
throw new Error("useSidebar must be used within a SidebarProvider.")
}
return context
}
function SidebarProvider({
defaultOpen = true,
open: openProp,
onOpenChange: setOpenProp,
className,
style,
children,
...props
}: React.ComponentProps<"div"> & {
defaultOpen?: boolean
open?: boolean
onOpenChange?: (open: boolean) => void
}) {
const isMobile = useIsMobile()
const [openMobile, setOpenMobile] = React.useState(false)
// This is the internal state of the sidebar.
// We use openProp and setOpenProp for control from outside the component.
const [_open, _setOpen] = React.useState(defaultOpen)
const open = openProp ?? _open
const setOpen = React.useCallback(
(value: boolean | ((value: boolean) => boolean)) => {
const openState = typeof value === "function" ? value(open) : value
if (setOpenProp) {
setOpenProp(openState)
} else {
_setOpen(openState)
}
// This sets the cookie to keep the sidebar state.
document.cookie = `${SIDEBAR_COOKIE_NAME}=${openState}; path=/; max-age=${SIDEBAR_COOKIE_MAX_AGE}`
},
[setOpenProp, open]
)
// Helper to toggle the sidebar.
const toggleSidebar = React.useCallback(() => {
return isMobile ? setOpenMobile((open) => !open) : setOpen((open) => !open)
}, [isMobile, setOpen, setOpenMobile])
// Adds a keyboard shortcut to toggle the sidebar.
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
event.key === SIDEBAR_KEYBOARD_SHORTCUT &&
(event.metaKey || event.ctrlKey)
) {
event.preventDefault()
toggleSidebar()
}
}
window.addEventListener("keydown", handleKeyDown)
return () => window.removeEventListener("keydown", handleKeyDown)
}, [toggleSidebar])
// We add a state so that we can do data-state="expanded" or "collapsed".
// This makes it easier to style the sidebar with Tailwind classes.
const state = open ? "expanded" : "collapsed"
const contextValue = React.useMemo<SidebarContextProps>(
() => ({
state,
open,
setOpen,
isMobile,
openMobile,
setOpenMobile,
toggleSidebar,
}),
[state, open, setOpen, isMobile, openMobile, setOpenMobile, toggleSidebar]
)
return (
<SidebarContext.Provider value={contextValue}>
<TooltipProvider delayDuration={0}>
<div
data-slot="sidebar-wrapper"
style={
{
"--sidebar-width": SIDEBAR_WIDTH,
"--sidebar-width-icon": SIDEBAR_WIDTH_ICON,
...style,
} as React.CSSProperties
}
className={cn(
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
className
)}
{...props}
>
{children}
</div>
</TooltipProvider>
</SidebarContext.Provider>
)
}
function Sidebar({
side = "left",
variant = "sidebar",
collapsible = "offcanvas",
className,
children,
...props
}: React.ComponentProps<"div"> & {
side?: "left" | "right"
variant?: "sidebar" | "floating" | "inset"
collapsible?: "offcanvas" | "icon" | "none"
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar()
if (collapsible === "none") {
return (
<div
data-slot="sidebar"
className={cn(
"bg-sidebar text-sidebar-foreground flex h-full w-(--sidebar-width) flex-col",
className
)}
{...props}
>
{children}
</div>
)
}
if (isMobile) {
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
} as React.CSSProperties
}
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
)
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)"
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l",
className
)}
{...props}
>
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
)
}
function SidebarTrigger({
className,
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
return (
<Button
data-sidebar="trigger"
data-slot="sidebar-trigger"
variant="ghost"
size="icon"
className={cn("size-7", className)}
onClick={(event) => {
onClick?.(event)
toggleSidebar()
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
</Button>
)
}
function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
const { toggleSidebar } = useSidebar()
return (
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
className={cn(
"hover:after:bg-sidebar-border absolute inset-y-0 z-20 hidden w-4 -translate-x-1/2 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:left-1/2 after:w-[2px] sm:flex",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
"[[data-side=left][data-state=collapsed]_&]:cursor-e-resize [[data-side=right][data-state=collapsed]_&]:cursor-w-resize",
"hover:group-data-[collapsible=offcanvas]:bg-sidebar group-data-[collapsible=offcanvas]:translate-x-0 group-data-[collapsible=offcanvas]:after:left-full",
"[[data-side=left][data-collapsible=offcanvas]_&]:-right-2",
"[[data-side=right][data-collapsible=offcanvas]_&]:-left-2",
className
)}
{...props}
/>
)
}
function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
return (
<main
data-slot="sidebar-inset"
className={cn(
"bg-background relative flex w-full flex-1 flex-col",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
className
)}
{...props}
/>
)
}
function SidebarInput({
className,
...props
}: React.ComponentProps<typeof Input>) {
return (
<Input
data-slot="sidebar-input"
data-sidebar="input"
className={cn("bg-background h-8 w-full shadow-none", className)}
{...props}
/>
)
}
function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-header"
data-sidebar="header"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-footer"
data-sidebar="footer"
className={cn("flex flex-col gap-2 p-2", className)}
{...props}
/>
)
}
function SidebarSeparator({
className,
...props
}: React.ComponentProps<typeof Separator>) {
return (
<Separator
data-slot="sidebar-separator"
data-sidebar="separator"
className={cn("bg-sidebar-border mx-2 w-auto", className)}
{...props}
/>
)
}
function SidebarContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-content"
data-sidebar="content"
className={cn(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto group-data-[collapsible=icon]:overflow-hidden",
className
)}
{...props}
/>
)
}
function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group"
data-sidebar="group"
className={cn("relative flex w-full min-w-0 flex-col p-2", className)}
{...props}
/>
)
}
function SidebarGroupLabel({
className,
asChild = false,
...props
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "div"
return (
<Comp
data-slot="sidebar-group-label"
data-sidebar="group-label"
className={cn(
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarGroupAction({
className,
asChild = false,
...props
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-group-action"
data-sidebar="group-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarGroupContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-group-content"
data-sidebar="group-content"
className={cn("w-full text-sm", className)}
{...props}
/>
)
}
function SidebarMenu({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu"
data-sidebar="menu"
className={cn("flex w-full min-w-0 flex-col gap-1", className)}
{...props}
/>
)
}
function SidebarMenuItem({ className, ...props }: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-item"
data-sidebar="menu-item"
className={cn("group/menu-item relative", className)}
{...props}
/>
)
}
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md p-2 text-left text-sm outline-hidden ring-sidebar-ring transition-[width,height,padding] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 group-has-data-[sidebar=menu-action]/menu-item:pr-8 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:size-8! group-data-[collapsible=icon]:p-2! [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
{
variants: {
variant: {
default: "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
outline:
"bg-background shadow-[0_0_0_1px_hsl(var(--sidebar-border))] hover:bg-sidebar-accent hover:text-sidebar-accent-foreground hover:shadow-[0_0_0_1px_hsl(var(--sidebar-accent))]",
},
size: {
default: "h-8 text-sm",
sm: "h-7 text-xs",
lg: "h-12 text-sm group-data-[collapsible=icon]:p-0!",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function SidebarMenuButton({
asChild = false,
isActive = false,
variant = "default",
size = "default",
tooltip,
className,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
isActive?: boolean
tooltip?: string | React.ComponentProps<typeof TooltipContent>
} & VariantProps<typeof sidebarMenuButtonVariants>) {
const Comp = asChild ? Slot : "button"
const { isMobile, state } = useSidebar()
const button = (
<Comp
data-slot="sidebar-menu-button"
data-sidebar="menu-button"
data-size={size}
data-active={isActive}
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
{...props}
/>
)
if (!tooltip) {
return button
}
if (typeof tooltip === "string") {
tooltip = {
children: tooltip,
}
}
return (
<Tooltip>
<TooltipTrigger asChild>{button}</TooltipTrigger>
<TooltipContent
side="right"
align="center"
hidden={state !== "collapsed" || isMobile}
{...tooltip}
/>
</Tooltip>
)
}
function SidebarMenuAction({
className,
asChild = false,
showOnHover = false,
...props
}: React.ComponentProps<"button"> & {
asChild?: boolean
showOnHover?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="sidebar-menu-action"
data-sidebar="menu-action"
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
// Increases the hit area of the button on mobile.
"after:absolute after:-inset-2 md:after:hidden",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
showOnHover &&
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
className
)}
{...props}
/>
)
}
function SidebarMenuBadge({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="sidebar-menu-badge"
data-sidebar="menu-badge"
className={cn(
"text-sidebar-foreground pointer-events-none absolute right-1 flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums select-none",
"peer-hover/menu-button:text-sidebar-accent-foreground peer-data-[active=true]/menu-button:text-sidebar-accent-foreground",
"peer-data-[size=sm]/menu-button:top-1",
"peer-data-[size=default]/menu-button:top-1.5",
"peer-data-[size=lg]/menu-button:top-2.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSkeleton({
className,
showIcon = false,
...props
}: React.ComponentProps<"div"> & {
showIcon?: boolean
}) {
// Random width between 50 to 90%.
const width = React.useMemo(() => {
return `${Math.floor(Math.random() * 40) + 50}%`
}, [])
return (
<div
data-slot="sidebar-menu-skeleton"
data-sidebar="menu-skeleton"
className={cn("flex h-8 items-center gap-2 rounded-md px-2", className)}
{...props}
>
{showIcon && (
<Skeleton
className="size-4 rounded-md"
data-sidebar="menu-skeleton-icon"
/>
)}
<Skeleton
className="h-4 max-w-(--skeleton-width) flex-1"
data-sidebar="menu-skeleton-text"
style={
{
"--skeleton-width": width,
} as React.CSSProperties
}
/>
</div>
)
}
function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) {
return (
<ul
data-slot="sidebar-menu-sub"
data-sidebar="menu-sub"
className={cn(
"border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
function SidebarMenuSubItem({
className,
...props
}: React.ComponentProps<"li">) {
return (
<li
data-slot="sidebar-menu-sub-item"
data-sidebar="menu-sub-item"
className={cn("group/menu-sub-item relative", className)}
{...props}
/>
)
}
function SidebarMenuSubButton({
asChild = false,
size = "md",
isActive = false,
className,
...props
}: React.ComponentProps<"a"> & {
asChild?: boolean
size?: "sm" | "md"
isActive?: boolean
}) {
const Comp = asChild ? Slot : "a"
return (
<Comp
data-slot="sidebar-menu-sub-button"
data-sidebar="menu-sub-button"
data-size={size}
data-active={isActive}
className={cn(
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
size === "sm" && "text-xs",
size === "md" && "text-sm",
"group-data-[collapsible=icon]:hidden",
className
)}
{...props}
/>
)
}
export {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupAction,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarInput,
SidebarInset,
SidebarMenu,
SidebarMenuAction,
SidebarMenuBadge,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSkeleton,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
SidebarProvider,
SidebarRail,
SidebarSeparator,
SidebarTrigger,
useSidebar,
}
+13
View File
@@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("bg-accent animate-pulse rounded-md", className)}
{...props}
/>
)
}
export { Skeleton }
+63
View File
@@ -0,0 +1,63 @@
"use client"
import * as React from "react"
import * as SliderPrimitive from "@radix-ui/react-slider"
import { cn } from "@/lib/utils"
function Slider({
className,
defaultValue,
value,
min = 0,
max = 100,
...props
}: React.ComponentProps<typeof SliderPrimitive.Root>) {
const _values = React.useMemo(
() =>
Array.isArray(value)
? value
: Array.isArray(defaultValue)
? defaultValue
: [min, max],
[value, defaultValue, min, max]
)
return (
<SliderPrimitive.Root
data-slot="slider"
defaultValue={defaultValue}
value={value}
min={min}
max={max}
className={cn(
"relative flex w-full touch-none items-center select-none data-[disabled]:opacity-50 data-[orientation=vertical]:h-full data-[orientation=vertical]:min-h-44 data-[orientation=vertical]:w-auto data-[orientation=vertical]:flex-col",
className
)}
{...props}
>
<SliderPrimitive.Track
data-slot="slider-track"
className={cn(
"bg-muted relative grow overflow-hidden rounded-full data-[orientation=horizontal]:h-1.5 data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-1.5"
)}
>
<SliderPrimitive.Range
data-slot="slider-range"
className={cn(
"bg-primary absolute data-[orientation=horizontal]:h-full data-[orientation=vertical]:w-full"
)}
/>
</SliderPrimitive.Track>
{Array.from({ length: _values.length }, (_, index) => (
<SliderPrimitive.Thumb
data-slot="slider-thumb"
key={index}
className="border-primary ring-ring/50 block size-4 shrink-0 rounded-full border bg-white shadow-sm transition-[color,box-shadow] hover:ring-4 focus-visible:ring-4 focus-visible:outline-hidden disabled:pointer-events-none disabled:opacity-50"
/>
))}
</SliderPrimitive.Root>
)
}
export { Slider }
+35
View File
@@ -0,0 +1,35 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
return (
<Sonner
theme="light"
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }
+16
View File
@@ -0,0 +1,16 @@
import { Loader2Icon } from "lucide-react"
import { cn } from "@/lib/utils"
function Spinner({ className, ...props }: React.ComponentProps<"svg">) {
return (
<Loader2Icon
role="status"
aria-label="Loading"
className={cn("size-4 animate-spin", className)}
{...props}
/>
)
}
export { Spinner }
+31
View File
@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({ className, ...props }: React.ComponentProps<"table">) {
return (
<div
data-slot="table-container"
className="relative w-full overflow-x-auto"
>
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"bg-muted/50 border-t font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors",
className
)}
{...props}
/>
)
}
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
...props
}: React.ComponentProps<"caption">) {
return (
<caption
data-slot="table-caption"
className={cn("text-muted-foreground mt-4 text-sm", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+66
View File
@@ -0,0 +1,66 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
function Tabs({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Root>) {
return (
<TabsPrimitive.Root
data-slot="tabs"
className={cn("flex flex-col gap-2", className)}
{...props}
/>
)
}
function TabsList({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
className={cn(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
className
)}
{...props}
/>
)
}
function TabsTrigger({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function TabsContent({
className,
...props
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 outline-none", className)}
{...props}
/>
)
}
export { Tabs, TabsList, TabsTrigger, TabsContent }
+18
View File
@@ -0,0 +1,18 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
return (
<textarea
data-slot="textarea"
className={cn(
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
{...props}
/>
)
}
export { Textarea }
@@ -0,0 +1,81 @@
import * as React from "react"
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { toggleVariants } from "@/components/ui/toggle"
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & {
spacing?: number
}
>({
size: "default",
variant: "default",
spacing: 0,
})
function ToggleGroup({
className,
variant,
size,
spacing = 0,
children,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
spacing?: number
}) {
return (
<ToggleGroupPrimitive.Root
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
style={{ "--gap": spacing } as React.CSSProperties}
className={cn(
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
className
)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
function ToggleGroupItem({
className,
children,
variant,
size,
...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>) {
const context = React.useContext(ToggleGroupContext)
return (
<ToggleGroupPrimitive.Item
data-slot="toggle-group-item"
data-variant={context.variant || variant}
data-size={context.size || size}
data-spacing={context.spacing}
className={cn(
toggleVariants({
variant: context.variant || variant,
size: context.size || size,
}),
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
className
)}
{...props}
>
{children}
</ToggleGroupPrimitive.Item>
)
}
export { ToggleGroup, ToggleGroupItem }
+45
View File
@@ -0,0 +1,45 @@
import * as React from "react"
import * as TogglePrimitive from "@radix-ui/react-toggle"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const toggleVariants = cva(
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium hover:bg-muted hover:text-muted-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 [&_svg]:shrink-0 focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] outline-none transition-[color,box-shadow] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive whitespace-nowrap",
{
variants: {
variant: {
default: "bg-transparent",
outline:
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
},
size: {
default: "h-9 px-2 min-w-9",
sm: "h-8 px-1.5 min-w-8",
lg: "h-10 px-2.5 min-w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Toggle({
className,
variant,
size,
...props
}: React.ComponentProps<typeof TogglePrimitive.Root> &
VariantProps<typeof toggleVariants>) {
return (
<TogglePrimitive.Root
data-slot="toggle"
className={cn(toggleVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Toggle, toggleVariants }
+61
View File
@@ -0,0 +1,61 @@
"use client"
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return (
<TooltipProvider>
<TooltipPrimitive.Root data-slot="tooltip" {...props} />
</TooltipProvider>
)
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"bg-foreground text-background animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-foreground fill-foreground z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
+19
View File
@@ -0,0 +1,19 @@
import * as React from "react"
const MOBILE_BREAKPOINT = 768
export function useIsMobile() {
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
React.useEffect(() => {
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
const onChange = () => {
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
}
mql.addEventListener("change", onChange)
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
return () => mql.removeEventListener("change", onChange)
}, [])
return !!isMobile
}
+49
View File
@@ -0,0 +1,49 @@
import { useEffect, useState, useCallback } from 'react';
export interface LogMessage {
id?: number;
runId?: number;
stageId?: number;
level?: string;
message?: string;
source?: string;
timestamp?: string;
}
/**
* 订阅一次运行的实时日志(SSE)。
* runId 为后端生成的 pr-<timestamp>;不存在时连接会失败并保持 disconnected。
*/
export function usePipelineLogs(runId: string | null, enabled: boolean) {
const [logs, setLogs] = useState<LogMessage[]>([]);
const [connected, setConnected] = useState(false);
const clearLogs = useCallback(() => setLogs([]), []);
useEffect(() => {
if (!enabled || !runId) {
setConnected(false);
return;
}
const es = new EventSource(`/api/runs/${runId}/logs/stream`);
es.addEventListener('connected', () => setConnected(true));
es.addEventListener('log', (evt) => {
try {
const entry: LogMessage = JSON.parse((evt as MessageEvent).data);
setLogs((prev) => [...prev, entry]);
} catch {
// ignore invalid JSON
}
});
es.onerror = () => setConnected(false);
return () => {
es.close();
setConnected(false);
};
}, [runId, enabled]);
return { logs, connected, clearLogs };
}
+353
View File
@@ -0,0 +1,353 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 240 10% 3.9%;
--card: 0 0% 100%;
--card-foreground: 240 10% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 240 10% 3.9%;
--primary: 240 5.9% 10%;
--primary-foreground: 0 0% 98%;
--secondary: 240 4.8% 95.9%;
--secondary-foreground: 240 5.9% 10%;
--muted: 240 4.8% 95.9%;
--muted-foreground: 240 3.8% 46.1%;
--accent: 240 4.8% 95.9%;
--accent-foreground: 240 5.9% 10%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 240 5.9% 90%;
--input: 240 5.9% 90%;
--ring: 240 5.9% 10%;
--radius: 0.5rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%;
--sidebar-primary: 240 5.9% 10%;
--sidebar-primary-foreground: 0 0% 98%;
--sidebar-accent: 240 4.8% 95.9%;
--sidebar-accent-foreground: 240 5.9% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217.2 91.2% 59.8%;
--canvas-bg: #f0f2f5;
--panel-bg: #ffffff;
--panel-bg-secondary: #f8f9fa;
--border-color: #d0d7de;
--border-color-light: #e1e4e8;
--text-primary: #1f2328;
--text-secondary: #656d76;
--text-tertiary: #8c959f;
--accent-blue: #0969da;
--accent-blue-light: #ddf4ff;
--accent-green: #1a7f37;
--accent-red: #cf222e;
--accent-amber: #9a6700;
--accent-purple: #8250df;
--node-source: #0969da;
--node-build: #9a6700;
--node-deploy: #1a7f37;
--node-test: #8250df;
--node-approval: #bc4c00;
--node-custom: #6e7781;
--line-active: #0969da;
--line-success: #1a7f37;
--line-pending: #d0d7de;
}
.dark {
--background: 240 10% 3.9%;
--foreground: 0 0% 98%;
--card: 240 10% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 240 10% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 240 5.9% 10%;
--secondary: 240 3.7% 15.9%;
--secondary-foreground: 0 0% 98%;
--muted: 240 3.7% 15.9%;
--muted-foreground: 240 5% 64.9%;
--accent: 240 3.7% 15.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 240 3.7% 15.9%;
--input: 240 3.7% 15.9%;
--ring: 240 4.9% 83.9%;
--canvas-bg: #0d1117;
--panel-bg: #161b22;
--panel-bg-secondary: #21262d;
--border-color: #30363d;
--border-color-light: #21262d;
--text-primary: #c9d1d9;
--text-secondary: #8b949e;
--text-tertiary: #6e7681;
--accent-blue: #58a6ff;
--accent-blue-light: #388bfd;
--accent-green: #3fb950;
--accent-red: #f85149;
--accent-amber: #d29922;
--accent-purple: #bc8cff;
--node-source: #58a6ff;
--node-build: #d29922;
--node-deploy: #3fb950;
--node-test: #bc8cff;
--node-approval: #f0883e;
--node-custom: #8b949e;
--line-active: #58a6ff;
--line-success: #3fb950;
--line-pending: #30363d;
}
body {
@apply bg-background text-foreground;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow: hidden;
}
}
/* Dot grid background */
.dot-grid-bg {
background-color: var(--canvas-bg);
background-image: radial-gradient(circle, #d0d7de 1px, transparent 1px);
background-size: 20px 20px;
}
.dark .dot-grid-bg {
background-color: var(--canvas-bg);
background-image: radial-gradient(circle, #30363d 1px, transparent 1px);
background-size: 20px 20px;
}
/* Animated flowing line */
.flowing-line {
stroke-dasharray: 8 4;
animation: flowLine 0.8s linear infinite;
}
@keyframes flowLine {
0% {
stroke-dashoffset: 0;
}
100% {
stroke-dashoffset: -24;
}
}
/* Running status pulse */
.running-pulse {
animation: runningPulse 1.5s ease-in-out infinite;
}
@keyframes runningPulse {
0%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.6);
opacity: 0.5;
}
}
/* Glow effect for active connections */
.glow-line {
filter: drop-shadow(0 0 2px rgba(9, 105, 218, 0.4));
}
.dark .glow-line {
filter: drop-shadow(0 0 3px rgba(88, 166, 255, 0.5));
}
/* Node hover lift - subtle */
.node-card {
transition: box-shadow 0.15s ease, transform 0.15s ease;
}
.node-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
}
/* Scrollbar styling */
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #d0d7de;
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #8c959f;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
background: #30363d;
}
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #484f58;
}
/* Minimap styling */
.minimap-bg {
background: rgba(255, 255, 255, 0.95);
border: 1px solid var(--border-color);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.dark .minimap-bg {
background: rgba(22, 27, 34, 0.95);
border-color: var(--border-color);
}
/* Tab indicator animation */
.tab-indicator {
transition: left 0.3s ease, width 0.3s ease;
}
/* Draggable item ghost */
.dragging-ghost {
opacity: 0.8;
transform: rotate(2deg);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
}
/* Panel transitions */
.panel-transition {
transition: transform 0.3s ease, width 0.3s ease;
}
/* Flow particle on line */
@keyframes flowParticle {
0% {
offset-distance: 0%;
opacity: 0;
}
10% {
opacity: 1;
}
90% {
opacity: 1;
}
100% {
offset-distance: 100%;
opacity: 0;
}
}
.flow-particle {
animation: flowParticle 2s linear infinite;
offset-path: path('M0,0 C50,0 50,50 100,50');
}
/* Node selection ring - subtle */
@keyframes selectionRing {
0% {
box-shadow: 0 0 0 0px rgba(9, 105, 218, 0.25);
}
50% {
box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.15);
}
100% {
box-shadow: 0 0 0 0px rgba(9, 105, 218, 0.25);
}
}
.selection-ring {
animation: selectionRing 2s ease-in-out infinite;
box-shadow: 0 0 0 1.5px var(--accent-blue);
}
/* Button hover effects */
.btn-primary {
background: var(--accent-blue);
color: white;
transition: opacity 0.15s ease;
}
.btn-primary:hover {
opacity: 0.9;
}
/* Responsive panel collapse */
@media (max-width: 1280px) {
.left-panel {
width: 48px !important;
}
}
/* Tab content fade-in animation */
.tab-content-fade {
animation: tabContentFadeIn 200ms ease-out;
}
@keyframes tabContentFadeIn {
0% {
opacity: 0;
transform: translateY(4px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
/* Focus ring for inputs - thinner */
input:focus,
textarea:focus {
outline: none;
ring: 1px;
ring-color: rgba(9, 105, 218, 0.25);
}
/* Script editor styling */
.script-editor {
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
}
/* Context menu styling */
.context-menu {
background: var(--panel-bg);
border: 1px solid var(--border-color);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
border-radius: 6px;
padding: 4px;
min-width: 160px;
}
.context-menu-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
font-size: 13px;
cursor: pointer;
transition: background 0.1s ease;
}
.context-menu-item:hover {
background: var(--panel-bg-secondary);
}
.context-menu-item-danger {
color: var(--accent-red);
}
.context-menu-item-danger:hover {
background: rgba(207, 34, 46, 0.08);
}
+95
View File
@@ -0,0 +1,95 @@
// FlowStack gin 后端 REST 客户端
export class ApiError extends Error {
status: number
constructor(message: string, status: number) {
super(message)
this.status = status
}
}
export async function api<T = any>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, {
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
...init,
})
if (!res.ok) {
let message = `请求失败 (HTTP ${res.status})`
try {
const body = await res.json()
if (body?.error) message = body.error
} catch {
// 非 JSON 响应体
}
throw new ApiError(message, res.status)
}
return res.json()
}
// ---- 类型定义(与后端 model 对齐) ----
export interface PipelineNodeDTO {
dbId?: number
pipelineId?: number
nodeId: string
type: string
name: string
positionX: number
positionY: number
width: number
height: number
category: string
config?: Record<string, any>
}
export interface PipelineEdgeDTO {
dbId?: number
pipelineId?: number
edgeId: string
source: string
target: string
label?: string
animated?: boolean
}
export interface PipelineDetail {
pipeline: any
nodes: PipelineNodeDTO[]
edges: PipelineEdgeDTO[]
}
export interface PipelineRunDTO {
id: number
runId: string
pipelineId: number
pipelineName: string
branch: string
env: string
status: 'pending' | 'running' | 'success' | 'failed' | 'cancelled'
durationMs: number
startedAt: string
completedAt?: string
}
export const apiClient = {
// 流水线
listPipelines: () => api<any[]>('/api/pipelines'),
getPipeline: (id: number | string) => api<PipelineDetail>(`/api/pipelines/${id}`),
createPipeline: (body: any) => api('/api/pipelines', { method: 'POST', body: JSON.stringify(body) }),
updatePipeline: (id: number | string, body: any) => api(`/api/pipelines/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
deletePipeline: (id: number | string) => api(`/api/pipelines/${id}`, { method: 'DELETE' }),
saveNodes: (id: number | string, nodes: PipelineNodeDTO[]) =>
api(`/api/pipelines/${id}/nodes`, { method: 'PUT', body: JSON.stringify({ nodes }) }),
saveEdges: (id: number | string, edges: PipelineEdgeDTO[]) =>
api(`/api/pipelines/${id}/edges`, { method: 'PUT', body: JSON.stringify({ edges }) }),
// 运行
createRun: (pipelineId: number | string, body?: any) =>
api<PipelineRunDTO>(`/api/pipelines/${pipelineId}/runs`, { method: 'POST', body: JSON.stringify(body || {}) }),
listRuns: (params?: { pipelineId?: number | string; status?: string }) => {
const q = new URLSearchParams()
if (params?.pipelineId) q.set('pipelineId', String(params.pipelineId))
if (params?.status && params.status !== 'all') q.set('status', params.status)
const qs = q.toString()
return api<PipelineRunDTO[]>(`/api/runs${qs ? `?${qs}` : ''}`)
},
getRun: (runId: string) => api<any>(`/api/runs/${runId}`),
}
+6
View File
@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
+15
View File
@@ -0,0 +1,15 @@
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router'
import { Toaster } from '@/components/ui/sonner'
import './index.css'
import { QueryProvider } from "@/providers/query"
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<BrowserRouter>
<QueryProvider>
<App />
<Toaster position="top-right" />
</QueryProvider>
</BrowserRouter>,
)
+250
View File
@@ -0,0 +1,250 @@
import { useState } from 'react';
import {
ChevronRight, CheckCircle2, Circle, GitBranch, Server,
Play, Loader2, Terminal, ArrowRight, CloudCog, Package, AlertCircle
} from 'lucide-react';
import { toast } from 'sonner';
interface PipelineItem {
id: string; name: string; branch: string; type: 'native' | 'jenkins';
deployType: 'host' | 'k8s'; env: string; lastStatus: string;
}
const mockPipelines: PipelineItem[] = [
{ id: '1', name: 'aiops-backend', branch: 'master', type: 'native', deployType: 'k8s', env: 'production', lastStatus: 'success' },
{ id: '2', name: 'aiops-web', branch: 'master', type: 'native', deployType: 'host', env: 'production', lastStatus: 'success' },
{ id: '3', name: 'gateway-api', branch: 'release/v2.1', type: 'jenkins', deployType: 'k8s', env: 'staging', lastStatus: 'pending' },
{ id: '4', name: 'admin-dashboard', branch: 'master', type: 'native', deployType: 'host', env: 'staging', lastStatus: 'success' },
{ id: '5', name: 'data-processor', branch: 'develop', type: 'jenkins', deployType: 'k8s', env: 'test', lastStatus: 'failed' },
{ id: '6', name: 'notification-service', branch: 'master', type: 'native', deployType: 'k8s', env: 'production', lastStatus: 'success' },
];
type StepType = 'select' | 'configure' | 'confirm' | 'executing' | 'done';
const steps: { id: StepType; label: string }[] = [
{ id: 'select', label: '选择流水线' },
{ id: 'configure', label: '配置参数' },
{ id: 'confirm', label: '确认发布' },
{ id: 'executing', label: '执行中' },
{ id: 'done', label: '完成' },
];
export default function BatchRelease() {
const [currentStep, setCurrentStep] = useState<StepType>('select');
const [selectedIds, setSelectedIds] = useState<string[]>([]);
const [execLogs, setExecLogs] = useState<string[]>([]);
const [completedCount, setCompletedCount] = useState(0);
const toggleSelect = (id: string) => {
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
};
const selectedPipelines = mockPipelines.filter(p => selectedIds.includes(p.id));
const stepIndex = steps.findIndex(s => s.id === currentStep);
const startExecution = () => {
setCurrentStep('executing');
setExecLogs([`[${new Date().toLocaleTimeString()}] 批量发布开始,共 ${selectedIds.length} 个流水线`]);
let count = 0;
selectedPipelines.forEach((p, idx) => {
setTimeout(() => {
setExecLogs(prev => [
...prev,
`[${new Date().toLocaleTimeString()}] [${idx + 1}/${selectedPipelines.length}] 开始发布: ${p.name} (${p.branch})`,
`[${new Date().toLocaleTimeString()}] -> 拉取代码...`,
`[${new Date().toLocaleTimeString()}] -> 构建中...`,
`[${new Date().toLocaleTimeString()}] -> ${p.deployType === 'k8s' ? '更新 K8s Deployment...' : 'SCP 上传 + SSH 部署...'}`,
`[${new Date().toLocaleTimeString()}] ✓ ${p.name} 发布成功`,
]);
count++;
setCompletedCount(count);
if (count === selectedPipelines.length) {
setTimeout(() => {
setExecLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] 批量发布全部完成`]);
setCurrentStep('done');
}, 500);
}
}, idx * 2500);
});
};
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-3">
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>批量发布</h1>
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>向导</span>
</div>
</div>
<div className="px-5 py-3 border-b flex items-center justify-center gap-0 flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
{steps.map((step, idx) => {
const isActive = idx === stepIndex;
const isDone = idx < stepIndex;
return (
<div key={step.id} className="flex items-center">
<div className="flex items-center gap-2 px-3">
{isDone ? <CheckCircle2 className="w-4 h-4" style={{ color: 'var(--accent-green)' }} /> :
isActive ? <div className="w-4 h-4 rounded-full border-2 flex items-center justify-center" style={{ borderColor: 'var(--accent-blue)' }}><div className="w-2 h-2 rounded-full" style={{ background: 'var(--accent-blue)' }} /></div> :
<Circle className="w-4 h-4" style={{ color: 'var(--border-color)' }} />}
<span className="text-[12px] font-medium" style={{ color: isActive ? 'var(--accent-blue)' : isDone ? 'var(--text-secondary)' : 'var(--text-tertiary)' }}>{step.label}</span>
</div>
{idx < steps.length - 1 && <ChevronRight className="w-4 h-4 mx-1" style={{ color: idx < stepIndex ? 'var(--accent-green)' : 'var(--border-color)' }} />}
</div>
);
})}
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{currentStep === 'select' && (
<div className="max-w-4xl mx-auto">
<div className="mb-4 flex items-center justify-between">
<p className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>选择要批量发布的流水线(支持原生流水线和 Jenkins Job 混合发布)</p>
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个</span>
</div>
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="grid grid-cols-[40px_1fr_120px_100px_100px_80px] gap-2 px-4 py-2 text-[11px] font-semibold uppercase" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
<div></div><div>流水线</div><div>分支</div><div>类型</div><div>部署方式</div><div>环境</div>
</div>
{mockPipelines.map((p) => {
const isSelected = selectedIds.includes(p.id);
return (
<div key={p.id} onClick={() => toggleSelect(p.id)}
className="grid grid-cols-[40px_1fr_120px_100px_100px_80px] gap-2 px-4 py-2.5 border-t items-center cursor-pointer hover:bg-black/[0.02] transition-colors"
style={{ borderColor: 'var(--border-color-light)' }}>
<div><div className="w-4 h-4 rounded border flex items-center justify-center" style={{ borderColor: isSelected ? 'var(--accent-blue)' : 'var(--border-color)', background: isSelected ? 'var(--accent-blue)' : 'transparent' }}>
{isSelected && <CheckCircle2 className="w-3 h-3 text-white" />}
</div></div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{p.name}</div>
<div className="flex items-center gap-1 text-[12px]" style={{ color: 'var(--text-secondary)' }}><GitBranch className="w-3 h-3" /> {p.branch}</div>
<div><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: p.type === 'native' ? '#ddf4ff' : '#fbefff', color: p.type === 'native' ? '#0969da' : '#8250df' }}>{p.type === 'native' ? '原生' : 'Jenkins'}</span></div>
<div className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>{p.deployType === 'k8s' ? <span className="flex items-center gap-1"><CloudCog className="w-3 h-3" /> K8s</span> : <span className="flex items-center gap-1"><Server className="w-3 h-3" /> 主机</span>}</div>
<div className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{p.env}</div>
</div>
);
})}
</div>
<div className="flex justify-end mt-4">
<button onClick={() => selectedIds.length > 0 ? setCurrentStep('configure') : toast.error('请至少选择一个流水线')}
className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
下一步 <ArrowRight className="w-4 h-4" />
</button>
</div>
</div>
)}
{currentStep === 'configure' && (
<div className="max-w-3xl mx-auto space-y-4">
<p className="text-[13px] mb-3" style={{ color: 'var(--text-secondary)' }}>配置发布参数,所有选中的流水线将使用相同的发布策略</p>
<div className="rounded-lg border p-4 space-y-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="grid grid-cols-2 gap-4">
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>发布策略</label>
<select className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
<option>串行发布(逐个执行,失败即停)</option><option>并行发布(同时执行,互不影响)</option><option>分组发布(按环境分组串行)</option>
</select></div>
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>失败处理</label>
<select className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
<option>立即中止后续发布</option><option>继续发布其他流水线</option><option>标记失败并跳过</option>
</select></div>
</div>
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>K8s 就绪检查</label>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Deployment 副本就绪</span></label>
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Service 可用性</span></label>
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>等待 Pod 健康检查通过</span></label>
</div></div>
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>发布后通知</label>
<div className="flex items-center gap-4">
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>飞书通知</span></label>
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>钉钉通知</span></label>
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>Webhook</span></label>
</div></div>
</div>
<div className="rounded-lg border p-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>已选择的流水线</div>
<div className="flex items-center gap-2 flex-wrap">
{selectedPipelines.map((p) => (
<span key={p.id} className="text-[11px] px-2 py-[3px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
<Package className="w-3 h-3" />{p.name}</span>
))}
</div></div>
<div className="flex justify-between">
<button onClick={() => setCurrentStep('select')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>上一步</button>
<button onClick={() => setCurrentStep('confirm')} className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>下一步 <ArrowRight className="w-4 h-4" /></button>
</div>
</div>
)}
{currentStep === 'confirm' && (
<div className="max-w-2xl mx-auto text-center py-8">
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ background: 'var(--accent-blue-light)' }}>
<AlertCircle className="w-8 h-8" style={{ color: 'var(--accent-blue)' }} />
</div>
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>确认批量发布</h2>
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>即将发布 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个流水线,串行执行,失败即停</p>
<div className="rounded-lg border p-4 mb-6 text-left" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
{selectedPipelines.map((p, idx) => (
<div key={p.id} className="flex items-center gap-3 py-2" style={{ borderTop: idx > 0 ? '1px solid var(--border-color-light)' : 'none' }}>
<span className="text-[11px] w-5 text-center font-medium" style={{ color: 'var(--text-tertiary)' }}>{idx + 1}</span>
<GitBranch className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<span className="text-[13px] font-medium flex-1" style={{ color: 'var(--text-primary)' }}>{p.name}</span>
<span className="text-[11px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{p.branch}</span>
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: p.type === 'native' ? '#ddf4ff' : '#fbefff', color: p.type === 'native' ? '#0969da' : '#8250df' }}>{p.type === 'native' ? '原生' : 'Jenkins'}</span>
</div>
))}
</div>
<div className="flex justify-center gap-3">
<button onClick={() => setCurrentStep('configure')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>返回修改</button>
<button onClick={startExecution} className="flex items-center gap-1.5 px-5 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
<Play className="w-4 h-4" />开始发布
</button>
</div>
</div>
)}
{currentStep === 'executing' && (
<div className="max-w-3xl mx-auto">
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="px-4 py-2.5 border-b flex items-center justify-between" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin" style={{ color: 'var(--accent-blue)' }} />
<span className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>批量发布执行中</span>
</div>
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{completedCount} / {selectedIds.length} 完成</span>
</div>
<div className="p-0">
<div className="px-4 py-2 border-b" style={{ background: '#0d1117', borderColor: '#21262d' }}>
<div className="flex items-center gap-2">
<Terminal className="w-3.5 h-3.5" style={{ color: '#7d8590' }} />
<span className="text-[11px] font-medium" style={{ color: '#7d8590' }}>执行日志</span>
</div>
</div>
<div className="p-3 font-mono text-[12px] leading-relaxed max-h-[400px] overflow-y-auto" style={{ background: '#0d1117', color: '#e6edf3' }}>
{execLogs.map((log, idx) => (
<div key={idx} className="py-[1px]">{log.includes('✓') ? <span style={{ color: '#3fb950' }}>{log}</span> : log.includes('->') ? <span style={{ color: '#58a6ff' }}>{log}</span> : log}</div>
))}
<div className="py-[1px] animate-pulse"><span style={{ color: '#7d8590' }}>_</span></div>
</div>
</div>
</div>
</div>
)}
{currentStep === 'done' && (
<div className="max-w-2xl mx-auto text-center py-8">
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ background: '#dafbe1' }}>
<CheckCircle2 className="w-8 h-8" style={{ color: '#1a7f37' }} />
</div>
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>批量发布完成</h2>
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>共成功发布 <span className="font-semibold" style={{ color: 'var(--accent-green)' }}>{selectedIds.length}</span> 个流水线</p>
<div className="flex justify-center gap-3">
<button onClick={() => { setCurrentStep('select'); setSelectedIds([]); setExecLogs([]); setCompletedCount(0); }}
className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>继续发布</button>
<button onClick={() => toast.success('已跳转至发布记录')} className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>查看记录</button>
</div>
</div>
)}
</div>
</div>
);
}
+168
View File
@@ -0,0 +1,168 @@
import { useState } from 'react';
import {
Cpu,
Plus,
Search,
CheckCircle2,
XCircle,
Loader2,
Edit2,
Trash2,
Tag,
Activity
} from 'lucide-react';
import { toast } from 'sonner';
interface BuildNode {
id: string;
name: string;
ip: string;
labels: string[];
maxConcurrency: number;
currentJobs: number;
status: 'online' | 'offline' | 'busy';
cpu: string;
memory: string;
disk: string;
lastHeartbeat: string;
}
const mockNodes: BuildNode[] = [
{ id: '1', name: 'build-host-01', ip: '192.168.10.101', labels: ['docker', 'go', 'java'], maxConcurrency: 4, currentJobs: 2, status: 'busy', cpu: '45%', memory: '6.2GB/16GB', disk: '120GB/500GB', lastHeartbeat: '10秒前' },
{ id: '2', name: 'build-host-02', ip: '192.168.10.102', labels: ['docker', 'node', 'python'], maxConcurrency: 3, currentJobs: 0, status: 'online', cpu: '12%', memory: '2.1GB/8GB', disk: '80GB/300GB', lastHeartbeat: '5秒前' },
{ id: '3', name: 'build-host-03', ip: '192.168.10.103', labels: ['k8s', 'helm'], maxConcurrency: 2, currentJobs: 0, status: 'offline', cpu: '-', memory: '-', disk: '-', lastHeartbeat: '2小时前' },
];
const statusConfig = {
online: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '在线' },
offline: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '离线' },
busy: { icon: Loader2, color: '#9a6700', bg: '#fff8c5', label: '忙碌' },
};
export default function BuildNodeManager() {
const [searchQuery, setSearchQuery] = useState('');
const filtered = mockNodes.filter(n =>
n.name.includes(searchQuery) || n.ip.includes(searchQuery)
);
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
<div
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
<div className="flex items-center gap-3">
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>构建节点</h1>
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
{filtered.length}
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-[6px] rounded-md border" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索节点..." className="bg-transparent outline-none w-32 text-[13px]" style={{ color: 'var(--text-primary)' }} />
</div>
<button className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }} onClick={() => toast.info('添加节点', { description: '功能开发中...' })}>
<Plus className="w-3.5 h-3.5" />
添加节点
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
<div className="grid grid-cols-3 gap-3 mb-4">
{[
{ label: '在线节点', count: 2, color: '#1a7f37' },
{ label: '忙碌节点', count: 1, color: '#9a6700' },
{ label: '离线节点', count: 1, color: '#cf222e' },
].map((stat) => (
<div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
<div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div>
</div>
))}
</div>
<div className="space-y-3">
{filtered.map((node) => {
const status = statusConfig[node.status];
const StatusIcon = status.icon;
const utilization = node.status === 'offline' ? 0 : (node.currentJobs / node.maxConcurrency) * 100;
return (
<div key={node.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<Cpu className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<div>
<div className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{node.name}</div>
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{node.ip}</div>
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[11px] px-2 py-[3px] rounded-full font-medium flex items-center gap-1" style={{ background: status.bg, color: status.color }}>
<StatusIcon className={`w-3 h-3 ${node.status === 'busy' ? 'animate-spin' : ''}`} />
{status.label}
</span>
<button className="p-1 rounded hover:bg-black/5"><Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
<button className="p-1 rounded hover:bg-black/5"><Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
</div>
</div>
{node.status !== 'offline' && (
<>
{/* Concurrency bar */}
<div className="mb-3">
<div className="flex items-center justify-between text-[11px] mb-1">
<span style={{ color: 'var(--text-tertiary)' }}>并发任务</span>
<span style={{ color: 'var(--text-secondary)' }}>{node.currentJobs} / {node.maxConcurrency}</span>
</div>
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--panel-bg-secondary)' }}>
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${utilization}%`,
background: utilization > 80 ? 'var(--accent-red)' : utilization > 50 ? 'var(--accent-amber)' : 'var(--accent-green)',
}}
/>
</div>
</div>
{/* Resource metrics */}
<div className="grid grid-cols-4 gap-3 text-[11px]">
<div className="flex items-center gap-1.5">
<Activity className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
<span style={{ color: 'var(--text-secondary)' }}>CPU {node.cpu}</span>
</div>
<div className="flex items-center gap-1.5">
<span style={{ color: 'var(--text-tertiary)' }}>内存</span>
<span style={{ color: 'var(--text-secondary)' }}>{node.memory}</span>
</div>
<div className="flex items-center gap-1.5">
<span style={{ color: 'var(--text-tertiary)' }}>磁盘</span>
<span style={{ color: 'var(--text-secondary)' }}>{node.disk}</span>
</div>
<div className="flex items-center gap-1.5">
<span style={{ color: 'var(--text-tertiary)' }}>心跳</span>
<span style={{ color: 'var(--text-secondary)' }}>{node.lastHeartbeat}</span>
</div>
</div>
</>
)}
{/* Labels */}
<div className="flex items-center gap-1 mt-3 flex-wrap">
{node.labels.map((label) => (
<span key={label} className="text-[10px] px-1.5 py-[2px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
<Tag className="w-2.5 h-2.5" />
{label}
</span>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
import { useState } from 'react';
import {
Key,
Plus,
Search,
Eye,
EyeOff,
Copy,
Trash2,
Edit2,
Shield,
GitBranch,
Container
} from 'lucide-react';
import { toast } from 'sonner';
interface Credential {
id: string;
name: string;
type: 'ssh' | 'password' | 'docker' | 'git' | 'kubeconfig';
scope: string;
createdAt: string;
usageCount: number;
}
const mockCredentials: Credential[] = [
{ id: '1', name: 'production-ssh-key', type: 'ssh', scope: 'production-group', createdAt: '2024-06-01', usageCount: 12 },
{ id: '2', name: 'docker-registry-auth', type: 'docker', scope: '全局', createdAt: '2024-06-05', usageCount: 8 },
{ id: '3', name: 'gitee-token', type: 'git', scope: '全局', createdAt: '2024-06-10', usageCount: 5 },
{ id: '4', name: 'staging-ssh-key', type: 'ssh', scope: 'staging-group', createdAt: '2024-06-12', usageCount: 3 },
{ id: '5', name: 'ack-kubeconfig', type: 'kubeconfig', scope: 'ack-production', createdAt: '2024-06-15', usageCount: 6 },
{ id: '6', name: 'db-password', type: 'password', scope: 'production-group', createdAt: '2024-06-18', usageCount: 2 },
];
const typeConfig: Record<string, { icon: typeof Key; label: string; color: string; bg: string }> = {
ssh: { icon: Key, label: 'SSH密钥', color: '#0969da', bg: '#ddf4ff' },
password: { icon: Shield, label: '用户名密码', color: '#9a6700', bg: '#fff8c5' },
docker: { icon: Container, label: 'Docker认证', color: '#1a7f37', bg: '#dafbe1' },
git: { icon: GitBranch, label: 'Git凭证', color: '#8250df', bg: '#fbefff' },
kubeconfig: { icon: Key, label: 'Kubeconfig', color: '#bc4c00', bg: '#fff1e5' },
};
export default function CredentialManager() {
const [searchQuery, setSearchQuery] = useState('');
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
const filtered = mockCredentials.filter(c =>
c.name.includes(searchQuery) || c.type.includes(searchQuery)
);
const toggleSecret = (id: string) => {
setShowSecrets(prev => ({ ...prev, [id]: !prev[id] }));
};
const copyValue = (name: string) => {
toast.success('已复制', { description: `${name} 的值已复制到剪贴板` });
};
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
<div
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
<div className="flex items-center gap-3">
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>凭证管理</h1>
<span
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
>
{filtered.length}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="flex items-center gap-2 px-3 py-[6px] rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}
>
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索凭证..."
className="bg-transparent outline-none w-32 text-[13px]"
style={{ color: 'var(--text-primary)' }}
/>
</div>
<button
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
onClick={() => toast.info('新建凭证', { description: '功能开发中...' })}
>
<Plus className="w-3.5 h-3.5" />
新建凭证
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{/* Type summary */}
<div className="grid grid-cols-5 gap-3 mb-4">
{Object.entries(typeConfig).map(([type, config]) => {
const count = mockCredentials.filter(c => c.type === type).length;
const Icon = config.icon;
return (
<div key={type} className="rounded-lg border p-3 flex items-center gap-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="w-8 h-8 rounded-md flex items-center justify-center" style={{ background: config.bg }}>
<Icon className="w-4 h-4" style={{ color: config.color }} />
</div>
<div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{config.label}</div>
<div className="text-[16px] font-bold" style={{ color: 'var(--text-primary)' }}>{count}</div>
</div>
</div>
);
})}
</div>
{/* Credential list */}
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<table className="w-full">
<thead>
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
{['凭证名称', '类型', '使用范围', '使用次数', '创建时间', '值', '操作'].map((h) => (
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{filtered.map((cred) => {
const config = typeConfig[cred.type];
const Icon = config.icon;
const isRevealed = showSecrets[cred.id];
return (
<tr key={cred.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-6 h-6 rounded flex items-center justify-center" style={{ background: config.bg }}>
<Icon className="w-3 h-3" style={{ color: config.color }} />
</div>
<span className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{cred.name}</span>
</div>
</td>
<td className="px-4 py-3">
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: config.bg, color: config.color }}>
{config.label}
</span>
</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.scope}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.usageCount}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{cred.createdAt}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<code className="text-[11px] px-2 py-[3px] rounded font-mono" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
{isRevealed ? 'AKIAIOSFODNN7EXAMPLE' : '••••••••••••••••'}
</code>
<button onClick={() => toggleSecret(cred.id)} className="p-1 rounded hover:bg-black/5">
{isRevealed ? <EyeOff className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} /> : <Eye className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />}
</button>
<button onClick={() => copyValue(cred.name)} className="p-1 rounded hover:bg-black/5">
<Copy className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-0">
<button className="p-1 rounded hover:bg-black/5"><Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
<button className="p-1 rounded hover:bg-black/5"><Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
</div>
);
}
+295
View File
@@ -0,0 +1,295 @@
import { useState } from 'react';
import {
Server,
Plus,
Search,
CheckCircle2,
XCircle,
Edit2,
Trash2,
Terminal,
CloudCog,
} from 'lucide-react';
import { toast } from 'sonner';
interface Host {
id: string;
name: string;
ip: string;
port: number;
username: string;
tags: string[];
status: 'online' | 'offline';
os: string;
}
interface K8sCluster {
id: string;
name: string;
context: string;
version: string;
nodes: number;
namespaces: string[];
status: 'connected' | 'disconnected';
}
const mockHosts: Host[] = [
{ id: '1', name: 'prod-web-01', ip: '192.168.1.101', port: 22, username: 'root', tags: ['production', 'web'], status: 'online', os: 'CentOS 8' },
{ id: '2', name: 'prod-web-02', ip: '192.168.1.102', port: 22, username: 'root', tags: ['production', 'web'], status: 'online', os: 'CentOS 8' },
{ id: '3', name: 'prod-api-01', ip: '192.168.1.111', port: 22, username: 'root', tags: ['production', 'api'], status: 'online', os: 'Ubuntu 22.04' },
{ id: '4', name: 'staging-web-01', ip: '192.168.2.101', port: 22, username: 'admin', tags: ['staging', 'web'], status: 'online', os: 'Ubuntu 22.04' },
{ id: '5', name: 'staging-api-01', ip: '192.168.2.111', port: 22, username: 'admin', tags: ['staging', 'api'], status: 'offline', os: 'CentOS 7' },
];
const mockClusters: K8sCluster[] = [
{ id: '1', name: 'ack-production', context: 'ack-prod', version: 'v1.28.3', nodes: 6, namespaces: ['aiops', 'gateway', 'monitoring'], status: 'connected' },
{ id: '2', name: 'ack-staging', context: 'ack-staging', version: 'v1.27.8', nodes: 3, namespaces: ['aiops', 'default'], status: 'connected' },
{ id: '3', name: 'tke-test', context: 'tke-test', version: 'v1.26.5', nodes: 2, namespaces: ['default'], status: 'disconnected' },
];
export default function EnvironmentManager() {
const [activeTab, setActiveTab] = useState<'hosts' | 'k8s'>('hosts');
const [searchQuery, setSearchQuery] = useState('');
const filteredHosts = mockHosts.filter(h =>
h.name.includes(searchQuery) || h.ip.includes(searchQuery)
);
const filteredClusters = mockClusters.filter(c =>
c.name.includes(searchQuery) || c.context.includes(searchQuery)
);
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<div
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
<div className="flex items-center gap-3">
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>环境管理</h1>
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
<button
onClick={() => setActiveTab('hosts')}
className="px-3 py-[5px] text-[12px] font-medium transition-colors"
style={{
background: activeTab === 'hosts' ? 'var(--panel-bg-secondary)' : 'transparent',
color: activeTab === 'hosts' ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}
>
主机组
</button>
<button
onClick={() => setActiveTab('k8s')}
className="px-3 py-[5px] text-[12px] font-medium transition-colors"
style={{
background: activeTab === 'k8s' ? 'var(--panel-bg-secondary)' : 'transparent',
color: activeTab === 'k8s' ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}
>
K8s集群
</button>
</div>
</div>
<div className="flex items-center gap-2">
<div
className="flex items-center gap-2 px-3 py-[6px] rounded-md border"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}
>
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={activeTab === 'hosts' ? '搜索主机...' : '搜索集群...'}
className="bg-transparent outline-none w-32 text-[13px]"
style={{ color: 'var(--text-primary)' }}
/>
</div>
<button
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
onClick={() => toast.info(`添加${activeTab === 'hosts' ? '主机' : '集群'}`, { description: '功能开发中...' })}
>
<Plus className="w-3.5 h-3.5" />
添加{activeTab === 'hosts' ? '主机' : '集群'}
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{activeTab === 'hosts' ? (
<div className="space-y-3">
{/* Host groups summary */}
<div className="grid grid-cols-4 gap-3 mb-4">
{[
{ label: '生产环境', count: 3, online: 3, color: '#1a7f37' },
{ label: '预发环境', count: 2, online: 1, color: '#9a6700' },
{ label: '测试环境', count: 0, online: 0, color: '#8250df' },
{ label: '总计', count: 5, online: 4, color: '#0969da' },
].map((stat) => (
<div
key={stat.label}
className="rounded-lg border p-4"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
<div className="flex items-baseline gap-2">
<span className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</span>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
在线 {stat.online}
</span>
</div>
</div>
))}
</div>
{/* Host list */}
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<table className="w-full">
<thead>
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
{['主机名', 'IP地址', '端口', '用户名', '标签', '操作系统', '状态', '操作'].map((h) => (
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{filteredHosts.map((host) => (
<tr key={host.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<Server className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
<span className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{host.name}</span>
</div>
</td>
<td className="px-4 py-3 text-[12px] font-mono" style={{ color: 'var(--text-secondary)' }}>{host.ip}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.port}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.username}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1 flex-wrap">
{host.tags.map((tag) => (
<span
key={tag}
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
>
{tag}
</span>
))}
</div>
</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.os}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
{host.status === 'online' ? (
<CheckCircle2 className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
) : (
<XCircle className="w-3.5 h-3.5" style={{ color: 'var(--accent-red)' }} />
)}
<span className="text-[12px]" style={{ color: host.status === 'online' ? 'var(--accent-green)' : 'var(--accent-red)' }}>
{host.status === 'online' ? '在线' : '离线'}
</span>
</div>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-0">
<button className="p-1 rounded hover:bg-black/5" title="SSH连接" onClick={() => toast.info('SSH连接', { description: `${host.username}@${host.ip}:${host.port}` })}>
<Terminal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
<button className="p-1 rounded hover:bg-black/5" title="编辑">
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
<button className="p-1 rounded hover:bg-black/5" title="删除">
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
/* K8s Clusters */
<div className="space-y-3">
<div className="grid grid-cols-3 gap-3 mb-4">
{[
{ label: '已连接', count: 2, color: '#1a7f37' },
{ label: '未连接', count: 1, color: '#cf222e' },
{ label: '总节点数', count: 11, color: '#0969da' },
].map((stat) => (
<div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
<div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div>
</div>
))}
</div>
<div className="space-y-3">
{filteredClusters.map((cluster) => (
<div
key={cluster.id}
className="rounded-lg border p-4"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<CloudCog className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
<div>
<div className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{cluster.name}</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>Context: {cluster.context}</div>
</div>
</div>
<div className="flex items-center gap-2">
<span
className="text-[11px] px-2 py-[3px] rounded-full font-medium"
style={{
background: cluster.status === 'connected' ? '#dafbe1' : '#ffebe9',
color: cluster.status === 'connected' ? '#1a7f37' : '#cf222e',
}}
>
{cluster.status === 'connected' ? '已连接' : '未连接'}
</span>
<button className="p-1 rounded hover:bg-black/5">
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-4 text-[12px]">
<div>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>版本</div>
<div style={{ color: 'var(--text-primary)' }}>{cluster.version}</div>
</div>
<div>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>节点数</div>
<div style={{ color: 'var(--text-primary)' }}>{cluster.nodes}</div>
</div>
<div className="col-span-2">
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>命名空间</div>
<div className="flex items-center gap-1 flex-wrap">
{cluster.namespaces.map((ns) => (
<span
key={ns}
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
>
{ns}
</span>
))}
</div>
</div>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
);
}
+358
View File
@@ -0,0 +1,358 @@
import { useState } from 'react';
import {
CloudSync, GitBranch, RefreshCw, CheckCircle2, XCircle,
Clock, ChevronRight, TreePine, Diff, Layers,
Server, AlertTriangle, FolderGit, Package
} from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useMutation } from '@tanstack/react-query';
import { api } from '@/lib/api';
interface Application {
id: string;
name: string;
repo: string;
branch: string;
path: string;
cluster: string;
namespace: string;
status: 'synced' | 'out-of-sync' | 'syncing' | 'error' | 'unknown';
syncStatus: 'synced' | 'out-of-sync';
health: 'healthy' | 'degraded' | 'progressing' | 'suspended';
lastSync: string;
resources: number;
manifestType: 'helm' | 'kustomize' | 'plain';
autoSync: boolean;
}
interface ResourceNode {
id: string;
kind: string;
name: string;
namespace: string;
status: 'healthy' | 'degraded' | 'progressing' | 'suspended';
children?: ResourceNode[];
}
const mockApps: Application[] = [
{
id: 'app-1', name: 'aiops-backend', repo: 'https://gitee.com/attacker/aiops-gitops.git',
branch: 'main', path: 'apps/aiops-backend/overlays/production',
cluster: 'production-k8s', namespace: 'aiops', status: 'synced', syncStatus: 'synced',
health: 'healthy', lastSync: '2分钟前', resources: 12, manifestType: 'kustomize', autoSync: true,
},
{
id: 'app-2', name: 'aiops-web', repo: 'https://gitee.com/attacker/aiops-gitops.git',
branch: 'main', path: 'apps/aiops-web/overlays/production',
cluster: 'production-k8s', namespace: 'aiops', status: 'syncing', syncStatus: 'out-of-sync',
health: 'progressing', lastSync: '正在同步', resources: 8, manifestType: 'helm', autoSync: true,
},
{
id: 'app-3', name: 'gateway-api', repo: 'https://gitee.com/attacker/aiops-gitops.git',
branch: 'release/v2.1', path: 'apps/gateway/overlays/staging',
cluster: 'staging-k8s', namespace: 'gateway', status: 'out-of-sync', syncStatus: 'out-of-sync',
health: 'degraded', lastSync: '1小时前', resources: 6, manifestType: 'kustomize', autoSync: false,
},
{
id: 'app-4', name: 'data-processor', repo: 'https://gitee.com/attacker/aiops-gitops.git',
branch: 'main', path: 'apps/data/overlays/production',
cluster: 'production-k8s', namespace: 'data', status: 'synced', syncStatus: 'synced',
health: 'healthy', lastSync: '30分钟前', resources: 5, manifestType: 'plain', autoSync: true,
},
];
const mockResourceTree: ResourceNode[] = [
{
id: 'r1', kind: 'Deployment', name: 'aiops-backend', namespace: 'aiops', status: 'healthy',
children: [
{ id: 'r1-1', kind: 'ReplicaSet', name: 'aiops-backend-7d9f4', namespace: 'aiops', status: 'healthy',
children: [
{ id: 'r1-1-1', kind: 'Pod', name: 'aiops-backend-7d9f4-abc1', namespace: 'aiops', status: 'healthy' },
{ id: 'r1-1-2', kind: 'Pod', name: 'aiops-backend-7d9f4-abc2', namespace: 'aiops', status: 'healthy' },
{ id: 'r1-1-3', kind: 'Pod', name: 'aiops-backend-7d9f4-abc3', namespace: 'aiops', status: 'healthy' },
]
},
]
},
{
id: 'r2', kind: 'Service', name: 'aiops-backend-svc', namespace: 'aiops', status: 'healthy',
},
{
id: 'r3', kind: 'Ingress', name: 'aiops-backend-ingress', namespace: 'aiops', status: 'healthy',
},
{
id: 'r4', kind: 'ConfigMap', name: 'aiops-backend-config', namespace: 'aiops', status: 'healthy',
},
];
const statusConfig: Record<string, { color: string; bg: string; label: string; icon: typeof CheckCircle2 }> = {
synced: { color: '#1a7f37', bg: '#dafbe1', label: '已同步', icon: CheckCircle2 },
'out-of-sync': { color: '#9a6700', bg: '#fff8c5', label: '待同步', icon: Clock },
syncing: { color: '#0969da', bg: '#ddf4ff', label: '同步中', icon: RefreshCw },
error: { color: '#cf222e', bg: '#ffebe9', label: '错误', icon: XCircle },
unknown: { color: '#6e7781', bg: '#f6f8fa', label: '未知', icon: AlertTriangle },
};
const healthConfig: Record<string, { color: string; label: string; bg: string }> = {
healthy: { color: '#1a7f37', label: '健康', bg: '#dafbe1' },
degraded: { color: '#cf222e', label: '降级', bg: '#ffebe9' },
progressing: { color: '#9a6700', label: '进行中', bg: '#fff8c5' },
suspended: { color: '#6e7781', label: '暂停', bg: '#f6f8fa' },
};
const manifestConfig: Record<string, { label: string; icon: typeof Package }> = {
helm: { label: 'Helm', icon: Package },
kustomize: { label: 'Kustomize', icon: Layers },
plain: { label: 'Plain YAML', icon: FolderGit },
};
export default function GitOpsDeploy() {
const [selectedAppId, setSelectedAppId] = useState<string | null>(null);
const [activeView, setActiveView] = useState<'list' | 'tree' | 'diff'>('list');
const { data: dbApps = [] } = useQuery({ queryKey: ['gitops', 'apps'], queryFn: () => api<any[]>('/api/gitops/apps') });
const { data: argoApps = [] } = useQuery({ queryKey: ['gitops', 'argo'], queryFn: () => api<any[]>('/api/gitops/argo/apps') });
const syncMutation = useMutation({
mutationFn: (appName: string) => api<any>('/api/gitops/argo/sync', { method: 'POST', body: JSON.stringify({ appName }) }),
onSuccess: (data) => toast.success(data.message),
onError: (err: Error) => toast.error(err.message),
});
const apps: Application[] = dbApps.length > 0
? dbApps.map((db: any) => {
const argo = (argoApps as any[]).find((a: any) => a.metadata?.name === db.name);
return {
id: String(db.id),
name: db.name,
repo: db.repo,
branch: db.branch,
path: db.path,
cluster: db.cluster,
namespace: db.namespace,
status: argo?.status?.sync?.status === 'Synced' ? 'synced' : argo?.status?.sync?.status === 'OutOfSync' ? 'out-of-sync' : 'unknown',
syncStatus: argo?.status?.sync?.status === 'Synced' ? 'synced' : 'out-of-sync',
health: (argo?.status?.health?.status?.toLowerCase() || 'unknown') as any,
lastSync: '刚刚',
resources: 0,
manifestType: db.manifestType,
autoSync: db.syncPolicy === 'automated',
};
})
: mockApps;
const selectedApp = apps.find(a => a.id === selectedAppId);
const renderResourceNode = (node: ResourceNode, depth: number = 0) => {
const health = healthConfig[node.status];
const hasChildren = node.children && node.children.length > 0;
return (
<div key={node.id} className="select-none">
<div
className="flex items-center gap-2 py-1.5 px-3 hover:bg-black/[0.02] rounded-md cursor-pointer"
style={{ paddingLeft: `${12 + depth * 20}px` }}
>
{hasChildren ? <ChevronRight className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /> : <div className="w-3.5" />}
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: health.color }} />
<span className="text-[11px] font-medium px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{node.kind}</span>
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{node.name}</span>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{node.namespace}</span>
</div>
{hasChildren && node.children!.map(child => renderResourceNode(child, depth + 1))}
</div>
);
};
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-3">
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>GitOps 部署</h1>
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>Argo CD</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
{[
{ id: 'list', label: '应用列表', icon: Layers },
{ id: 'tree', label: '资源树', icon: TreePine },
{ id: 'diff', label: '差异对比', icon: Diff },
].map(v => (
<button key={v.id} onClick={() => setActiveView(v.id as any)}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors"
style={{ background: activeView === v.id ? 'var(--panel-bg-secondary)' : 'transparent', color: activeView === v.id ? 'var(--accent-blue)' : 'var(--text-secondary)' }}>
<v.icon className="w-3.5 h-3.5" />{v.label}
</button>
))}
</div>
<button onClick={() => toast.success('刷新应用状态')} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<RefreshCw className="w-3.5 h-3.5" />刷新
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-hidden flex">
{/* App list sidebar */}
<div className="w-[280px] border-r flex flex-col flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>Applications ({apps.length})</div>
<div className="flex-1 overflow-y-auto custom-scrollbar">
{apps.map(app => {
const st = statusConfig[app.status];
const hl = healthConfig[app.health];
const mf = manifestConfig[app.manifestType];
const isSelected = selectedAppId === app.id;
return (
<div key={app.id} onClick={() => setSelectedAppId(app.id)}
className="mx-2 mb-1 p-2.5 rounded-md cursor-pointer border transition-all"
style={{
background: isSelected ? 'var(--accent-blue-light)' : 'transparent',
borderColor: isSelected ? 'var(--accent-blue)' : 'transparent',
}}>
<div className="flex items-center justify-between mb-1">
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>{app.name}</span>
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium flex items-center gap-1" style={{ background: st.bg, color: st.color }}>
<st.icon className="w-3 h-3" />{st.label}
</span>
</div>
<div className="flex items-center gap-1.5 mb-1">
<GitBranch className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
<span className="text-[11px] font-mono truncate" style={{ color: 'var(--text-secondary)' }}>{app.repo.replace('https://', '').replace('.git', '')}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-[10px] flex items-center gap-1" style={{ color: 'var(--text-tertiary)' }}><mf.icon className="w-3 h-3" />{mf.label}</span>
<span className="text-[10px]" style={{ color: hl.color }}>{hl.label}</span>
</div>
<span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>{app.lastSync}</span>
</div>
</div>
);
})}
</div>
</div>
{/* Detail view */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{!selectedApp ? (
<div className="h-full flex flex-col items-center justify-center text-center" style={{ color: 'var(--text-tertiary)' }}>
<CloudSync className="w-12 h-12 mb-3 opacity-30" />
<p className="text-[14px]">选择一个 Application 查看详情</p>
<p className="text-[12px] mt-1">Git 仓库作为部署声明的单一事实来源</p>
</div>
) : activeView === 'list' ? (
<div className="space-y-4">
{/* App header */}
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-3">
<h2 className="text-[16px] font-semibold" style={{ color: 'var(--text-primary)' }}>{selectedApp.name}</h2>
<span className="text-[10px] px-2 py-[2px] rounded-full font-medium" style={{ background: statusConfig[selectedApp.status].bg, color: statusConfig[selectedApp.status].color }}>
{statusConfig[selectedApp.status].label}
</span>
<span className="text-[10px] px-2 py-[2px] rounded-full font-medium" style={{ background: healthConfig[selectedApp.health].bg || '#f6f8fa', color: healthConfig[selectedApp.health].color }}>
{healthConfig[selectedApp.health].label}
</span>
</div>
<div className="flex items-center gap-2">
<button onClick={() => syncMutation.mutate(selectedApp.name)}
disabled={syncMutation.isPending}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white disabled:opacity-50" style={{ background: 'var(--accent-blue)' }}>
<RefreshCw className={`w-3.5 h-3.5 ${syncMutation.isPending ? 'animate-spin' : ''}`} />立即同步
</button>
<button onClick={() => toast.info('回滚', { description: '选择要回滚的版本' })}
className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
回滚
</button>
</div>
</div>
<div className="grid grid-cols-4 gap-4 text-[12px]">
<div><div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>Git 仓库</div><div className="font-mono" style={{ color: 'var(--text-primary)' }}>{selectedApp.repo}</div></div>
<div><div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>分支 / 路径</div><div style={{ color: 'var(--text-primary)' }}>{selectedApp.branch} / {selectedApp.path}</div></div>
<div><div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>目标集群</div><div className="flex items-center gap-1" style={{ color: 'var(--text-primary)' }}><Server className="w-3 h-3" />{selectedApp.cluster}</div></div>
<div><div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>命名空间</div><div style={{ color: 'var(--text-primary)' }}>{selectedApp.namespace}</div></div>
</div>
</div>
{/* Sync config */}
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>同步配置</h3>
<div className="grid grid-cols-3 gap-4">
<div className="flex items-center gap-2">
<label className="flex items-center gap-2 text-[12px] cursor-pointer">
<input type="checkbox" checked={selectedApp.autoSync} readOnly className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
<span style={{ color: 'var(--text-primary)' }}>自动同步</span>
</label>
</div>
<div><label className="block text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>同步策略</label>
<select className="w-full px-2 py-[5px] text-[12px] rounded-md border" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
<option>自动(Git 变更触发)</option><option>手动(仅点击同步)</option><option>定时同步</option>
</select></div>
<div><label className="block text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>Prune 资源</label>
<select className="w-full px-2 py-[5px] text-[12px] rounded-md border" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
<option>自动清理已删除资源</option><option>保留所有资源</option>
</select></div>
</div>
</div>
{/* Environment promotion */}
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>环境晋升</h3>
<div className="flex items-center gap-3">
<div className="flex-1 rounded-md border p-3 text-center" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>开发环境</div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>dev</div>
<div className="text-[10px] mt-1" style={{ color: '#1a7f37' }}>已同步</div>
</div>
<ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex-1 rounded-md border p-3 text-center" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>测试环境</div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>staging</div>
<div className="text-[10px] mt-1" style={{ color: '#9a6700' }}>待同步</div>
</div>
<ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex-1 rounded-md border p-3 text-center" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>生产环境</div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>production</div>
<div className="text-[10px] mt-1" style={{ color: '#1a7f37' }}>已同步</div>
</div>
<button onClick={() => toast.success('晋升', { description: '将 staging 版本晋升到 production' })}
className="px-3 py-[7px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
晋升
</button>
</div>
</div>
</div>
) : activeView === 'tree' ? (
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="px-4 py-2 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>资源树</span>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{mockResourceTree.length} 个顶级资源</span>
</div>
<div className="py-2">
{mockResourceTree.map(node => renderResourceNode(node))}
</div>
</div>
) : (
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>Git vs 集群差异</h3>
<div className="space-y-2">
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#dafbe1' }}>
<CheckCircle2 className="w-4 h-4" style={{ color: '#1a7f37' }} />
<span className="text-[12px]" style={{ color: '#1a7f37' }}>Deployment/aiops-backend: Git 和集群一致</span>
</div>
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#fff8c5' }}>
<AlertTriangle className="w-4 h-4" style={{ color: '#9a6700' }} />
<span className="text-[12px]" style={{ color: '#9a6700' }}>Service/aiops-backend-svc: 集群中端口被手动修改 (80 {'->'} 8080)</span>
</div>
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#ffebe9' }}>
<XCircle className="w-4 h-4" style={{ color: '#cf222e' }} />
<span className="text-[12px]" style={{ color: '#cf222e' }}>ConfigMap/aiops-backend-config: Git 中新增,集群中不存在</span>
</div>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useState } from 'react'
import '../App.css'
export default function Home() {
const [count, setCount] = useState(0)
return (
<>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
</>
)
}
+297
View File
@@ -0,0 +1,297 @@
import { useState } from 'react';
import {
Download,
Link,
Search,
CheckCircle2,
AlertCircle,
ArrowRight,
FileCode,
GitBranch,
RefreshCw
} from 'lucide-react';
import { toast } from 'sonner';
interface JenkinsJob {
id: string;
name: string;
path: string;
type: 'freestyle' | 'pipeline' | 'multibranch';
lastBuild: string;
status: 'success' | 'failed' | 'unstable';
selected: boolean;
}
const mockJobs: JenkinsJob[] = [
{ id: '1', name: 'backend-build', path: 'aiops/backend-build', type: 'pipeline', lastBuild: '2024-06-20 14:30', status: 'success', selected: false },
{ id: '2', name: 'frontend-deploy', path: 'aiops/frontend-deploy', type: 'freestyle', lastBuild: '2024-06-20 13:15', status: 'success', selected: false },
{ id: '3', name: 'gateway-service', path: 'platform/gateway-service', type: 'pipeline', lastBuild: '2024-06-19 18:45', status: 'failed', selected: false },
{ id: '4', name: 'data-sync-job', path: 'data/data-sync-job', type: 'freestyle', lastBuild: '2024-06-18 02:00', status: 'unstable', selected: false },
{ id: '5', name: 'mobile-app-build', path: 'mobile/mobile-app-build', type: 'multibranch', lastBuild: '2024-06-17 16:20', status: 'success', selected: false },
];
const typeConfig = {
freestyle: { label: '自由风格', color: '#6e7781', bg: '#f6f8fa' },
pipeline: { label: '流水线', color: '#0969da', bg: '#ddf4ff' },
multibranch: { label: '多分支', color: '#8250df', bg: '#fbefff' },
};
const statusConfig = {
success: { icon: CheckCircle2, color: '#1a7f37' },
failed: { icon: AlertCircle, color: '#cf222e' },
unstable: { icon: AlertCircle, color: '#9a6700' },
};
export default function JenkinsImport() {
const [step, setStep] = useState<'connect' | 'select' | 'preview' | 'done'>('connect');
const [jenkinsUrl, setJenkinsUrl] = useState('http://jenkins.company.com:8080');
const [jenkinsUser, setJenkinsUser] = useState('');
const [jenkinsToken, setJenkinsToken] = useState('');
const [jobs, setJobs] = useState<JenkinsJob[]>(mockJobs);
const [searchQuery, setSearchQuery] = useState('');
const [isConnecting, setIsConnecting] = useState(false);
const handleConnect = () => {
setIsConnecting(true);
setTimeout(() => {
setIsConnecting(false);
setStep('select');
toast.success('连接成功', { description: `已连接到 ${jenkinsUrl}` });
}, 1500);
};
const toggleJob = (id: string) => {
setJobs(prev => prev.map(j => j.id === id ? { ...j, selected: !j.selected } : j));
};
const selectedCount = jobs.filter(j => j.selected).length;
const filteredJobs = jobs.filter(j =>
j.name.includes(searchQuery) || j.path.includes(searchQuery)
);
const handleImport = () => {
toast.success('导入完成', { description: `成功导入 ${selectedCount} 个Job` });
setStep('done');
};
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>Jenkins 导入</h1>
{step !== 'connect' && (
<div className="flex items-center gap-1 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>
<span style={{ color: step === 'select' || step === 'preview' || step === 'done' ? 'var(--accent-blue)' : undefined }}>1.连接</span>
<ArrowRight className="w-3 h-3" />
<span style={{ color: step === 'select' || step === 'preview' || step === 'done' ? 'var(--accent-blue)' : undefined }}>2.选择</span>
<ArrowRight className="w-3 h-3" />
<span style={{ color: step === 'preview' || step === 'done' ? 'var(--accent-blue)' : undefined }}>3.预览</span>
<ArrowRight className="w-3 h-3" />
<span style={{ color: step === 'done' ? 'var(--accent-blue)' : undefined }}>4.完成</span>
</div>
)}
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
<div className="max-w-2xl mx-auto">
{step === 'connect' && (
<div className="rounded-lg border p-6" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-3 mb-6">
<div className="w-10 h-10 rounded-lg flex items-center justify-center" style={{ background: 'var(--accent-blue-light)' }}>
<Link className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
</div>
<div>
<h2 className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>连接 Jenkins 服务器</h2>
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>输入 Jenkins 地址和认证信息以获取 Job 列表</p>
</div>
</div>
<div className="space-y-4">
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>Jenkins 地址</label>
<input
value={jenkinsUrl}
onChange={(e) => setJenkinsUrl(e.target.value)}
placeholder="http://jenkins.company.com:8080"
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none focus:border-[var(--accent-blue)]"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>用户名</label>
<input
value={jenkinsUser}
onChange={(e) => setJenkinsUser(e.target.value)}
placeholder="admin"
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none focus:border-[var(--accent-blue)]"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
/>
</div>
<div>
<label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>API Token</label>
<input
type="password"
value={jenkinsToken}
onChange={(e) => setJenkinsToken(e.target.value)}
placeholder="11xxxxxxxxxxxxxxxxxxxxxx"
className="w-full px-3 py-[7px] rounded-md border text-[13px] outline-none focus:border-[var(--accent-blue)]"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
/>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<button
onClick={handleConnect}
disabled={isConnecting}
className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white disabled:opacity-60"
style={{ background: 'var(--accent-blue)' }}
>
{isConnecting ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Link className="w-3.5 h-3.5" />}
{isConnecting ? '连接中...' : '连接并获取Job'}
</button>
</div>
</div>
</div>
)}
{step === 'select' && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="flex items-center gap-2 px-3 py-[6px] rounded-md border" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索Job..." className="bg-transparent outline-none w-40 text-[13px]" style={{ color: 'var(--text-primary)' }} />
</div>
</div>
<div className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>
已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedCount}</span> 个 Job
</div>
</div>
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<table className="w-full">
<thead>
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
<th className="w-10 px-3 py-2"></th>
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>Job名称</th>
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>类型</th>
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>状态</th>
<th className="text-left px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>最后构建</th>
</tr>
</thead>
<tbody>
{filteredJobs.map((job) => {
const type = typeConfig[job.type];
const status = statusConfig[job.status];
const StatusIcon = status.icon;
return (
<tr key={job.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
<td className="px-3 py-3">
<input
type="checkbox"
checked={job.selected}
onChange={() => toggleJob(job.id)}
className="w-4 h-4 rounded cursor-pointer"
style={{ accentColor: 'var(--accent-blue)' }}
/>
</td>
<td className="px-3 py-3">
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{job.name}</div>
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{job.path}</div>
</td>
<td className="px-3 py-3">
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: type.bg, color: type.color }}>{type.label}</span>
</td>
<td className="px-3 py-3">
<div className="flex items-center gap-1">
<StatusIcon className="w-3.5 h-3.5" style={{ color: status.color }} />
<span className="text-[12px]" style={{ color: status.color }}>
{job.status === 'success' ? '成功' : job.status === 'failed' ? '失败' : '不稳定'}
</span>
</div>
</td>
<td className="px-3 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{job.lastBuild}</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="flex justify-end">
<button
onClick={() => selectedCount > 0 ? setStep('preview') : toast.error('请至少选择一个Job')}
className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
>
下一步: 预览转换
<ArrowRight className="w-3.5 h-3.5" />
</button>
</div>
</div>
)}
{step === 'preview' && (
<div className="space-y-4">
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>转换预览</h3>
<div className="space-y-3">
{jobs.filter(j => j.selected).map((job) => (
<div key={job.id} className="flex items-center gap-3 p-3 rounded-md" style={{ background: 'var(--panel-bg-secondary)' }}>
<FileCode className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
<div className="flex-1">
<div className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{job.name}</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>Jenkins {typeConfig[job.type].label} → AIOps 流水线</div>
</div>
<ArrowRight className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
<div className="flex items-center gap-1">
<GitBranch className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
<span className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>aiops-{job.name}</span>
</div>
</div>
))}
</div>
</div>
<div className="flex justify-end gap-2">
<button onClick={() => setStep('select')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>上一步</button>
<button onClick={handleImport} className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
<Download className="w-3.5 h-3.5" />
确认导入
</button>
</div>
</div>
)}
{step === 'done' && (
<div className="text-center py-12">
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ background: '#dafbe1' }}>
<CheckCircle2 className="w-8 h-8" style={{ color: '#1a7f37' }} />
</div>
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>导入完成</h2>
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>
成功导入 {selectedCount} 个 Jenkins Job 为 AIOps 流水线
</p>
<div className="flex justify-center gap-2">
<button
onClick={() => { setStep('connect'); setJobs(mockJobs.map(j => ({ ...j, selected: false }))); }}
className="px-4 py-[7px] text-[13px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}
>
继续导入
</button>
<button
onClick={() => toast.info('跳转到流水线列表')}
className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
>
查看流水线
</button>
</div>
</div>
)}
</div>
</div>
</div>
);
}
+403
View File
@@ -0,0 +1,403 @@
import { useState } from 'react';
import {
Search,
Plus,
Play,
Settings,
Trash2,
GitBranch,
Clock,
CheckCircle2,
XCircle,
Loader2,
Filter,
LayoutGrid,
List,
MoreHorizontal
} from 'lucide-react';
import { toast } from 'sonner';
interface Pipeline {
id: string;
name: string;
description: string;
lastStatus: 'success' | 'failed' | 'running' | 'pending';
lastRunAt: string;
triggerType: string;
duration: string;
branch: string;
deployType: 'host' | 'k8s';
}
const mockPipelines: Pipeline[] = [
{
id: '1',
name: 'aiops-backend',
description: 'Go后端服务 - Docker构建 + K8s部署',
lastStatus: 'success',
lastRunAt: '2024-06-20 14:30:15',
triggerType: 'Git Push',
duration: '3分22秒',
branch: 'master',
deployType: 'k8s',
},
{
id: '2',
name: 'aiops-web',
description: 'Vue前端应用 - npm构建 + 主机部署',
lastStatus: 'running',
lastRunAt: '2024-06-20 15:45:00',
triggerType: '手动',
duration: '1分12秒',
branch: 'master',
deployType: 'host',
},
{
id: '3',
name: 'gateway-api',
description: 'API网关服务 - Java构建 + K8s部署',
lastStatus: 'failed',
lastRunAt: '2024-06-20 13:20:00',
triggerType: '手动',
duration: '45秒',
branch: 'release/v2.1',
deployType: 'k8s',
},
{
id: '4',
name: 'admin-dashboard',
description: '管理后台 - React构建 + 主机部署',
lastStatus: 'success',
lastRunAt: '2024-06-20 12:10:00',
triggerType: 'Cron',
duration: '2分05秒',
branch: 'master',
deployType: 'host',
},
{
id: '5',
name: 'data-processor',
description: '数据处理服务 - Python构建 + K8s部署',
lastStatus: 'pending',
lastRunAt: '-',
triggerType: 'Webhook',
duration: '-',
branch: 'develop',
deployType: 'k8s',
},
];
const statusConfig = {
success: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '成功' },
failed: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '失败' },
running: { icon: Loader2, color: '#0969da', bg: '#ddf4ff', label: '执行中' },
pending: { icon: Clock, color: '#6e7781', bg: '#f6f8fa', label: '未执行' },
};
export default function PipelineList({ onSelectPipeline }: { onSelectPipeline: (id: string) => void }) {
const [viewMode, setViewMode] = useState<'card' | 'list'>('card');
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [pipelines] = useState<Pipeline[]>(mockPipelines);
const filtered = pipelines.filter((p) => {
const matchSearch = p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase());
const matchStatus = statusFilter === 'all' || p.lastStatus === statusFilter;
return matchSearch && matchStatus;
});
const statusOptions = [
{ value: 'all', label: '全部状态' },
{ value: 'success', label: '成功' },
{ value: 'failed', label: '失败' },
{ value: 'running', label: '执行中' },
{ value: 'pending', label: '未执行' },
];
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header toolbar */}
<div
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
<div className="flex items-center gap-3">
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>
流水线列表
</h1>
<span
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
>
{filtered.length}
</span>
</div>
<div className="flex items-center gap-2">
{/* Search */}
<div
className="flex items-center gap-2 px-3 py-[6px] rounded-md border text-[13px]"
style={{
background: 'var(--panel-bg-secondary)',
borderColor: 'var(--border-color-light)',
color: 'var(--text-secondary)',
}}
>
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
<input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="搜索流水线..."
className="bg-transparent outline-none w-40"
style={{ color: 'var(--text-primary)', fontSize: '13px' }}
/>
</div>
{/* Status filter */}
<div className="relative group">
<button
className="flex items-center gap-1.5 px-3 py-[6px] rounded-md border text-[13px] font-medium"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--border-color-light)',
color: 'var(--text-secondary)',
}}
>
<Filter className="w-3.5 h-3.5" />
{statusOptions.find(s => s.value === statusFilter)?.label}
</button>
<div className="absolute right-0 top-full mt-1 hidden group-hover:block z-50">
<div
className="rounded-md border shadow-lg py-1 min-w-[120px]"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
{statusOptions.map((opt) => (
<button
key={opt.value}
onClick={() => setStatusFilter(opt.value)}
className="w-full text-left px-3 py-[6px] text-[13px] hover:bg-black/5 transition-colors"
style={{ color: statusFilter === opt.value ? 'var(--accent-blue)' : 'var(--text-secondary)' }}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
{/* View toggle */}
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
<button
onClick={() => setViewMode('card')}
className="p-[6px] transition-colors"
style={{ background: viewMode === 'card' ? 'var(--panel-bg-secondary)' : 'var(--panel-bg)' }}
>
<LayoutGrid className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
</button>
<button
onClick={() => setViewMode('list')}
className="p-[6px] transition-colors"
style={{ background: viewMode === 'list' ? 'var(--panel-bg-secondary)' : 'var(--panel-bg)' }}
>
<List className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
</button>
</div>
{/* New pipeline button */}
<button
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
onClick={() => toast.info('新建流水线', { description: '功能开发中...' })}
>
<Plus className="w-3.5 h-3.5" />
新建流水线
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20">
<div className="w-12 h-12 rounded-full flex items-center justify-center mb-3" style={{ background: 'var(--panel-bg-secondary)' }}>
<Search className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
</div>
<p className="text-[13px] font-medium" style={{ color: 'var(--text-secondary)' }}>未找到流水线</p>
<p className="text-[12px] mt-1" style={{ color: 'var(--text-tertiary)' }}>尝试调整搜索条件</p>
</div>
) : viewMode === 'card' ? (
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map((pipeline) => {
const status = statusConfig[pipeline.lastStatus];
const StatusIcon = status.icon;
return (
<div
key={pipeline.id}
className="rounded-lg border transition-all duration-200 hover:shadow-md cursor-pointer group"
style={{
background: 'var(--panel-bg)',
borderColor: 'var(--border-color)',
}}
onClick={() => onSelectPipeline(pipeline.id)}
>
{/* Card header */}
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color-light)' }}>
<div className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{ background: status.color }}
/>
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>
{pipeline.name}
</span>
</div>
<span
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase tracking-wide"
style={{
background: pipeline.deployType === 'k8s' ? '#ddf4ff' : '#dafbe1',
color: pipeline.deployType === 'k8s' ? '#0969da' : '#1a7f37',
}}
>
{pipeline.deployType === 'k8s' ? 'K8s' : '主机'}
</span>
</div>
{/* Card body */}
<div className="px-4 py-3">
<p className="text-[12px] mb-3 line-clamp-2" style={{ color: 'var(--text-secondary)' }}>
{pipeline.description}
</p>
<div className="flex items-center gap-4 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
<div className="flex items-center gap-1">
<GitBranch className="w-3 h-3" />
{pipeline.branch}
</div>
<div className="flex items-center gap-1">
<Clock className="w-3 h-3" />
{pipeline.duration}
</div>
</div>
</div>
{/* Card footer */}
<div
className="px-4 py-2 border-t flex items-center justify-between"
style={{ borderColor: 'var(--border-color-light)' }}
>
<div className="flex items-center gap-1.5">
<StatusIcon
className={`w-3.5 h-3.5 ${pipeline.lastStatus === 'running' ? 'animate-spin' : ''}`}
style={{ color: status.color }}
/>
<span className="text-[11px] font-medium" style={{ color: status.color }}>
{status.label}
</span>
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
{pipeline.lastRunAt}
</span>
</div>
<div className="flex items-center gap-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => { e.stopPropagation(); toast.success('开始执行', { description: pipeline.name }); }}
className="p-1 rounded hover:bg-black/5"
title="执行"
>
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
</button>
<button
className="p-1 rounded hover:bg-black/5"
title="配置"
onClick={(e) => e.stopPropagation()}
>
<Settings className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
<button
className="p-1 rounded hover:bg-black/5"
title="删除"
onClick={(e) => e.stopPropagation()}
>
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</div>
</div>
);
})}
</div>
) : (
/* List view */
<div
className="rounded-lg border overflow-hidden"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
<table className="w-full">
<thead>
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
{['流水线', '分支', '部署方式', '最近执行', '耗时', '触发方式', '操作'].map((h) => (
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
{h}
</th>
))}
</tr>
</thead>
<tbody>
{filtered.map((pipeline) => {
const status = statusConfig[pipeline.lastStatus];
const StatusIcon = status.icon;
return (
<tr
key={pipeline.id}
className="border-t cursor-pointer hover:bg-black/[0.02] transition-colors"
style={{ borderColor: 'var(--border-color-light)' }}
onClick={() => onSelectPipeline(pipeline.id)}
>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: status.color }} />
<div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{pipeline.name}</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{pipeline.description}</div>
</div>
</div>
</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>
<div className="flex items-center gap-1">
<GitBranch className="w-3 h-3" />
{pipeline.branch}
</div>
</td>
<td className="px-4 py-3">
<span
className="text-[10px] px-1.5 py-[2px] rounded font-medium uppercase"
style={{
background: pipeline.deployType === 'k8s' ? '#ddf4ff' : '#dafbe1',
color: pipeline.deployType === 'k8s' ? '#0969da' : '#1a7f37',
}}
>
{pipeline.deployType === 'k8s' ? 'K8s' : '主机'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1.5">
<StatusIcon className={`w-3.5 h-3.5 ${pipeline.lastStatus === 'running' ? 'animate-spin' : ''}`} style={{ color: status.color }} />
<span className="text-[12px]" style={{ color: status.color }}>{status.label}</span>
</div>
</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.duration}</td>
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{pipeline.triggerType}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-0">
<button onClick={(e) => { e.stopPropagation(); toast.success('开始执行'); }} className="p-1 rounded hover:bg-black/5">
<Play className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
</button>
<button className="p-1 rounded hover:bg-black/5">
<MoreHorizontal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
+273
View File
@@ -0,0 +1,273 @@
import { useState } from 'react';
import {
Puzzle, Search, Download, RefreshCw,
Star, Users, Trash2,
Zap, Palette, Code, Globe, Database, ShieldCheck
} from 'lucide-react';
import { toast } from 'sonner';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '@/lib/api';
type TabType = 'installed' | 'available' | 'updates' | 'advanced';
const categoryColors: Record<string, string> = {
'部署': '#0969da', '构建': '#1a7f37', '主题': '#8250df', '扫描': '#9a6700',
'安全': '#cf222e', '运维': '#6e7781', '通知': '#bf8700',
};
const iconMap: Record<string, typeof Puzzle> = {
'部署': Globe, '构建': Code, '主题': Palette, '扫描': ShieldCheck,
'安全': ShieldCheck, '运维': Database, '通知': Zap, 'general': Puzzle,
};
export default function PluginManager() {
const [activeTab, setActiveTab] = useState<TabType>('installed');
const [search, setSearch] = useState('');
const queryClient = useQueryClient();
const { data: installed = [] } = useQuery({ queryKey: ['plugins'], queryFn: () => api<any[]>('/api/plugins') });
const { data: available = [] } = useQuery({ queryKey: ['plugins', 'available'], queryFn: () => api<any[]>('/api/plugins/available') });
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['plugins'] });
const installMutation = useMutation({
mutationFn: (body: any) => api('/api/plugins/install', { method: 'POST', body: JSON.stringify(body) }),
onSuccess: () => { toast.success('插件安装成功'); invalidate(); },
onError: (err: Error) => toast.error(err.message),
});
const uninstallMutation = useMutation({
mutationFn: (id: number) => api(`/api/plugins/${id}`, { method: 'DELETE' }),
onSuccess: () => { toast.success('插件已卸载'); invalidate(); },
onError: (err: Error) => toast.error(err.message),
});
const toggleMutation = useMutation({
mutationFn: (input: { id: number; enabled: boolean }) =>
api(`/api/plugins/${input.id}/toggle`, { method: 'POST', body: JSON.stringify({ enabled: input.enabled }) }),
onSuccess: () => invalidate(),
onError: (err: Error) => toast.error(err.message),
});
const installedPlugins = installed.map((p: any) => ({
id: String(p.id),
name: p.name,
description: p.description || '',
version: p.version,
latestVersion: p.latestVersion || p.version,
author: p.author || 'Unknown',
installs: 0,
rating: 4.5,
category: p.category || 'general',
installed: true,
hasUpdate: p.latestVersion && p.latestVersion !== p.version,
enabled: p.enabled,
icon: iconMap[p.category || 'general'] || Puzzle,
}));
const availablePlugins = available.map((p: any) => ({
id: p.pluginId,
name: p.name,
description: p.description || '',
version: p.version,
latestVersion: p.version,
author: p.author || 'Unknown',
installs: p.installs || 0,
rating: 4.5,
category: p.category || 'general',
installed: false,
hasUpdate: false,
enabled: false,
icon: iconMap[p.category || 'general'] || Puzzle,
}));
const updatePlugins = installedPlugins.filter((p: any) => p.hasUpdate);
const allPlugins = [...installedPlugins, ...availablePlugins];
const filteredPlugins = () => {
const list = activeTab === 'installed' ? installedPlugins : activeTab === 'available' ? availablePlugins : activeTab === 'updates' ? updatePlugins : allPlugins;
return list.filter((p: any) => p.name.toLowerCase().includes(search.toLowerCase()) || p.description.toLowerCase().includes(search.toLowerCase()));
};
const handleInstall = (plugin: any) => {
installMutation.mutate({
pluginId: plugin.id,
name: plugin.name,
description: plugin.description,
version: plugin.version,
author: plugin.author,
category: plugin.category,
});
};
const handleUninstall = (id: string) => {
const numId = Number(id);
if (!isNaN(numId)) uninstallMutation.mutate(numId);
};
const handleToggle = (id: string) => {
const numId = Number(id);
const plugin = installedPlugins.find((p: any) => p.id === id);
if (!isNaN(numId) && plugin) {
toggleMutation.mutate({ id: numId, enabled: !plugin.enabled });
}
};
const tabs: { id: TabType; label: string; count: number }[] = [
{ id: 'installed', label: '已安装', count: installedPlugins.length },
{ id: 'available', label: '可安装', count: availablePlugins.length },
{ id: 'updates', label: '更新', count: updatePlugins.length },
{ id: 'advanced', label: '高级设置', count: 0 },
];
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-3">
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>插件管理</h1>
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
{installedPlugins.length} 已安装 / {availablePlugins.length} 可安装
</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
<Search className="w-3.5 h-3.5 ml-2.5" style={{ color: 'var(--text-tertiary)' }} />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索插件..."
className="px-2 py-[5px] text-[12px] outline-none w-48" style={{ background: 'transparent', color: 'var(--text-primary)' }} />
</div>
<button onClick={() => toast.success('刷新插件列表')} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<RefreshCw className="w-3.5 h-3.5" />刷新
</button>
</div>
</div>
{/* Tabs */}
<div className="px-5 pt-3 border-b flex items-center gap-0 flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
{tabs.map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className="px-4 py-[8px] text-[13px] font-medium border-b-2 transition-colors"
style={{
borderColor: activeTab === tab.id ? 'var(--accent-blue)' : 'transparent',
color: activeTab === tab.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}>
{tab.label}
{tab.count > 0 && <span className="ml-1.5 text-[10px] px-1.5 py-[1px] rounded-full font-medium" style={{ background: activeTab === tab.id ? 'var(--accent-blue-light)' : 'var(--panel-bg-secondary)', color: activeTab === tab.id ? 'var(--accent-blue)' : 'var(--text-tertiary)' }}>{tab.count}</span>}
</button>
))}
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{activeTab === 'advanced' ? (
<div className="max-w-2xl space-y-4">
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>插件安装源</h3>
<div className="space-y-2">
{['https://plugins.opsx.dev/stable', 'https://plugins.opsx.dev/beta', 'https://plugins.opsx.dev/community'].map((url, idx) => (
<div key={idx} className="flex items-center justify-between p-2.5 rounded-md border" style={{ borderColor: 'var(--border-color-light)' }}>
<div className="flex items-center gap-2">
<Globe className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
<span className="text-[12px] font-mono" style={{ color: 'var(--text-primary)' }}>{url}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: idx === 0 ? '#dafbe1' : '#fff8c5', color: idx === 0 ? '#1a7f37' : '#9a6700' }}>{idx === 0 ? '默认' : '备用'}</span>
<button className="p-1 rounded hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }}><Trash2 className="w-3.5 h-3.5" /></button>
</div>
</div>
))}
</div>
<button onClick={() => toast.info('添加插件源')} className="mt-2 flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<Download className="w-3.5 h-3.5" />添加源
</button>
</div>
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>代理设置</h3>
<div className="grid grid-cols-2 gap-3">
<div><label className="block text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>HTTP 代理</label>
<input placeholder="http://proxy:8080" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
<div><label className="block text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>HTTPS 代理</label>
<input placeholder="https://proxy:8080" className="w-full px-3 py-[6px] text-[12px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
</div>
</div>
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>其他设置</h3>
<div className="space-y-2">
<label className="flex items-center gap-2 text-[12px] cursor-pointer">
<input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
<span style={{ color: 'var(--text-primary)' }}>安装后自动启用插件</span>
</label>
<label className="flex items-center gap-2 text-[12px] cursor-pointer">
<input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
<span style={{ color: 'var(--text-primary)' }}>自动检查插件更新</span>
</label>
<label className="flex items-center gap-2 text-[12px] cursor-pointer">
<input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
<span style={{ color: 'var(--text-primary)' }}>允许安装未签名插件</span>
</label>
</div>
</div>
</div>
) : (
<div className="space-y-2">
{filteredPlugins().map(plugin => {
const Icon = plugin.icon;
return (
<div key={plugin.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0" style={{ background: 'var(--panel-bg-secondary)' }}>
<Icon className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-0.5">
<span className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{plugin.name}</span>
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: categoryColors[plugin.category] + '15', color: categoryColors[plugin.category] }}>{plugin.category}</span>
{plugin.installed && (
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: '#dafbe1', color: '#1a7f37' }}>已安装</span>
)}
</div>
<p className="text-[12px] mb-2" style={{ color: 'var(--text-secondary)' }}>{plugin.description}</p>
<div className="flex items-center gap-3 text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
<span className="flex items-center gap-1"><Star className="w-3 h-3" style={{ color: '#9a6700' }} />{plugin.rating}</span>
<span className="flex items-center gap-1"><Users className="w-3 h-3" />{plugin.installs.toLocaleString()} 次安装</span>
<span>作者: {plugin.author}</span>
<span className="font-mono">v{plugin.version}</span>
{plugin.hasUpdate && <span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>v{plugin.latestVersion} 可用</span>}
</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
{plugin.installed ? (
<>
<button onClick={() => handleToggle(plugin.id)}
className="px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors"
style={{ borderColor: 'var(--border-color-light)', color: plugin.enabled ? 'var(--accent-blue)' : 'var(--text-tertiary)' }}>
{plugin.enabled ? '已启用' : '已禁用'}
</button>
{plugin.hasUpdate && (
<button onClick={() => toast.info('更新插件', { description: `${plugin.name} 将更新到 v${plugin.latestVersion}` })}
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
<RefreshCw className="w-3 h-3" />更新
</button>
)}
<button onClick={() => handleUninstall(plugin.id)}
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }}>
<Trash2 className="w-3.5 h-3.5" />
</button>
</>
) : (
<button onClick={() => handleInstall(plugin)}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
<Download className="w-3.5 h-3.5" />安装
</button>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
+335
View File
@@ -0,0 +1,335 @@
import { useState } from 'react';
import {
Filter, Search, GitBranch,
CheckCircle2, XCircle, Clock, ChevronRight, Terminal,
RotateCcw, ExternalLink, Calendar, User, Container, Layers,
Wifi, WifiOff
} from 'lucide-react';
import { toast } from 'sonner';
import { usePipelineLogs } from '@/hooks/usePipelineLogs';
interface PipelineRunStage {
name: string;
status: string;
duration: string;
podName?: string;
logs?: string[];
}
interface ReleaseRecord {
id: string;
pipelineName: string;
branch: string;
env: string;
deployType: 'host' | 'k8s' | 'gitops';
type: 'native' | 'jenkins' | 'tekton';
status: 'success' | 'failed' | 'running' | 'cancelled';
triggerBy: string;
startTime: string;
duration: string;
stages: PipelineRunStage[];
podStatus?: { name: string; status: string; restarts: number; age: string }[];
}
const mockRecords: ReleaseRecord[] = [
{
id: 'pr-202410261430',
pipelineName: 'aiops-backend',
branch: 'master',
env: 'production',
deployType: 'gitops',
type: 'tekton',
status: 'success',
triggerBy: '张三',
startTime: '2024-10-26 14:30:00',
duration: '3m 42s',
stages: [
{ name: 'git-clone', status: 'success', duration: '15s', podName: 'pr-abc-git-clone-xyz', logs: ['Cloning into \'/workspace/source\'...', 'HEAD is now at 7d9f4e2 feat: update api'] },
{ name: 'go-build', status: 'success', duration: '1m 30s', podName: 'pr-abc-go-build-xyz', logs: ['go build -o bin/app ./cmd/app', 'Build successful'] },
{ name: 'kaniko-build', status: 'success', duration: '1m 45s', podName: 'pr-abc-kaniko-xyz', logs: ['INFO[0000] Resolved base name...', 'Pushing image to registry...', 'Image pushed: registry/aiops:v202410261430'] },
{ name: 'update-manifest', status: 'success', duration: '8s', podName: 'pr-abc-update-xyz', logs: ['git clone gitops-repo', 'Updating image tag to v202410261430', 'git push origin main'] },
],
podStatus: [
{ name: 'aiops-backend-7d9f4-abc1', status: 'Running', restarts: 0, age: '5m' },
{ name: 'aiops-backend-7d9f4-abc2', status: 'Running', restarts: 0, age: '5m' },
{ name: 'aiops-backend-7d9f4-abc3', status: 'Running', restarts: 0, age: '5m' },
],
},
{
id: 'pr-202410261415',
pipelineName: 'aiops-web',
branch: 'master',
env: 'production',
deployType: 'host',
type: 'native',
status: 'success',
triggerBy: '李四',
startTime: '2024-10-26 14:15:00',
duration: '2m 18s',
stages: [
{ name: '拉取代码', status: 'success', duration: '12s' },
{ name: 'npm-build', status: 'success', duration: '1m 30s' },
{ name: 'scp-deploy', status: 'success', duration: '30s' },
],
},
{
id: 'pr-202410261400',
pipelineName: 'gateway-api',
branch: 'release/v2.1',
env: 'staging',
deployType: 'k8s',
type: 'jenkins',
status: 'failed',
triggerBy: '王五',
startTime: '2024-10-26 14:00:00',
duration: '5m 10s',
stages: [
{ name: 'git-clone', status: 'success', duration: '18s' },
{ name: 'maven-build', status: 'success', duration: '2m 45s' },
{ name: 'kubectl-deploy', status: 'failed', duration: '2m 7s', logs: ['kubectl apply -f k8s/', 'Error: deployment gateway-api timeout', 'Pod gateway-api-xxx CrashLoopBackOff'] },
],
podStatus: [
{ name: 'gateway-api-xxx', status: 'CrashLoopBackOff', restarts: 5, age: '10m' },
],
},
{
id: 'pr-202410261330',
pipelineName: 'admin-dashboard',
branch: 'master',
env: 'staging',
deployType: 'host',
type: 'native',
status: 'running',
triggerBy: '赵六',
startTime: '2024-10-26 13:30:00',
duration: '进行中',
stages: [
{ name: '拉取代码', status: 'success', duration: '10s' },
{ name: '构建', status: 'success', duration: '1m 20s' },
{ name: '部署到主机', status: 'running', duration: '30s' },
{ name: '后置操作', status: 'pending', duration: '-' },
],
},
];
const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string; bg: string; label: string }> = {
success: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '成功' },
failed: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '失败' },
running: { icon: Clock, color: '#9a6700', bg: '#fff8c5', label: '执行中' },
cancelled: { icon: XCircle, color: '#6e7781', bg: '#f6f8fa', label: '已取消' },
};
const typeConfig: Record<string, { label: string; color: string; bg: string }> = {
native: { label: '原生', color: '#0969da', bg: '#ddf4ff' },
jenkins: { label: 'Jenkins', color: '#8250df', bg: '#fbefff' },
tekton: { label: 'Tekton', color: '#0e7c7b', bg: '#d5f5f6' },
};
function ExpandedRecord({ record, activeTab, setActiveTab }: {
record: ReleaseRecord;
activeTab: 'stages' | 'logs' | 'pods';
setActiveTab: (t: 'stages' | 'logs' | 'pods') => void;
}) {
const isRunning = record.status === 'running';
const { logs, connected } = usePipelineLogs(record.id, isRunning && activeTab === 'logs');
return (
<div className="border-t" style={{ borderColor: 'var(--border-color-light)' }}>
<div className="flex items-center gap-0 px-4 border-b" style={{ borderColor: 'var(--border-color-light)' }}>
{[
{ id: 'stages' as const, label: '阶段', icon: Layers },
{ id: 'logs' as const, label: '日志', icon: Terminal },
...(record.podStatus ? [{ id: 'pods' as const, label: 'Pod 状态', icon: Container }] : []),
].map(tab => (
<button key={tab.id} onClick={() => setActiveTab(tab.id)}
className="flex items-center gap-1 px-3 py-[8px] text-[12px] font-medium border-b-2 transition-colors"
style={{ borderColor: activeTab === tab.id ? 'var(--accent-blue)' : 'transparent', color: activeTab === tab.id ? 'var(--accent-blue)' : 'var(--text-secondary)' }}>
<tab.icon className="w-3.5 h-3.5" />{tab.label}
{tab.id === 'logs' && isRunning && (
connected
? <Wifi className="w-2.5 h-2.5 ml-0.5" style={{ color: '#1a7f37' }} />
: <WifiOff className="w-2.5 h-2.5 ml-0.5" style={{ color: '#cf222e' }} />
)}
</button>
))}
</div>
<div className="p-4">
{activeTab === 'stages' && (
<div className="flex items-center gap-0">
{record.stages.map((stage, idx) => {
const st = statusConfig[stage.status] || statusConfig.success;
const isLast = idx === record.stages.length - 1;
return (
<div key={idx} className="flex items-center flex-1">
<div className="flex flex-col items-center flex-1">
<div className="w-6 h-6 rounded-full flex items-center justify-center mb-1" style={{ background: st.bg }}>
{stage.status === 'success' ? <CheckCircle2 className="w-3.5 h-3.5" style={{ color: st.color }} /> :
stage.status === 'failed' ? <XCircle className="w-3.5 h-3.5" style={{ color: st.color }} /> :
stage.status === 'running' ? <Clock className="w-3.5 h-3.5 animate-pulse" style={{ color: st.color }} /> :
<div className="w-2 h-2 rounded-full" style={{ background: 'var(--border-color)' }} />}
</div>
<div className="text-[10px] font-medium text-center" style={{ color: 'var(--text-secondary)' }}>{stage.name}</div>
<div className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>{stage.duration}</div>
{stage.podName && <div className="text-[9px] font-mono mt-0.5" style={{ color: 'var(--text-tertiary)' }}>{stage.podName}</div>}
</div>
{!isLast && <div className="flex-1 h-[2px] mx-1" style={{ background: idx < record.stages.findIndex(s => s.status === 'failed' || s.status === 'running') ? '#dafbe1' : 'var(--border-color-light)' }} />}
</div>
);
})}
</div>
)}
{activeTab === 'logs' && (
<div className="rounded-md overflow-hidden" style={{ background: '#0d1117' }}>
<div className="px-3 py-1.5 border-b flex items-center gap-2" style={{ borderColor: '#21262d' }}>
<Terminal className="w-3.5 h-3.5" style={{ color: '#7d8590' }} />
<span className="text-[11px] font-medium" style={{ color: '#7d8590' }}>PipelineRun 日志</span>
{isRunning && (
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium ml-auto" style={{ background: connected ? '#1a7f3720' : '#cf222e20', color: connected ? '#1a7f37' : '#cf222e' }}>
{connected ? '实时推送中' : '连接中...'}
</span>
)}
</div>
<div className="p-3 font-mono text-[11px] leading-relaxed max-h-[300px] overflow-y-auto" style={{ color: '#e6edf3' }}>
{record.stages.map((stage, idx) => (
<div key={idx}>
<div className="py-1" style={{ color: '#58a6ff' }}>=== {stage.name} ===</div>
{stage.logs ? stage.logs.map((log, li) => (
<div key={li} className="py-[1px]" style={{ color: log.includes('Error') ? '#f85149' : '#e6edf3' }}>{log}</div>
)) : <div className="py-[1px]" style={{ color: '#7d8590' }}>No logs available</div>}
</div>
))}
{logs.length > 0 && (
<div className="mt-2 pt-2 border-t" style={{ borderColor: '#21262d' }}>
<div className="py-1" style={{ color: '#58a6ff' }}>=== 实时日志 ===</div>
{logs.map((log, idx) => (
<div key={idx} className="py-[1px]" style={{ color: log.level === 'error' ? '#f85149' : log.level === 'warn' ? '#d29922' : '#e6edf3' }}>
<span style={{ color: '#7d8590' }}>[{new Date(log.timestamp || '').toLocaleTimeString()}]</span> {log.message}
</div>
))}
</div>
)}
</div>
</div>
)}
{activeTab === 'pods' && record.podStatus && (
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-color)' }}>
<div className="grid grid-cols-[1fr_100px_80px_60px] gap-2 px-4 py-2 text-[11px] font-semibold" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
<div>Pod 名称</div><div>状态</div><div>重启次数</div><div>运行时间</div>
</div>
{record.podStatus.map((pod, idx) => (
<div key={idx} className="grid grid-cols-[1fr_100px_80px_60px] gap-2 px-4 py-2 border-t items-center text-[12px]" style={{ borderColor: 'var(--border-color-light)' }}>
<div className="font-mono" style={{ color: 'var(--text-primary)' }}>{pod.name}</div>
<div className="flex items-center gap-1">
<Container className="w-3 h-3" style={{ color: pod.status === 'Running' ? '#1a7f37' : '#cf222e' }} />
<span style={{ color: pod.status === 'Running' ? '#1a7f37' : '#cf222e' }}>{pod.status}</span>
</div>
<div style={{ color: pod.restarts > 0 ? '#cf222e' : 'var(--text-secondary)' }}>{pod.restarts}</div>
<div style={{ color: 'var(--text-tertiary)' }}>{pod.age}</div>
</div>
))}
</div>
)}
<div className="flex items-center gap-2 pt-3 mt-3 border-t" style={{ borderColor: 'var(--border-color-light)' }}>
<button onClick={() => toast.info('查看日志', { description: `${record.pipelineName} 完整日志` })}
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<Terminal className="w-3 h-3" />查看日志
</button>
{record.status === 'failed' && (
<button onClick={() => toast.success('重试发布', { description: `${record.pipelineName} 重新开始发布` })}
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<RotateCcw className="w-3 h-3" />重试
</button>
)}
<button onClick={() => toast.info('查看流水线', { description: `跳转至 ${record.pipelineName}` })}
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<ExternalLink className="w-3 h-3" />查看流水线
</button>
</div>
</div>
</div>
);
}
export default function ReleaseRecords() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [expandedId, setExpandedId] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<'stages' | 'logs' | 'pods'>('stages');
const filtered = mockRecords.filter(r => {
const matchSearch = r.pipelineName.includes(search) || r.id.includes(search);
const matchStatus = statusFilter === 'all' || r.status === statusFilter;
return matchSearch && matchStatus;
});
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-3">
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>发布记录</h1>
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>PipelineRun</span>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
<Search className="w-3.5 h-3.5 ml-2.5" style={{ color: 'var(--text-tertiary)' }} />
<input value={search} onChange={e => setSearch(e.target.value)} placeholder="搜索流水线或记录ID"
className="px-2 py-[5px] text-[12px] outline-none w-48" style={{ background: 'transparent', color: 'var(--text-primary)' }} />
</div>
<div className="relative">
<select value={statusFilter} onChange={e => setStatusFilter(e.target.value)}
className="px-3 py-[5px] text-[12px] border rounded-md outline-none appearance-none pr-7"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<option value="all">全部状态</option><option value="success">成功</option><option value="failed">失败</option><option value="running">执行中</option><option value="cancelled">已取消</option>
</select>
<Filter className="w-3 h-3 absolute right-2 top-1/2 -translate-y-1/2 pointer-events-none" style={{ color: 'var(--text-tertiary)' }} />
</div>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
<div className="space-y-2">
{filtered.map((record) => {
const status = statusConfig[record.status];
const StatusIcon = status.icon;
const tp = typeConfig[record.type];
const isExpanded = expandedId === record.id;
return (
<div key={record.id} className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
{/* Summary row */}
<div onClick={() => setExpandedId(isExpanded ? null : record.id)}
className="px-4 py-3 flex items-center gap-4 cursor-pointer hover:bg-black/[0.01] transition-colors">
<StatusIcon className={`w-4 h-4 flex-shrink-0 ${record.status === 'running' ? 'animate-pulse' : ''}`} style={{ color: status.color }} />
<div className="w-[130px]">
<div className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>{record.pipelineName}</div>
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{record.id}</div>
</div>
<div className="flex items-center gap-1 text-[12px] w-[100px]" style={{ color: 'var(--text-secondary)' }}>
<GitBranch className="w-3 h-3" /> {record.branch}
</div>
<div className="w-[80px]"><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: tp.bg, color: tp.color }}>{tp.label}</span></div>
<div className="text-[12px] w-[70px]" style={{ color: 'var(--text-tertiary)' }}>{record.env}</div>
<div className="flex items-center gap-1 text-[11px] w-[80px]" style={{ color: 'var(--text-tertiary)' }}><User className="w-3 h-3" />{record.triggerBy}</div>
<div className="flex items-center gap-1 text-[11px] w-[130px]" style={{ color: 'var(--text-tertiary)' }}><Calendar className="w-3 h-3" />{record.startTime}</div>
<div className="text-[12px] font-medium w-[70px] text-right" style={{ color: record.status === 'running' ? 'var(--accent-blue)' : 'var(--text-secondary)' }}>{record.duration}</div>
<ChevronRight className={`w-4 h-4 flex-shrink-0 transition-transform ${isExpanded ? 'rotate-90' : ''}`} style={{ color: 'var(--text-tertiary)' }} />
</div>
{/* Expanded detail */}
{isExpanded && (
<ExpandedRecord
record={record}
activeTab={activeTab}
setActiveTab={setActiveTab}
/>
)}
</div>
);
})}
</div>
</div>
</div>
);
}
+241
View File
@@ -0,0 +1,241 @@
import { useState } from 'react';
import {
Palette, Monitor, Moon, Sun,
Save, RotateCcw, CheckCircle2, Globe, Bell
} from 'lucide-react';
import { toast } from 'sonner';
interface ThemeConfig {
mode: 'light' | 'dark' | 'system';
primaryColor: string;
fontSize: 'small' | 'medium' | 'large';
density: 'compact' | 'comfortable' | 'spacious';
sidebarStyle: 'collapsed' | 'icons' | 'full';
borderRadius: 'sharp' | 'soft' | 'round';
}
const colorOptions = [
{ name: '蓝色', value: '#0969da', class: 'bg-[#0969da]' },
{ name: '绿色', value: '#1a7f37', class: 'bg-[#1a7f37]' },
{ name: '紫色', value: '#8250df', class: 'bg-[#8250df]' },
{ name: '橙色', value: '#bc4c00', class: 'bg-[#bc4c00]' },
{ name: '红色', value: '#cf222e', class: 'bg-[#cf222e]' },
{ name: '青色', value: '#0e7c7b', class: 'bg-[#0e7c7b]' },
];
export default function SystemSettings() {
const [theme, setTheme] = useState<ThemeConfig>({
mode: 'light',
primaryColor: '#0969da',
fontSize: 'medium',
density: 'comfortable',
sidebarStyle: 'full',
borderRadius: 'soft',
});
const [notifications, setNotifications] = useState({
email: true,
browser: true,
pipelineSuccess: false,
pipelineFailed: true,
securityAlert: true,
});
const handleSave = () => {
toast.success('设置已保存', { description: '主题和偏好设置已更新' });
};
const handleReset = () => {
setTheme({
mode: 'light',
primaryColor: '#0969da',
fontSize: 'medium',
density: 'comfortable',
sidebarStyle: 'full',
borderRadius: 'soft',
});
toast.info('已恢复默认设置');
};
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>系统设置</h1>
<div className="flex items-center gap-2">
<button onClick={handleReset} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
<RotateCcw className="w-3.5 h-3.5" />恢复默认
</button>
<button onClick={handleSave} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
<Save className="w-3.5 h-3.5" />保存设置
</button>
</div>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
<div className="max-w-3xl mx-auto space-y-4">
{/* Appearance */}
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="px-4 py-2.5 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
<Palette className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>外观主题</span>
</div>
<div className="p-4 space-y-4">
{/* Mode */}
<div>
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>主题模式</label>
<div className="flex items-center gap-2">
{[
{ id: 'light', label: '浅色', icon: Sun },
{ id: 'dark', label: '深色', icon: Moon },
{ id: 'system', label: '跟随系统', icon: Monitor },
].map(m => (
<button key={m.id} onClick={() => setTheme(p => ({ ...p, mode: m.id as any }))}
className="flex items-center gap-2 px-4 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
style={{
borderColor: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
background: theme.mode === m.id ? 'var(--accent-blue-light)' : 'transparent',
color: theme.mode === m.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}>
<m.icon className="w-3.5 h-3.5" />{m.label}
</button>
))}
</div>
</div>
{/* Primary color */}
<div>
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>主题色</label>
<div className="flex items-center gap-2">
{colorOptions.map(c => (
<button key={c.value} onClick={() => setTheme(p => ({ ...p, primaryColor: c.value }))}
className={`w-8 h-8 rounded-full ${c.class} flex items-center justify-center transition-transform hover:scale-110`}
title={c.name}>
{theme.primaryColor === c.value && <CheckCircle2 className="w-4 h-4 text-white" />}
</button>
))}
</div>
</div>
{/* Font size */}
<div>
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>字体大小</label>
<div className="flex items-center gap-2">
{[
{ id: 'small', label: '小', size: '12px' },
{ id: 'medium', label: '中', size: '14px' },
{ id: 'large', label: '大', size: '16px' },
].map(f => (
<button key={f.id} onClick={() => setTheme(p => ({ ...p, fontSize: f.id as any }))}
className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors text-center"
style={{
borderColor: theme.fontSize === f.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
background: theme.fontSize === f.id ? 'var(--accent-blue-light)' : 'transparent',
color: theme.fontSize === f.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
fontSize: f.size,
}}>
{f.label}
</button>
))}
</div>
</div>
{/* Density */}
<div>
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>界面密度</label>
<div className="flex items-center gap-2">
{[
{ id: 'compact', label: '紧凑' },
{ id: 'comfortable', label: '舒适' },
{ id: 'spacious', label: '宽松' },
].map(d => (
<button key={d.id} onClick={() => setTheme(p => ({ ...p, density: d.id as any }))}
className="flex-1 py-[7px] text-[12px] font-medium rounded-md border transition-colors"
style={{
borderColor: theme.density === d.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
background: theme.density === d.id ? 'var(--accent-blue-light)' : 'transparent',
color: theme.density === d.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}>
{d.label}
</button>
))}
</div>
</div>
{/* Border radius */}
<div>
<label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>圆角风格</label>
<div className="flex items-center gap-2">
{[
{ id: 'sharp', label: '直角', radius: '0px' },
{ id: 'soft', label: '圆角', radius: '6px' },
{ id: 'round', label: '大圆角', radius: '12px' },
].map(r => (
<button key={r.id} onClick={() => setTheme(p => ({ ...p, borderRadius: r.id as any }))}
className="flex-1 py-[7px] text-[12px] font-medium border transition-colors"
style={{
borderColor: theme.borderRadius === r.id ? 'var(--accent-blue)' : 'var(--border-color-light)',
background: theme.borderRadius === r.id ? 'var(--accent-blue-light)' : 'transparent',
color: theme.borderRadius === r.id ? 'var(--accent-blue)' : 'var(--text-secondary)',
borderRadius: r.radius,
}}>
{r.label}
</button>
))}
</div>
</div>
</div>
</div>
{/* Notifications */}
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="px-4 py-2.5 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
<Bell className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>通知设置</span>
</div>
<div className="p-4 space-y-3">
{[
{ key: 'email', label: '邮件通知', desc: '通过邮件接收重要通知' },
{ key: 'browser', label: '浏览器通知', desc: '在浏览器中显示推送通知' },
{ key: 'pipelineSuccess', label: '流水线成功通知', desc: '流水线执行成功时发送通知' },
{ key: 'pipelineFailed', label: '流水线失败通知', desc: '流水线执行失败时发送通知(推荐开启)' },
{ key: 'securityAlert', label: '安全警报', desc: '凭证泄露、异常登录等安全事件' },
].map(item => (
<label key={item.key} className="flex items-start gap-3 cursor-pointer">
<input type="checkbox" checked={(notifications as any)[item.key]}
onChange={e => setNotifications(p => ({ ...p, [item.key]: e.target.checked }))}
className="mt-0.5 w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
<div>
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{item.label}</div>
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{item.desc}</div>
</div>
</label>
))}
</div>
</div>
{/* Language & Region */}
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="px-4 py-2.5 border-b flex items-center gap-2" style={{ borderColor: 'var(--border-color)', background: 'var(--panel-bg-secondary)' }}>
<Globe className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>语言与区域</span>
</div>
<div className="p-4 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>界面语言</label>
<select className="w-full px-3 py-[6px] text-[13px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
<option>简体中文</option><option>English</option><option>日本語</option>
</select></div>
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>时区</label>
<select className="w-full px-3 py-[6px] text-[13px] rounded-md border outline-none" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
<option>Asia/Shanghai (UTC+8)</option><option>UTC</option><option>America/New_York</option>
</select></div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}
+427
View File
@@ -0,0 +1,427 @@
import { useState } from 'react';
import {
Save,
Download,
CheckCircle2,
XCircle,
Loader2,
Settings,
ChevronDown,
Monitor,
Baby,
RefreshCw,
Terminal
} from 'lucide-react';
import { toast } from 'sonner';
interface DevOpsTool {
id: string;
name: string;
key: string;
description: string;
category: 'build' | 'deploy';
status: 'not-installed' | 'installed' | 'installing';
version: string;
installMode: 'preinstalled' | 'lazy' | 'disabled';
path: string;
}
const initialTools: DevOpsTool[] = [
{
id: 'jdk',
name: 'JDK (Java)',
key: 'jdk',
description: 'Java 开发工具包,流水线 Java/Maven 构建阶段使用',
category: 'build',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/jdk',
},
{
id: 'maven',
name: 'Maven',
key: 'maven',
description: 'Java 项目构建工具,流水线 Java 构建阶段使用(依赖 JDK)',
category: 'build',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/maven',
},
{
id: 'golang',
name: 'Go (Golang)',
key: 'golang',
description: 'Go 语言工具链,流水线 go build/test 阶段使用',
category: 'build',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/go',
},
{
id: 'nvm',
name: 'NVM (Node 版本管理)',
key: 'nvm',
description: 'Node 版本管理器,流水线 Node.js 构建阶段使用(node 依赖此工具)',
category: 'build',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/nvm',
},
{
id: 'nodejs',
name: 'Node.js + npm',
key: 'nodejs',
description: '前端构建工具,流水线 npm install/build 阶段使用(通过 NVM 安装)',
category: 'build',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/node',
},
{
id: 'git',
name: 'Git',
key: 'git',
description: '版本控制工具,流水线代码拉取使用(首次使用自动安装)',
category: 'build',
status: 'installed',
version: '2.43.0',
installMode: 'preinstalled',
path: '/usr/bin/git',
},
{
id: 'docker',
name: 'Docker CLI',
key: 'docker-cli',
description: 'Docker 命令行工具,流水线镜像构建/推送使用(首次使用自动安装)',
category: 'deploy',
status: 'installed',
version: '24.0.7',
installMode: 'preinstalled',
path: '/usr/bin/docker',
},
{
id: 'kubectl',
name: 'kubectl',
key: 'kubectl',
description: 'Kubernetes 命令行工具,流水线 K8s 部署阶段使用',
category: 'deploy',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/kubectl',
},
{
id: 'helm',
name: 'Helm',
key: 'helm',
description: 'Kubernetes 包管理器,流水线 Helm 部署阶段使用',
category: 'deploy',
status: 'not-installed',
version: '',
installMode: 'lazy',
path: '/opt/tools/helm',
},
];
const categoryConfig = {
build: { label: '构建', color: '#9a6700', bg: '#fff8c5' },
deploy: { label: '部署', color: '#0969da', bg: '#ddf4ff' },
};
const statusConfig = {
'not-installed': { icon: XCircle, label: '未安装', color: '#6e7781', bg: '#f6f8fa' },
'installed': { icon: CheckCircle2, label: '已安装', color: '#1a7f37', bg: '#dafbe1' },
'installing': { icon: Loader2, label: '安装中', color: '#9a6700', bg: '#fff8c5' },
};
const installModeConfig = {
preinstalled: '预装在镜像中',
lazy: '首次使用自动下载',
disabled: '禁用不安装',
};
export default function ToolConfig() {
const [tools, setTools] = useState<DevOpsTool[]>(initialTools);
const [mode, setMode] = useState<'local' | 'jenkins'>('local');
const [installingId, setInstallingId] = useState<string | null>(null);
const [dropdownOpen, setDropdownOpen] = useState<string | null>(null);
const handleInstall = (toolId: string) => {
setInstallingId(toolId);
setTools(prev => prev.map(t => t.id === toolId ? { ...t, status: 'installing' } : t));
setTimeout(() => {
setTools(prev => prev.map(t =>
t.id === toolId
? { ...t, status: 'installed', version: getDefaultVersion(toolId) }
: t
));
setInstallingId(null);
toast.success('安装完成', { description: `${getToolName(toolId)} 安装成功` });
}, 2000);
};
const handleTest = (toolId: string) => {
const tool = tools.find(t => t.id === toolId);
if (tool?.status === 'installed') {
toast.success('测试通过', { description: `${tool.name} v${tool.version} 运行正常` });
} else {
toast.error('测试失败', { description: `${tool?.name || toolId} 未安装` });
}
};
const handleModeChange = (toolId: string, newMode: 'preinstalled' | 'lazy' | 'disabled') => {
setTools(prev => prev.map(t => t.id === toolId ? { ...t, installMode: newMode } : t));
setDropdownOpen(null);
toast.info('策略已更新', { description: `${getToolName(toolId)} 安装策略改为: ${installModeConfig[newMode]}` });
};
const getDefaultVersion = (id: string) => {
const versions: Record<string, string> = {
jdk: '17.0.9', maven: '3.9.6', golang: '1.22.0', nvm: '0.39.7',
nodejs: '20.11.0', git: '2.43.0', docker: '24.0.7', kubectl: '1.28.3', helm: '3.14.0',
};
return versions[id] || '1.0.0';
};
const getToolName = (id: string) => tools.find(t => t.id === id)?.name || id;
const installedCount = tools.filter(t => t.status === 'installed').length;
const notInstalledCount = tools.filter(t => t.status === 'not-installed').length;
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-3">
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>工具配置</h1>
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
{installedCount}/{tools.length}
</span>
</div>
<div className="flex items-center gap-2">
{/* Mode toggle */}
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
<button
onClick={() => setMode('local')}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors"
style={{
background: mode === 'local' ? 'var(--panel-bg-secondary)' : 'transparent',
color: mode === 'local' ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}
>
<Monitor className="w-3.5 h-3.5" />
本地模式
</button>
<button
onClick={() => setMode('jenkins')}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors"
style={{
background: mode === 'jenkins' ? 'var(--panel-bg-secondary)' : 'transparent',
color: mode === 'jenkins' ? 'var(--accent-blue)' : 'var(--text-secondary)',
}}
>
<Baby className="w-3.5 h-3.5" />
Jenkins 模式
</button>
</div>
<button
onClick={() => toast.success('刷新完成', { description: '工具状态已更新' })}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
>
<RefreshCw className="w-3.5 h-3.5" />
刷新
</button>
<button
onClick={() => toast.success('配置已保存')}
className="flex items-center gap-1.5 px-3 py-[5px] text-[12px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
>
<Save className="w-3.5 h-3.5" />
保存
</button>
</div>
</div>
{/* Description bar */}
<div className="px-5 py-2 border-b text-[12px] flex items-center gap-2" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>
<Terminal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
管理 DevOps 工具的安装策略:预装在镜像中、首次使用自动下载(懒加载)、或禁用不安装
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{/* Stats */}
<div className="grid grid-cols-4 gap-3 mb-4">
{[
{ label: '已安装', count: installedCount, color: '#1a7f37' },
{ label: '未安装', count: notInstalledCount, color: '#6e7781' },
{ label: '构建工具', count: tools.filter(t => t.category === 'build').length, color: '#9a6700' },
{ label: '部署工具', count: tools.filter(t => t.category === 'deploy').length, color: '#0969da' },
].map((stat) => (
<div key={stat.label} className="rounded-lg border p-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
<div className="text-[20px] font-bold" style={{ color: stat.color }}>{stat.count}</div>
</div>
))}
</div>
{/* Tool list - screenshot style */}
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
{/* Table header */}
<div
className="grid grid-cols-[1fr_100px_120px_280px] gap-4 px-4 py-2 text-[11px] font-semibold uppercase tracking-wider"
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
>
<div>工具配置</div>
<div>分类</div>
<div>状态</div>
<div className="text-right">操作</div>
</div>
{tools.map((tool) => {
const cat = categoryConfig[tool.category];
const status = statusConfig[tool.status];
const StatusIcon = status.icon;
const isInstalling = installingId === tool.id;
return (
<div
key={tool.id}
className="grid grid-cols-[1fr_100px_120px_280px] gap-4 px-4 py-3 border-t items-center"
style={{ borderColor: 'var(--border-color-light)' }}
>
{/* Tool info */}
<div>
<div className="text-[14px] font-semibold mb-0.5" style={{ color: 'var(--text-primary)' }}>{tool.name}</div>
<div className="text-[12px] font-mono mb-1" style={{ color: 'var(--text-tertiary)' }}>{tool.key}</div>
<div className="text-[11px] leading-relaxed" style={{ color: 'var(--text-secondary)' }}>{tool.description}</div>
{tool.status === 'installed' && (
<div className="text-[10px] mt-1 font-mono" style={{ color: 'var(--text-tertiary)' }}>路径: {tool.path}</div>
)}
</div>
{/* Category */}
<div>
<span className="text-[10px] px-2 py-[3px] rounded-full font-medium" style={{ background: cat.bg, color: cat.color }}>
{cat.label}
</span>
</div>
{/* Status */}
<div>
<div className="flex items-center gap-1.5">
<StatusIcon className={`w-3.5 h-3.5 ${isInstalling ? 'animate-spin' : ''}`} style={{ color: status.color }} />
<span className="text-[12px] font-medium" style={{ color: status.color }}>{status.label}</span>
</div>
{tool.status === 'installed' && (
<div className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>v{tool.version}</div>
)}
</div>
{/* Actions */}
<div className="flex items-center justify-end gap-2">
{/* Install / Installing button */}
{tool.status === 'not-installed' ? (
<button
onClick={() => handleInstall(tool.id)}
disabled={isInstalling}
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border transition-colors hover:bg-black/[0.02] disabled:opacity-50"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
>
<Download className={`w-3.5 h-3.5 ${isInstalling ? 'animate-bounce' : ''}`} />
{isInstalling ? '安装中...' : '安装'}
</button>
) : tool.status === 'installing' ? (
<button
disabled
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border opacity-50"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
>
<Loader2 className="w-3.5 h-3.5 animate-spin" />
安装中...
</button>
) : (
<button
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)', background: 'var(--panel-bg-secondary)' }}
>
<CheckCircle2 className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
已安装
</button>
)}
{/* Version / Install strategy dropdown */}
<div className="relative">
<button
onClick={() => setDropdownOpen(dropdownOpen === tool.id ? null : tool.id)}
className="flex items-center gap-1 px-2 py-[5px] text-[11px] rounded-md border"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
>
{tool.status === 'installed' ? tool.version : installModeConfig[tool.installMode]}
<ChevronDown className="w-3 h-3" />
</button>
{dropdownOpen === tool.id && (
<>
<div className="fixed inset-0 z-40" onClick={() => setDropdownOpen(null)} />
<div
className="absolute right-0 top-full mt-1 py-1 rounded-md border shadow-lg z-50 min-w-[160px]"
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
>
{tool.status !== 'installed' && (
<>
<div className="px-3 py-1 text-[10px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>安装策略</div>
{(Object.keys(installModeConfig) as Array<keyof typeof installModeConfig>).map((modeKey) => (
<button
key={modeKey}
onClick={() => handleModeChange(tool.id, modeKey)}
className="w-full text-left px-3 py-[6px] text-[12px] hover:bg-black/5 transition-colors"
style={{ color: tool.installMode === modeKey ? 'var(--accent-blue)' : 'var(--text-secondary)' }}
>
{installModeConfig[modeKey]}
</button>
))}
<div className="border-t my-1" style={{ borderColor: 'var(--border-color-light)' }} />
</>
)}
<div className="px-3 py-1 text-[10px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>版本</div>
<button className="w-full text-left px-3 py-[6px] text-[12px] hover:bg-black/5 transition-colors" style={{ color: 'var(--text-secondary)' }}>
{getDefaultVersion(tool.id)} (默认)
</button>
</div>
</>
)}
</div>
{/* Test button */}
<button
onClick={() => handleTest(tool.id)}
className="px-3 py-[5px] text-[12px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
>
测试
</button>
{/* Settings button */}
<button
onClick={() => toast.info('工具配置', { description: `${tool.name} 高级配置功能开发中...` })}
className="p-[5px] rounded-md border transition-colors hover:bg-black/[0.02]"
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}
>
<Settings className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}
+225
View File
@@ -0,0 +1,225 @@
import { useState } from 'react';
import {
Webhook, Plus, Trash2, Edit2, ToggleLeft, ToggleRight,
CheckCircle2, AlertTriangle,
Send
} from 'lucide-react';
import { toast } from 'sonner';
interface WebhookItem {
id: string;
name: string;
url: string;
events: string[];
secret: string;
active: boolean;
lastTriggered: string;
lastStatus: 'success' | 'failed' | 'none';
}
const initialWebhooks: WebhookItem[] = [
{
id: 'wh-1',
name: '飞书群通知',
url: 'https://open.feishu.cn/open-apis/bot/v2/hook/xxxxx',
events: ['pipeline.success', 'pipeline.failed'],
secret: 'whsec_xxxxxxxxxxxx',
active: true,
lastTriggered: '2024-10-26 14:30:15',
lastStatus: 'success',
},
{
id: 'wh-2',
name: '钉钉群通知',
url: 'https://oapi.dingtalk.com/robot/send?access_token=xxxxx',
events: ['pipeline.success', 'pipeline.failed', 'pipeline.started'],
secret: 'SEC_xxxxxxxxxxxx',
active: true,
lastTriggered: '2024-10-26 14:15:10',
lastStatus: 'success',
},
{
id: 'wh-3',
name: '自定义回调',
url: 'https://api.example.com/webhook/cicd',
events: ['pipeline.failed'],
secret: '',
active: false,
lastTriggered: '从未触发',
lastStatus: 'none',
},
];
const eventOptions = [
{ value: 'pipeline.started', label: '流水线开始', desc: '当流水线开始执行时触发' },
{ value: 'pipeline.success', label: '流水线成功', desc: '当流水线执行成功时触发' },
{ value: 'pipeline.failed', label: '流水线失败', desc: '当流水线执行失败时触发' },
{ value: 'pipeline.cancelled', label: '流水线取消', desc: '当流水线被取消时触发' },
{ value: 'stage.started', label: '阶段开始', desc: '当某个阶段开始执行时触发' },
{ value: 'stage.completed', label: '阶段完成', desc: '当某个阶段执行完成时触发' },
{ value: 'deploy.success', label: '部署成功', desc: '当部署阶段成功时触发' },
{ value: 'deploy.failed', label: '部署失败', desc: '当部署阶段失败时触发' },
];
export default function WebhookConfig() {
const [webhooks, setWebhooks] = useState<WebhookItem[]>(initialWebhooks);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState({ name: '', url: '', secret: '', events: [] as string[] });
const handleToggle = (id: string) => {
setWebhooks(prev => prev.map(w => w.id === id ? { ...w, active: !w.active } : w));
};
const handleDelete = (id: string) => {
setWebhooks(prev => prev.filter(w => w.id !== id));
toast.success('Webhook 已删除');
};
const handleSave = () => {
if (!form.name || !form.url) {
toast.error('请填写名称和 URL');
return;
}
if (editingId) {
setWebhooks(prev => prev.map(w => w.id === editingId ? { ...w, ...form } : w));
toast.success('Webhook 已更新');
} else {
setWebhooks(prev => [...prev, { ...form, id: `wh-${Date.now()}`, active: true, lastTriggered: '从未触发', lastStatus: 'none' }]);
toast.success('Webhook 已创建');
}
setShowForm(false);
setEditingId(null);
setForm({ name: '', url: '', secret: '', events: [] });
};
const startEdit = (w: WebhookItem) => {
setForm({ name: w.name, url: w.url, secret: w.secret, events: [...w.events] });
setEditingId(w.id);
setShowForm(true);
};
const toggleEvent = (event: string) => {
setForm(prev => ({
...prev,
events: prev.events.includes(event) ? prev.events.filter(e => e !== event) : [...prev.events, event],
}));
};
return (
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
{/* Header */}
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-center gap-3">
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>Webhook 配置</h1>
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
{webhooks.length}
</span>
</div>
<button
onClick={() => { setShowForm(true); setEditingId(null); setForm({ name: '', url: '', secret: '', events: [] }); }}
className="flex items-center gap-1.5 px-3 py-[5px] text-[12px] font-medium rounded-md text-white"
style={{ background: 'var(--accent-blue)' }}
>
<Plus className="w-3.5 h-3.5" />新建 Webhook
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
{showForm && (
<div className="rounded-lg border p-4 mb-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>{editingId ? '编辑 Webhook' : '新建 Webhook'}</h3>
<div className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>名称</label>
<input value={form.name} onChange={e => setForm(p => ({ ...p, name: e.target.value }))}
placeholder="如:飞书群通知" className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>Webhook URL</label>
<input value={form.url} onChange={e => setForm(p => ({ ...p, url: e.target.value }))}
placeholder="https://..." className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
</div>
<div><label className="block text-[12px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>签名密钥(可选)</label>
<input value={form.secret} onChange={e => setForm(p => ({ ...p, secret: e.target.value }))}
placeholder="用于验证请求来源" className="w-full px-3 py-[6px] rounded-md border text-[13px] outline-none"
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }} /></div>
<div><label className="block text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>订阅事件</label>
<div className="grid grid-cols-2 gap-2">
{eventOptions.map(evt => (
<label key={evt.value} className="flex items-start gap-2 p-2 rounded-md border cursor-pointer hover:bg-black/[0.02]"
style={{ borderColor: 'var(--border-color-light)', background: form.events.includes(evt.value) ? 'var(--accent-blue-light)' : 'transparent' }}>
<input type="checkbox" checked={form.events.includes(evt.value)} onChange={() => toggleEvent(evt.value)} className="mt-0.5 w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
<div><div className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{evt.label}</div><div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{evt.desc}</div></div>
</label>
))}
</div></div>
<div className="flex justify-end gap-2 pt-2">
<button onClick={() => setShowForm(false)} className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>取消</button>
<button onClick={handleSave} className="px-3 py-[5px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>保存</button>
</div>
</div>
</div>
)}
<div className="space-y-2">
{webhooks.map((wh) => (
<div key={wh.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{wh.name}</span>
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: wh.active ? '#dafbe1' : '#f6f8fa', color: wh.active ? '#1a7f37' : '#6e7781' }}>
{wh.active ? '已启用' : '已禁用'}
</span>
</div>
<div className="flex items-center gap-1 text-[12px] font-mono mb-2" style={{ color: 'var(--text-tertiary)' }}>
<Webhook className="w-3 h-3" /> {wh.url}
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{wh.events.map(evt => {
const opt = eventOptions.find(o => o.value === evt);
return (
<span key={evt} className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
{opt?.label || evt}
</span>
);
})}
</div>
</div>
<div className="flex items-center gap-1">
<button onClick={() => toast.success('测试发送', { description: `${wh.name} 测试消息已发送` })}
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="测试">
<Send className="w-3.5 h-3.5" />
</button>
<button onClick={() => startEdit(wh)}
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="编辑">
<Edit2 className="w-3.5 h-3.5" />
</button>
<button onClick={() => handleToggle(wh.id)}
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: wh.active ? 'var(--accent-blue)' : 'var(--text-tertiary)' }} title={wh.active ? '禁用' : '启用'}>
{wh.active ? <ToggleRight className="w-4 h-4" /> : <ToggleLeft className="w-4 h-4" />}
</button>
<button onClick={() => handleDelete(wh.id)}
className="p-1.5 rounded-md transition-colors hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }} title="删除">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
</div>
<div className="flex items-center gap-4 mt-2 pt-2 border-t text-[11px]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-tertiary)' }}>
<span>最后触发: {wh.lastTriggered}</span>
{wh.lastStatus !== 'none' && (
<span className="flex items-center gap-1">
状态: {wh.lastStatus === 'success' ? <CheckCircle2 className="w-3 h-3" style={{ color: '#1a7f37' }} /> : <AlertTriangle className="w-3 h-3" style={{ color: '#cf222e' }} />}
{wh.lastStatus === 'success' ? '成功' : '失败'}
</span>
)}
</div>
</div>
))}
</div>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 5 * 1000,
},
},
});
export function QueryProvider({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}
+95
View File
@@ -0,0 +1,95 @@
import type { LucideIcon } from 'lucide-react';
export type NodeType = 'source' | 'build' | 'deploy' | 'test' | 'approval' | 'custom';
export type NodeStatus = 'pending' | 'running' | 'success' | 'failed' | 'skipped';
export type ScriptType = 'shell' | 'python';
export interface PostHook {
id: string;
type: 'cdn_refresh' | 'custom_webhook' | 'custom_script';
enabled: boolean;
config: Record<string, string>;
}
export interface PipelineTask {
id: string;
name: string;
type: 'build' | 'deploy' | 'test' | 'custom';
icon: string; // lucide icon name
config: Record<string, string>;
script?: string;
scriptType?: ScriptType;
postHooks?: PostHook[];
}
export interface PipelineStage {
id: string;
name: string;
type: 'source' | 'build' | 'deploy' | 'test' | 'custom';
tasks: PipelineTask[];
}
export interface PipelineNode {
id: string;
name: string;
subtitle?: string;
type: NodeType;
icon: LucideIcon | string;
status: NodeStatus;
position: { x: number; y: number };
config?: Record<string, string>;
scriptType?: ScriptType;
script?: string;
postHooks?: PostHook[];
}
export interface PipelineConnection {
id: string;
from: string;
to: string;
status: NodeStatus;
}
export interface PipelineData {
name: string;
stages: PipelineStage[];
// Legacy fields for backwards compat
nodes?: PipelineNode[];
connections?: PipelineConnection[];
}
export interface ComponentCategory {
id: string;
name: string;
icon: LucideIcon;
items: ComponentItem[];
}
export interface ComponentItem {
id: string;
name: string;
type: NodeType;
icon: LucideIcon;
description?: string;
}
export type ThemeMode = 'light' | 'dark';
export interface PlacementItem {
id: string;
name: string;
type: NodeType;
icon: LucideIcon;
}
export interface ExecutionRecord {
id: number;
status: 'running' | 'success' | 'failed' | 'pending';
trigger: 'Git Push' | 'Manual' | 'Cron';
triggerBy: string;
duration: string;
timestamp: string;
pipelineName: string;
}