init: EZOps AIOps 智能运维平台

- 前端: React 18 + TypeScript + Vite + Tailwind CSS + shadcn/ui
- 后端: Go + Gin + GORM + JWT (golang-jwt/jwt/v5)
- 功能: 服务器管理、K8s集群、监控告警、CI/CD、自动化运维、审批流程、审计日志
- 文档: 设计文档(ROADMAP/PDD/架构/模块)
This commit is contained in:
attacker committed 2026-06-13 23:09:47 +08:00
commit 7d6674bddd
156 files changed
+45704

No files matched your search

+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "postcss.config.js",
"css": "src/index.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+12
View File
@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AIOps智能运维平台</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
Using Node.js 20, Tailwind CSS v3.4.19, and Vite v7.2.4
Tailwind CSS has been set up with the shadcn theme
Setup complete: /mnt/okcomputer/output/app
Components (40+):
accordion, alert-dialog, alert, aspect-ratio, avatar, badge, breadcrumb,
button-group, button, calendar, card, carousel, chart, checkbox, collapsible,
command, context-menu, dialog, drawer, dropdown-menu, empty, field, form,
hover-card, input-group, input-otp, input, item, kbd, label, menubar,
navigation-menu, pagination, popover, progress, radio-group, resizable,
scroll-area, select, separator, sheet, sidebar, skeleton, slider, sonner,
spinner, switch, table, tabs, textarea, toggle-group, toggle, tooltip
Usage:
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardTitle } from '@/components/ui/card'
Structure:
src/sections/ Page sections
src/hooks/ Custom hooks
src/types/ Type definitions
src/App.css Styles specific to the Webapp
src/App.tsx Root React component
src/index.css Global styles
src/main.tsx Entry point for rendering the Webapp
index.html Entry point for the Webapp
tailwind.config.js Configures Tailwind's theme, plugins, etc.
vite.config.ts Main build and dev server settings for Vite
postcss.config.js Config file for CSS post-processing tools
+8432
View File
File diff suppressed because it is too large. Load diff
+87
View File
@@ -0,0 +1,87 @@
{
"name": "my-app",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-aspect-ratio": "^1.1.8",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-context-menu": "^2.2.16",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-hover-card": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-menubar": "^1.1.16",
"@radix-ui/react-navigation-menu": "^1.2.14",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-radio-group": "^1.3.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slider": "^1.3.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-toggle": "^1.1.10",
"@radix-ui/react-toggle-group": "^1.1.11",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-query": "^5.95.2",
"@tanstack/react-query-devtools": "^5.95.2",
"@types/react-router-dom": "^5.3.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"date-fns": "^4.1.0",
"embla-carousel-react": "^8.6.0",
"input-otp": "^1.4.2",
"lucide-react": "^0.562.0",
"next-themes": "^0.4.6",
"react": "^19.2.0",
"react-day-picker": "^9.13.0",
"react-dom": "^19.2.0",
"react-hook-form": "^7.70.0",
"react-resizable-panels": "^4.2.2",
"react-router-dom": "^7.13.2",
"recharts": "^2.15.4",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"vaul": "^1.1.2",
"xterm": "^5.3.0",
"xterm-addon-fit": "^0.8.0",
"xterm-addon-web-links": "^0.9.0",
"zod": "^4.3.5",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.12.0",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"kimi-plugin-inspect-react": "^1.0.3",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
+42
View File
@@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}
+104
View File
@@ -0,0 +1,104 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { MainLayout } from '@/components/layout/MainLayout';
import { Dashboard } from '@/components/dashboard/Dashboard';
import { ServerManager } from '@/components/server/ServerManager';
import { WebShell } from '@/components/server/WebShell';
import { BatchImport } from '@/components/server/BatchImport';
import { ResourceModelManager } from '@/components/server/ResourceModelManager';
import { CloudAccountManager } from '@/components/cloud/CloudAccountManager';
import { DomainManager } from '@/components/cloud/DomainManager';
import { RegionManager } from '@/components/cloud/RegionManager';
import { PlatformManager } from '@/components/cloud/PlatformManager';
import { K8sClusters } from '@/components/k8s/K8sClusters';
import { TopologyView } from '@/components/k8s/TopologyView';
import { AlertManager } from '@/components/monitor/AlertManager';
import { AppReleaseManager } from '@/components/deploy/AppRelease';
import { ApprovalFlowManager } from '@/components/approval/ApprovalFlow';
import { NavigationPage } from '@/components/navigation/NavigationPage';
import { AutomationManager } from '@/components/automation/Automation';
import { SystemManager } from '@/components/system/SystemManager';
import { PipelineManager } from '@/components/deploy/PipelineManager';
import { ReleaseManager } from '@/components/deploy/ReleaseManager';
import { CronJobManager } from '@/components/automation/CronJobManager';
import AnsibleManager from '@/components/automation/AnsibleManager';
import { AIOpsAssistant } from '@/components/aiops/AIOpsAssistant';
import { AIOpsHub } from '@/components/aiops/AIOpsHub';
import { AIAlertAnalyzer } from '@/components/aiops/AIAlertAnalyzer';
import { AIScriptGenerator } from '@/components/aiops/AIScriptGenerator';
import { AIFaultDiagnosis } from '@/components/aiops/AIFaultDiagnosis';
import { AIPredictiveAnalytics } from '@/components/aiops/AIPredictiveAnalytics';
import SQLAuditManager from '@/components/audit/SQLAuditManager';
import LogManager from '@/components/monitor/logs/LogManager';
import AlertCenter from '@/components/monitor/alert-center/AlertCenter';
import TraceManager from '@/components/monitor/trace/TraceManager';
import GrafanaDashboard from '@/components/monitor/dashboard/GrafanaDashboard';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
retry: 1,
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/" element={<MainLayout />}>
<Route index element={<Dashboard />} />
<Route path="servers" element={<ServerManager />} />
<Route path="webshell" element={<WebShell />} />
<Route path="batch-import" element={<BatchImport />} />
<Route path="resource-models" element={<ResourceModelManager />} />
<Route path="cloud-accounts" element={<CloudAccountManager />} />
<Route path="domains" element={<DomainManager />} />
<Route path="regions" element={<RegionManager />} />
<Route path="platforms" element={<PlatformManager />} />
<Route path="clusters" element={<K8sClusters />} />
<Route path="nodes" element={<K8sClusters />} />
<Route path="workloads" element={<K8sClusters />} />
<Route path="topology" element={<TopologyView />} />
{/* 应用发布 */}
<Route path="pipelines" element={<PipelineManager />} />
<Route path="releases" element={<ReleaseManager />} />
<Route path="deploy" element={<AppReleaseManager />} />
{/* 监控告警 */}
<Route path="metrics" element={<AlertManager />} />
<Route path="alert-center" element={<AlertCenter />} />
<Route path="logs-monitor" element={<LogManager />} />
<Route path="trace" element={<TraceManager />} />
<Route path="grafana" element={<GrafanaDashboard />} />
<Route path="alerts" element={<AlertManager />} />
<Route path="logs" element={<AlertManager />} />
<Route path="approval" element={<ApprovalFlowManager />} />
<Route path="navigation" element={<NavigationPage />} />
{/* 自动化运维 */}
<Route path="cronjobs" element={<CronJobManager />} />
<Route path="scripts" element={<AutomationManager />} />
<Route path="ansible" element={<AnsibleManager />} />
<Route path="playbooks" element={<AutomationManager />} />
{/* AIOps智能 */}
<Route path="ai-hub" element={<AIOpsHub />} />
<Route path="ai-assistant" element={<AIOpsAssistant />} />
<Route path="ai-alert-analyzer" element={<AIAlertAnalyzer />} />
<Route path="ai-script-generator" element={<AIScriptGenerator />} />
<Route path="ai-fault-diagnosis" element={<AIFaultDiagnosis />} />
<Route path="ai-predictive" element={<AIPredictiveAnalytics />} />
<Route path="sql-audit" element={<SQLAuditManager />} />
<Route path="users" element={<SystemManager />} />
<Route path="roles" element={<SystemManager />} />
<Route path="logs-audit" element={<SystemManager />} />
<Route path="settings" element={<SystemManager />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
}
export default App;
@@ -0,0 +1,479 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
AlertTriangle,
Brain,
CheckCircle,
XCircle,
Clock,
Server,
Cpu,
MemoryStick,
HardDrive,
Network,
Zap,
FileText,
Terminal,
TrendingUp,
AlertCircle,
Sparkles,
Play,
Copy,
Check,
Loader2,
} from 'lucide-react';
interface AlertItem {
id: string;
name: string;
severity: 'critical' | 'warning' | 'info';
status: 'active' | 'resolved';
source: string;
metric: string;
threshold: string;
currentValue: string;
duration: string;
startedAt: string;
description: string;
}
interface AIAnalysis {
rootCause: string;
impact: string;
recommendations: string[];
autoFixScript?: string;
similarCases: number;
confidence: number;
}
const mockAlerts: AlertItem[] = [
{
id: 'ALT-001',
name: 'CPU使用率过高',
severity: 'critical',
status: 'active',
source: 'web-server-03',
metric: 'cpu_usage',
threshold: '80%',
currentValue: '96.5%',
duration: '15分钟',
startedAt: '2025-04-14 14:30:00',
description: '服务器CPU使用率持续超过阈值',
},
{
id: 'ALT-002',
name: '内存使用率告警',
severity: 'warning',
status: 'active',
source: 'db-master-01',
metric: 'memory_usage',
threshold: '85%',
currentValue: '89.2%',
duration: '8分钟',
startedAt: '2025-04-14 14:37:00',
description: '数据库服务器内存使用率接近上限',
},
{
id: 'ALT-003',
name: '磁盘空间不足',
severity: 'critical',
status: 'active',
source: 'log-server-02',
metric: 'disk_usage',
threshold: '90%',
currentValue: '94.8%',
duration: '32分钟',
startedAt: '2025-04-14 14:13:00',
description: '日志服务器磁盘空间即将耗尽',
},
{
id: 'ALT-004',
name: '网络延迟过高',
severity: 'warning',
status: 'active',
source: 'api-gateway-01',
metric: 'network_latency',
threshold: '100ms',
currentValue: '245ms',
duration: '5分钟',
startedAt: '2025-04-14 14:40:00',
description: 'API网关响应延迟异常',
},
];
const mockAIAnalysis: Record<string, AIAnalysis> = {
'ALT-001': {
rootCause: '检测到web-server-03上运行的Java应用出现内存泄漏,导致GC频繁触发,CPU使用率飙升。具体为订单服务(order-service)的某个定时任务未正确释放资源。',
impact: '影响用户下单体验,响应时间从平均200ms增加到1200ms,预计影响约35%的在线用户。',
recommendations: [
'立即重启order-service服务释放内存(预计恢复时间2分钟)',
'检查并修复定时任务中的资源释放逻辑',
'考虑临时扩容到web-server-04分流部分流量',
'调整JVM堆内存参数,增加GC间隔',
],
autoFixScript: `#!/bin/bash
# 自动修复脚本 - CPU过高问题
echo "正在诊断问题..."
PID=$(pgrep -f "order-service" | head -1)
echo "发现进程 PID: $PID"
echo "生成堆转储..."
jmap -dump:format=b,file=/tmp/heap_dump.hprof $PID
echo "重启服务..."
systemctl restart order-service
echo "服务已重启,监控中..."
`,
similarCases: 12,
confidence: 92,
},
'ALT-002': {
rootCause: '数据库连接池配置不当,连接数达到上限,导致内存持续增长。同时存在几个慢查询未优化。',
impact: '数据库响应变慢,部分查询超时,可能影响订单和库存模块。',
recommendations: [
'优化慢查询SQL,添加适当索引',
'调整连接池大小配置',
'考虑启用查询缓存',
'分析并优化大表查询',
],
similarCases: 8,
confidence: 87,
},
'ALT-003': {
rootCause: '日志文件未配置自动清理策略,/var/log目录下日志文件累积超过100GB。',
impact: '磁盘空间不足可能导致新日志无法写入,影响故障排查。',
recommendations: [
'清理30天前的日志文件',
'配置logrotate自动轮转策略',
'考虑将历史日志归档到对象存储',
'设置磁盘使用率告警阈值',
],
autoFixScript: `#!/bin/bash
# 自动清理日志脚本
echo "清理30天前的日志..."
find /var/log -name "*.log" -mtime +30 -delete
find /var/log -name "*.log.*" -mtime +30 -delete
echo "压缩7-30天的日志..."
find /var/log -name "*.log" -mtime +7 -mtime -30 -exec gzip {} \;
echo "清理完成"
df -h /var/log
`,
similarCases: 23,
confidence: 95,
},
'ALT-004': {
rootCause: '上游依赖服务payment-service响应变慢,导致API网关等待时间增加。',
impact: '所有经过网关的请求都受到影响,用户体验下降。',
recommendations: [
'检查payment-service的健康状态',
'增加API网关超时配置',
'考虑启用熔断机制',
'优化payment-service的性能',
],
similarCases: 5,
confidence: 78,
},
};
export function AIAlertAnalyzer() {
const [selectedAlert, setSelectedAlert] = useState<AlertItem | null>(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analysis, setAnalysis] = useState<AIAnalysis | null>(null);
const [showScriptDialog, setShowScriptDialog] = useState(false);
const [copied, setCopied] = useState(false);
const handleAnalyze = (alert: AlertItem) => {
setSelectedAlert(alert);
setIsAnalyzing(true);
setAnalysis(null);
// 模拟AI分析
setTimeout(() => {
setAnalysis(mockAIAnalysis[alert.id] || null);
setIsAnalyzing(false);
}, 2000);
};
const handleAutoFix = () => {
setShowScriptDialog(true);
};
const copyScript = () => {
if (analysis?.autoFixScript) {
navigator.clipboard.writeText(analysis.autoFixScript);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const getSeverityColor = (severity: string) => {
switch (severity) {
case 'critical':
return 'bg-red-500/10 text-red-400 border-red-500/30';
case 'warning':
return 'bg-amber-500/10 text-amber-400 border-amber-500/30';
default:
return 'bg-blue-500/10 text-blue-400 border-blue-500/30';
}
};
const getSeverityIcon = (severity: string) => {
switch (severity) {
case 'critical':
return <XCircle className="h-4 w-4 text-red-400" />;
case 'warning':
return <AlertCircle className="h-4 w-4 text-amber-400" />;
default:
return <CheckCircle className="h-4 w-4 text-blue-400" />;
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-amber-500 to-orange-500 flex items-center justify-center">
<Brain className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-100">AI 告警智能分析</h1>
<p className="text-sm text-slate-500">基于大模型的告警根因分析和自动修复建议</p>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<Sparkles className="h-3 w-3 mr-1" />
AI 引擎运行中
</Badge>
</div>
</div>
<div className="grid grid-cols-2 gap-6 h-[calc(100vh-200px)]">
{/* Alert List */}
<Card className="bg-slate-800 border-slate-700 flex flex-col">
<CardHeader className="border-b border-slate-700 pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-slate-100 flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-amber-400" />
活跃告警
</CardTitle>
<Badge className="bg-red-500/10 text-red-400 border-red-500/30">
{mockAlerts.filter(a => a.status === 'active').length} 个活跃
</Badge>
</div>
</CardHeader>
<ScrollArea className="flex-1">
<CardContent className="p-4 space-y-3">
{mockAlerts.map((alert) => (
<div
key={alert.id}
className={`p-4 rounded-lg border cursor-pointer transition-all ${
selectedAlert?.id === alert.id
? 'border-purple-500 bg-purple-500/10'
: 'border-slate-700 bg-slate-800/50 hover:border-slate-600'
}`}
onClick={() => handleAnalyze(alert)}
>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2">
{getSeverityIcon(alert.severity)}
<span className="font-medium text-slate-100">{alert.name}</span>
</div>
<Badge variant="outline" className={getSeverityColor(alert.severity)}>
{alert.severity === 'critical' ? '严重' : alert.severity === 'warning' ? '警告' : '提示'}
</Badge>
</div>
<div className="text-sm text-slate-400 mb-2">{alert.description}</div>
<div className="flex items-center gap-4 text-xs text-slate-500">
<span className="flex items-center gap-1">
<Server className="h-3 w-3" />
{alert.source}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{alert.duration}
</span>
<span className="flex items-center gap-1">
<TrendingUp className="h-3 w-3" />
{alert.currentValue}
</span>
</div>
</div>
))}
</CardContent>
</ScrollArea>
</Card>
{/* AI Analysis Result */}
<Card className="bg-slate-800 border-slate-700 flex flex-col">
<CardHeader className="border-b border-slate-700 pb-3">
<CardTitle className="text-slate-100 flex items-center gap-2">
<Sparkles className="h-5 w-5 text-purple-400" />
AI 分析结果
</CardTitle>
</CardHeader>
<ScrollArea className="flex-1">
<CardContent className="p-4">
{!selectedAlert ? (
<div className="flex flex-col items-center justify-center h-64 text-slate-500">
<Brain className="h-16 w-16 mb-4 opacity-30" />
<p>选择左侧告警进行AI智能分析</p>
</div>
) : isAnalyzing ? (
<div className="flex flex-col items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-purple-400 mb-4" />
<p className="text-slate-400">AI正在分析告警根因...</p>
<p className="text-xs text-slate-500 mt-2">正在关联日志、指标和历史案例</p>
</div>
) : analysis ? (
<div className="space-y-4">
{/* Confidence Score */}
<div className="flex items-center justify-between p-3 bg-purple-500/10 rounded-lg border border-purple-500/30">
<div className="flex items-center gap-2">
<Brain className="h-5 w-5 text-purple-400" />
<span className="text-slate-300">分析置信度</span>
</div>
<div className="flex items-center gap-2">
<div className="w-32 h-2 bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-500 to-pink-500"
style={{ width: `${analysis.confidence}%` }}
/>
</div>
<span className="text-purple-400 font-bold">{analysis.confidence}%</span>
</div>
</div>
{/* Root Cause */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-slate-300 flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-400" />
根因分析
</h4>
<div className="p-3 bg-slate-900 rounded-lg border border-slate-700">
<p className="text-sm text-slate-300 leading-relaxed">{analysis.rootCause}</p>
</div>
</div>
{/* Impact */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-slate-300 flex items-center gap-2">
<TrendingUp className="h-4 w-4 text-amber-400" />
影响范围
</h4>
<div className="p-3 bg-slate-900 rounded-lg border border-slate-700">
<p className="text-sm text-slate-300 leading-relaxed">{analysis.impact}</p>
</div>
</div>
{/* Recommendations */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-slate-300 flex items-center gap-2">
<CheckCircle className="h-4 w-4 text-emerald-400" />
修复建议
</h4>
<div className="space-y-2">
{analysis.recommendations.map((rec, idx) => (
<div
key={idx}
className="flex items-start gap-2 p-2 bg-slate-900 rounded-lg border border-slate-700"
>
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-xs">
{idx + 1}
</span>
<span className="text-sm text-slate-300">{rec}</span>
</div>
))}
</div>
</div>
{/* Similar Cases */}
<div className="flex items-center gap-2 p-3 bg-slate-900 rounded-lg border border-slate-700">
<FileText className="h-4 w-4 text-blue-400" />
<span className="text-sm text-slate-400">
发现 <span className="text-blue-400 font-medium">{analysis.similarCases}</span> 个相似历史案例
</span>
</div>
{/* Auto Fix */}
{analysis.autoFixScript && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-slate-300 flex items-center gap-2">
<Terminal className="h-4 w-4 text-purple-400" />
自动修复脚本
</h4>
<div className="p-3 bg-slate-950 rounded-lg border border-slate-700">
<pre className="text-xs text-slate-400 overflow-x-auto">{analysis.autoFixScript}</pre>
</div>
<Button
className="w-full bg-purple-600 hover:bg-purple-700"
onClick={handleAutoFix}
>
<Play className="h-4 w-4 mr-2" />
执行自动修复
</Button>
</div>
)}
</div>
) : null}
</CardContent>
</ScrollArea>
</Card>
</div>
{/* Auto Fix Dialog */}
<Dialog open={showScriptDialog} onOpenChange={setShowScriptDialog}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-700 text-slate-100">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5 text-purple-400" />
自动修复脚本
</DialogTitle>
<DialogDescription className="text-slate-500">
AI生成的自动修复脚本,请确认后执行
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="p-4 bg-slate-950 rounded-lg border border-slate-700">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-slate-400">修复脚本</span>
<Button variant="ghost" size="sm" onClick={copyScript} className="text-slate-400">
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
<pre className="text-sm text-slate-300 overflow-x-auto whitespace-pre-wrap">
{analysis?.autoFixScript}
</pre>
</div>
<div className="flex items-center gap-2 p-3 bg-amber-500/10 rounded-lg border border-amber-500/30">
<AlertTriangle className="h-4 w-4 text-amber-400" />
<span className="text-sm text-amber-400">
警告:执行脚本前请确保已备份重要数据
</span>
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" className="flex-1 border-slate-700 text-slate-300" onClick={() => setShowScriptDialog(false)}>
取消
</Button>
<Button className="flex-1 bg-purple-600 hover:bg-purple-700" onClick={() => setShowScriptDialog(false)}>
<Play className="h-4 w-4 mr-2" />
确认执行
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,493 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Textarea } from '@/components/ui/textarea';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion';
import {
Brain,
Search,
AlertTriangle,
CheckCircle,
XCircle,
Clock,
Server,
Activity,
FileText,
Terminal,
TrendingUp,
AlertCircle,
Sparkles,
Cpu,
MemoryStick,
HardDrive,
Network,
Database,
Globe,
Shield,
Zap,
Lightbulb,
ChevronRight,
Loader2,
MessageSquare,
History,
} from 'lucide-react';
interface DiagnosisResult {
id: string;
problem: string;
symptoms: string[];
rootCauses: {
description: string;
probability: number;
evidence: string[];
}[];
solutions: {
title: string;
steps: string[];
difficulty: 'easy' | 'medium' | 'hard';
estimatedTime: string;
}[];
relatedLogs: {
timestamp: string;
level: string;
message: string;
source: string;
}[];
similarCases: {
id: string;
title: string;
resolution: string;
date: string;
}[];
}
const mockDiagnosisHistory: DiagnosisResult[] = [
{
id: 'DG-001',
problem: 'Web服务响应缓慢',
symptoms: ['页面加载时间超过5秒', 'CPU使用率飙升至90%', '部分请求超时'],
rootCauses: [
{
description: '数据库连接池耗尽,导致请求排队等待',
probability: 85,
evidence: ['数据库连接数达到上限100', '连接等待队列长度15', '平均连接等待时间200ms'],
},
{
description: 'Redis缓存命中率下降',
probability: 60,
evidence: ['缓存命中率从85%降至45%', '大量请求穿透到数据库'],
},
],
solutions: [
{
title: '增加数据库连接池大小',
steps: ['修改配置文件,将max_connections从100调整为200', '重启应用服务', '监控连接数变化'],
difficulty: 'easy',
estimatedTime: '5分钟',
},
{
title: '优化Redis缓存策略',
steps: ['分析缓存key的访问模式', '调整过期时间策略', '增加热点数据预热'],
difficulty: 'medium',
estimatedTime: '30分钟',
},
],
relatedLogs: [
{
timestamp: '2025-04-14 14:30:15',
level: 'ERROR',
message: 'Connection pool exhausted, waiting for available connection',
source: 'database-pool',
},
{
timestamp: '2025-04-14 14:30:12',
level: 'WARN',
message: 'Request timeout after 5000ms',
source: 'http-server',
},
],
similarCases: [
{
id: 'CASE-2025-032',
title: '数据库连接池耗尽问题',
resolution: '增加连接池大小并优化连接释放逻辑',
date: '2025-03-15',
},
],
},
];
const commonProblems = [
{ id: 'slow', name: '服务响应缓慢', icon: Clock },
{ id: 'crash', name: '应用崩溃/重启', icon: XCircle },
{ id: 'memory', name: '内存泄漏', icon: MemoryStick },
{ id: 'cpu', name: 'CPU使用率过高', icon: Cpu },
{ id: 'disk', name: '磁盘空间不足', icon: HardDrive },
{ id: 'network', name: '网络连接问题', icon: Network },
{ id: 'db', name: '数据库性能问题', icon: Database },
{ id: 'security', name: '安全异常', icon: Shield },
];
export function AIFaultDiagnosis() {
const [symptomInput, setSymptomInput] = useState('');
const [isDiagnosing, setIsDiagnosing] = useState(false);
const [diagnosisResult, setDiagnosisResult] = useState<DiagnosisResult | null>(null);
const [selectedProblem, setSelectedProblem] = useState<string | null>(null);
const [diagnosisHistory, setDiagnosisHistory] = useState<DiagnosisResult[]>(mockDiagnosisHistory);
const handleDiagnose = () => {
if (!symptomInput.trim()) return;
setIsDiagnosing(true);
setDiagnosisResult(null);
// 模拟AI诊断
setTimeout(() => {
setDiagnosisResult(mockDiagnosisHistory[0]);
setIsDiagnosing(false);
}, 2500);
};
const handleProblemSelect = (problemId: string) => {
setSelectedProblem(problemId);
const problemMap: Record<string, string> = {
slow: 'Web服务响应缓慢,页面加载时间超过5秒',
crash: '应用服务频繁崩溃重启,错误日志显示内存溢出',
memory: '服务器内存持续增长,疑似存在内存泄漏',
cpu: 'CPU使用率持续在90%以上,系统响应变慢',
disk: '磁盘空间使用率超过95%,影响日志写入',
network: '网络连接不稳定,丢包率较高',
db: '数据库查询变慢,部分SQL执行超时',
security: '检测到异常登录尝试和安全告警',
};
setSymptomInput(problemMap[problemId] || '');
};
const getDifficultyColor = (difficulty: string) => {
switch (difficulty) {
case 'easy':
return 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30';
case 'medium':
return 'bg-amber-500/10 text-amber-400 border-amber-500/30';
case 'hard':
return 'bg-red-500/10 text-red-400 border-red-500/30';
default:
return 'bg-slate-500/10 text-slate-400 border-slate-500/30';
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-red-500 to-orange-500 flex items-center justify-center">
<Brain className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-100">AI 故障诊断</h1>
<p className="text-sm text-slate-500">智能分析故障症状,定位根因并提供解决方案</p>
</div>
</div>
<Badge variant="outline" className="border-purple-500/30 text-purple-400 bg-purple-500/10">
<Sparkles className="h-3 w-3 mr-1" />
AI 诊断引擎
</Badge>
</div>
<Tabs defaultValue="diagnose" className="space-y-4">
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="diagnose" className="data-[state=active]:bg-slate-700">
<Search className="h-4 w-4 mr-2" />
故障诊断
</TabsTrigger>
<TabsTrigger value="history" className="data-[state=active]:bg-slate-700">
<History className="h-4 w-4 mr-2" />
诊断历史
</TabsTrigger>
</TabsList>
<TabsContent value="diagnose" className="space-y-4">
<div className="grid grid-cols-3 gap-4">
{/* Common Problems */}
<Card className="bg-slate-800 border-slate-700 col-span-1">
<CardHeader className="pb-3">
<CardTitle className="text-slate-100 text-sm">常见问题</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{commonProblems.map((problem) => (
<Button
key={problem.id}
variant="ghost"
className={`w-full justify-start ${
selectedProblem === problem.id
? 'bg-purple-500/20 text-purple-300'
: 'text-slate-300 hover:text-slate-100 hover:bg-slate-700'
}`}
onClick={() => handleProblemSelect(problem.id)}
>
<problem.icon className="h-4 w-4 mr-2" />
{problem.name}
</Button>
))}
</CardContent>
</Card>
{/* Input Area */}
<Card className="bg-slate-800 border-slate-700 col-span-2">
<CardHeader className="pb-3">
<CardTitle className="text-slate-100 flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-blue-400" />
描述故障症状
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Textarea
value={symptomInput}
onChange={(e) => setSymptomInput(e.target.value)}
placeholder="请详细描述故障现象,例如:服务响应缓慢、应用崩溃、内存占用过高等..."
className="bg-slate-900 border-slate-700 text-slate-100 min-h-[120px]"
/>
<div className="flex gap-2">
<Button
className="flex-1 bg-purple-600 hover:bg-purple-700"
onClick={handleDiagnose}
disabled={isDiagnosing || !symptomInput.trim()}
>
{isDiagnosing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
AI诊断中...
</>
) : (
<>
<Brain className="h-4 w-4 mr-2" />
开始诊断
</>
)}
</Button>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Terminal className="h-4 w-4 mr-2" />
上传日志
</Button>
</div>
</CardContent>
</Card>
</div>
{/* Diagnosis Result */}
{diagnosisResult && (
<Card className="bg-slate-800 border-slate-700">
<CardHeader className="border-b border-slate-700">
<div className="flex items-center justify-between">
<CardTitle className="text-slate-100 flex items-center gap-2">
<Sparkles className="h-5 w-5 text-purple-400" />
诊断结果
</CardTitle>
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<CheckCircle className="h-3 w-3 mr-1" />
诊断完成
</Badge>
</div>
</CardHeader>
<CardContent className="p-6">
<div className="space-y-6">
{/* Problem Summary */}
<div className="p-4 bg-slate-900 rounded-lg border border-slate-700">
<h3 className="text-lg font-medium text-slate-100 mb-2">{diagnosisResult.problem}</h3>
<div className="flex flex-wrap gap-2">
{diagnosisResult.symptoms.map((symptom, idx) => (
<Badge key={idx} variant="outline" className="border-amber-500/30 text-amber-400">
<AlertTriangle className="h-3 w-3 mr-1" />
{symptom}
</Badge>
))}
</div>
</div>
{/* Root Causes */}
<div>
<h4 className="text-sm font-medium text-slate-300 mb-3 flex items-center gap-2">
<AlertCircle className="h-4 w-4 text-red-400" />
可能的根因
</h4>
<div className="space-y-3">
{diagnosisResult.rootCauses.map((cause, idx) => (
<div key={idx} className="p-4 bg-slate-900 rounded-lg border border-slate-700">
<div className="flex items-center justify-between mb-2">
<span className="text-slate-200">{cause.description}</span>
<div className="flex items-center gap-2">
<span className="text-sm text-slate-400">概率</span>
<div className="w-20 h-2 bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-purple-500 to-pink-500"
style={{ width: `${cause.probability}%` }}
/>
</div>
<span className="text-purple-400 font-medium">{cause.probability}%</span>
</div>
</div>
<div className="space-y-1">
{cause.evidence.map((ev, eidx) => (
<div key={eidx} className="flex items-center gap-2 text-sm text-slate-500">
<ChevronRight className="h-3 w-3" />
{ev}
</div>
))}
</div>
</div>
))}
</div>
</div>
{/* Solutions */}
<div>
<h4 className="text-sm font-medium text-slate-300 mb-3 flex items-center gap-2">
<Lightbulb className="h-4 w-4 text-emerald-400" />
解决方案
</h4>
<Accordion type="single" collapsible className="space-y-2">
{diagnosisResult.solutions.map((solution, idx) => (
<AccordionItem
key={idx}
value={`solution-${idx}`}
className="border border-slate-700 rounded-lg bg-slate-900"
>
<AccordionTrigger className="px-4 py-3 hover:no-underline">
<div className="flex items-center gap-3">
<span className="font-medium text-slate-200">{solution.title}</span>
<Badge variant="outline" className={getDifficultyColor(solution.difficulty)}>
{solution.difficulty === 'easy' ? '简单' : solution.difficulty === 'medium' ? '中等' : '困难'}
</Badge>
<span className="text-sm text-slate-500 flex items-center gap-1">
<Clock className="h-3 w-3" />
{solution.estimatedTime}
</span>
</div>
</AccordionTrigger>
<AccordionContent className="px-4 pb-4">
<div className="space-y-2">
{solution.steps.map((step, sidx) => (
<div key={sidx} className="flex items-start gap-2">
<span className="flex-shrink-0 w-5 h-5 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-xs">
{sidx + 1}
</span>
<span className="text-sm text-slate-300">{step}</span>
</div>
))}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</div>
{/* Related Logs */}
<div>
<h4 className="text-sm font-medium text-slate-300 mb-3 flex items-center gap-2">
<FileText className="h-4 w-4 text-blue-400" />
相关日志
</h4>
<div className="space-y-2">
{diagnosisResult.relatedLogs.map((log, idx) => (
<div key={idx} className="p-3 bg-slate-950 rounded-lg border border-slate-700 font-mono text-sm">
<div className="flex items-center gap-2 mb-1">
<span className="text-slate-500">{log.timestamp}</span>
<Badge
variant="outline"
className={
log.level === 'ERROR'
? 'border-red-500/30 text-red-400'
: 'border-amber-500/30 text-amber-400'
}
>
{log.level}
</Badge>
<span className="text-slate-500">[{log.source}]</span>
</div>
<span className="text-slate-300">{log.message}</span>
</div>
))}
</div>
</div>
{/* Similar Cases */}
<div>
<h4 className="text-sm font-medium text-slate-300 mb-3 flex items-center gap-2">
<History className="h-4 w-4 text-purple-400" />
相似历史案例
</h4>
<div className="space-y-2">
{diagnosisResult.similarCases.map((caseItem, idx) => (
<div key={idx} className="p-3 bg-slate-900 rounded-lg border border-slate-700">
<div className="flex items-center justify-between">
<span className="text-slate-200">{caseItem.title}</span>
<span className="text-sm text-slate-500">{caseItem.date}</span>
</div>
<p className="text-sm text-slate-400 mt-1">解决方案: {caseItem.resolution}</p>
</div>
))}
</div>
</div>
</div>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="history">
<Card className="bg-slate-800 border-slate-700">
<CardHeader>
<CardTitle className="text-slate-100">诊断历史</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{diagnosisHistory.map((item) => (
<div
key={item.id}
className="p-4 rounded-lg border border-slate-700 bg-slate-800/50 hover:border-slate-600 cursor-pointer"
onClick={() => setDiagnosisResult(item)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Brain className="h-5 w-5 text-purple-400" />
</div>
<div>
<h4 className="font-medium text-slate-100">{item.problem}</h4>
<p className="text-sm text-slate-400">
{item.symptoms.length} 个症状 · {item.rootCauses.length} 个根因 · {item.solutions.length} 个方案
</p>
</div>
</div>
<Button variant="ghost" size="sm" className="text-slate-400">
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
+442
View File
@@ -0,0 +1,442 @@
import { useState, useRef, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Bot,
Send,
Settings,
Sparkles,
AlertTriangle,
Server,
Activity,
TrendingUp,
FileText,
Terminal,
RotateCw,
ChevronRight,
Copy,
Check,
Clock,
User,
MessageSquare,
Zap,
Brain,
Cpu,
BarChart3,
Loader2,
} from 'lucide-react';
interface Message {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: string;
suggestions?: string[];
}
interface LLMConfig {
provider: string;
model: string;
apiKey: string;
endpoint: string;
temperature: number;
maxTokens: number;
}
const mockLLMProviders = [
{ id: 'openai', name: 'OpenAI', models: ['gpt-4', 'gpt-4-turbo', 'gpt-3.5-turbo'] },
{ id: 'anthropic', name: 'Anthropic', models: ['claude-3-opus', 'claude-3-sonnet', 'claude-3-haiku'] },
{ id: 'azure', name: 'Azure OpenAI', models: ['gpt-4', 'gpt-35-turbo'] },
{ id: 'ollama', name: 'Ollama (本地)', models: ['llama2', 'codellama', 'mistral'] },
{ id: 'custom', name: '自定义', models: ['custom'] },
];
const mockConversations: Message[] = [
{
id: '1',
role: 'assistant',
content: '你好!我是 EZOps AI 智能助手,可以帮助你:\n\n🔍 **故障诊断** - 分析告警原因,提供解决方案\n📊 **性能分析** - 识别系统瓶颈,优化建议\n📝 **运维知识** - 解答技术问题,最佳实践\n🤖 **自动化建议** - 推荐自动化脚本和方案\n\n请问有什么可以帮助你的?',
timestamp: '2025-04-14 10:00:00',
suggestions: ['查看今日告警', '分析服务器性能', '生成巡检报告'],
},
];
const quickActions = [
{ id: 'alerts', name: '分析今日告警', icon: AlertTriangle, description: '分析今日所有告警事件' },
{ id: 'performance', name: '性能瓶颈分析', icon: Activity, description: '分析系统性能瓶颈' },
{ id: 'report', name: '生成运维报告', icon: FileText, description: '生成每日运维报告' },
{ id: 'optimize', name: '资源优化建议', icon: TrendingUp, description: '提供资源优化方案' },
];
export function AIOpsAssistant() {
const [messages, setMessages] = useState<Message[]>(mockConversations);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isConfigOpen, setIsConfigOpen] = useState(false);
const [llmConfig, setLlmConfig] = useState<LLMConfig>({
provider: 'openai',
model: 'gpt-4',
apiKey: '',
endpoint: '',
temperature: 0.7,
maxTokens: 2000,
});
const messagesEndRef = useRef<HTMLDivElement>(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
}, [messages]);
const handleSend = () => {
if (!input.trim()) return;
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: input,
timestamp: new Date().toISOString(),
};
setMessages((prev) => [...prev, userMessage]);
setInput('');
setIsLoading(true);
// Mock AI response
setTimeout(() => {
const responses: Record<string, string> = {
'告警': '根据今日告警数据分析:\n\n**严重告警 (2)**\n- 服务器 web-server-03 CPU 使用率超过 95%\n- 数据库主库连接数接近上限\n\n**建议处理方案:**\n1. 立即扩容 web-server-03 或迁移部分流量\n2. 检查数据库慢查询,优化连接池配置\n3. 考虑启用读写分离',
'性能': '系统性能分析结果:\n\n**CPU 使用率**\n- 平均: 45%\n- 峰值: 92% (14:30)\n- 建议: 高峰期需要扩容\n\n**内存使用**\n- 平均: 68%\n- 建议: 正常范围内\n\n**磁盘 I/O**\n- 数据库服务器 I/O 等待较高\n- 建议: 考虑使用 SSD 或优化查询',
'报告': '## 每日运维报告 (2025-04-14)\n\n### 📊 系统状态\n- 服务器总数: 156\n- 运行中: 142\n- 告警事件: 23\n\n### 🚨 告警统计\n- 严重: 2\n- 警告: 8\n- 提示: 13\n\n### 📈 今日发布\n- 成功: 5\n- 失败: 1\n- 回滚: 0',
'优化': '资源优化建议:\n\n**成本优化**\n1. 关闭 5 台闲置测试服务器,预计月节省 ¥2,400\n2. 调整 3 台服务器规格,预计月节省 ¥1,800\n\n**性能优化**\n1. 数据库添加索引,预计查询性能提升 40%\n2. Redis 缓存命中率仅 65%,建议优化缓存策略\n\n**自动化建议**\n1. 部署自动扩缩容策略\n2. 配置告警自动处理规则',
};
let responseContent = '我已收到你的问题。让我分析一下...\n\n';
for (const [key, value] of Object.entries(responses)) {
if (input.includes(key)) {
responseContent = value;
break;
}
}
if (responseContent === '我已收到你的问题。让我分析一下...\n\n') {
responseContent += '这是一个很好的问题。根据当前系统状态,我建议:\n\n1. 查看相关监控指标\n2. 分析日志信息\n3. 制定处理方案\n\n需要我提供更详细的分析吗?';
}
const assistantMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: responseContent,
timestamp: new Date().toISOString(),
suggestions: ['查看更多详情', '生成处理脚本', '创建工单'],
};
setMessages((prev) => [...prev, assistantMessage]);
setIsLoading(false);
}, 1500);
};
const handleQuickAction = (actionId: string) => {
const actionMap: Record<string, string> = {
alerts: '分析今日告警',
performance: '分析系统性能瓶颈',
report: '生成今日运维报告',
optimize: '提供资源优化建议',
};
setInput(actionMap[actionId] || '');
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-purple-500 to-pink-500 flex items-center justify-center">
<Brain className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-100">AIOps 智能助手</h1>
<p className="text-sm text-slate-500">AI 驱动的智能运维助手</p>
</div>
</div>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setIsConfigOpen(true)}>
<Settings className="h-4 w-4 mr-2" />
模型配置
</Button>
</div>
<div className="grid grid-cols-4 gap-6 h-[calc(100vh-200px)]">
{/* Sidebar */}
<div className="col-span-1 space-y-4">
<Card className="bg-slate-800 border-slate-700">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-300">快速操作</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{quickActions.map((action) => (
<Button
key={action.id}
variant="ghost"
className="w-full justify-start text-slate-300 hover:text-slate-100 hover:bg-slate-700"
onClick={() => handleQuickAction(action.id)}
>
<action.icon className="h-4 w-4 mr-2" />
<div className="text-left">
<p className="text-sm">{action.name}</p>
<p className="text-xs text-slate-500">{action.description}</p>
</div>
</Button>
))}
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardHeader className="pb-3">
<CardTitle className="text-sm text-slate-300">能力展示</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-2 text-sm text-slate-400">
<AlertTriangle className="h-4 w-4 text-amber-400" />
<span>智能告警分析</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-400">
<Activity className="h-4 w-4 text-blue-400" />
<span>性能瓶颈识别</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-400">
<FileText className="h-4 w-4 text-emerald-400" />
<span>自动报告生成</span>
</div>
<div className="flex items-center gap-2 text-sm text-slate-400">
<Terminal className="h-4 w-4 text-purple-400" />
<span>脚本自动生成</span>
</div>
</CardContent>
</Card>
</div>
{/* Chat Area */}
<Card className="col-span-3 bg-slate-800 border-slate-700 flex flex-col">
<CardHeader className="border-b border-slate-700 pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Bot className="h-5 w-5 text-purple-400" />
<span className="font-medium text-slate-100">AI 助手</span>
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10 text-xs">
<span className="w-2 h-2 rounded-full bg-emerald-400 mr-1 animate-pulse" />
在线
</Badge>
</div>
<Button variant="ghost" size="sm" className="text-slate-400">
<RotateCw className="h-4 w-4 mr-1" />
新对话
</Button>
</div>
</CardHeader>
<ScrollArea className="flex-1 p-4">
<div className="space-y-4">
{messages.map((message) => (
<div
key={message.id}
className={`flex gap-3 ${message.role === 'user' ? 'flex-row-reverse' : ''}`}
>
<Avatar className={message.role === 'assistant' ? 'bg-purple-500' : 'bg-blue-500'}>
<AvatarFallback>
{message.role === 'assistant' ? <Bot className="h-4 w-4" /> : <User className="h-4 w-4" />}
</AvatarFallback>
</Avatar>
<div className={`max-w-[80%] ${message.role === 'user' ? 'text-right' : ''}`}>
<div
className={`inline-block rounded-lg p-3 text-left ${
message.role === 'assistant'
? 'bg-slate-700 text-slate-100'
: 'bg-blue-600 text-white'
}`}
>
<div className="whitespace-pre-wrap">{message.content}</div>
</div>
<div className="text-xs text-slate-500 mt-1">{message.timestamp}</div>
{message.suggestions && message.role === 'assistant' && (
<div className="flex gap-2 mt-2">
{message.suggestions.map((suggestion, idx) => (
<Button
key={idx}
variant="outline"
size="sm"
className="border-slate-600 text-slate-300 text-xs"
onClick={() => setInput(suggestion)}
>
{suggestion}
</Button>
))}
</div>
)}
</div>
</div>
))}
{isLoading && (
<div className="flex gap-3">
<Avatar className="bg-purple-500">
<AvatarFallback>
<Bot className="h-4 w-4" />
</AvatarFallback>
</Avatar>
<div className="bg-slate-700 rounded-lg p-3">
<div className="flex items-center gap-2 text-slate-400">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-sm">思考中...</span>
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
</ScrollArea>
<CardContent className="border-t border-slate-700 pt-4">
<div className="flex gap-2">
<Input
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSend()}
placeholder="输入问题或选择快速操作..."
className="flex-1 bg-slate-900 border-slate-700 text-slate-100"
/>
<Button className="bg-purple-600 hover:bg-purple-700" onClick={handleSend} disabled={isLoading}>
<Send className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
</div>
{/* LLM Config Dialog */}
<Dialog open={isConfigOpen} onOpenChange={setIsConfigOpen}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-700 text-slate-100">
<DialogHeader>
<DialogTitle>大模型配置</DialogTitle>
<DialogDescription className="text-slate-500">配置 AI 助手使用的大语言模型</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-300">提供商</Label>
<Select
value={llmConfig.provider}
onValueChange={(v) => setLlmConfig({ ...llmConfig, provider: v })}
>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-100 mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{mockLLMProviders.map((p) => (
<SelectItem key={p.id} value={p.id} className="text-slate-100">
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label className="text-slate-300">模型</Label>
<Select
value={llmConfig.model}
onValueChange={(v) => setLlmConfig({ ...llmConfig, model: v })}
>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-100 mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{mockLLMProviders
.find((p) => p.id === llmConfig.provider)
?.models.map((m) => (
<SelectItem key={m} value={m} className="text-slate-100">
{m}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-slate-300">API Key</Label>
<Input
type="password"
value={llmConfig.apiKey}
onChange={(e) => setLlmConfig({ ...llmConfig, apiKey: e.target.value })}
placeholder="sk-..."
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
<div>
<Label className="text-slate-300">API Endpoint (可选)</Label>
<Input
value={llmConfig.endpoint}
onChange={(e) => setLlmConfig({ ...llmConfig, endpoint: e.target.value })}
placeholder="https://api.openai.com/v1"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-300">Temperature</Label>
<Input
type="number"
min={0}
max={2}
step={0.1}
value={llmConfig.temperature}
onChange={(e) => setLlmConfig({ ...llmConfig, temperature: parseFloat(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
<p className="text-xs text-slate-500 mt-1">值越高,回答越创造性</p>
</div>
<div>
<Label className="text-slate-300">Max Tokens</Label>
<Input
type="number"
value={llmConfig.maxTokens}
onChange={(e) => setLlmConfig({ ...llmConfig, maxTokens: parseInt(e.target.value) })}
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsConfigOpen(false)} className="border-slate-700 text-slate-300">
取消
</Button>
<Button className="bg-purple-600 hover:bg-purple-700" onClick={() => setIsConfigOpen(false)}>
保存配置
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+250
View File
@@ -0,0 +1,250 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Separator } from '@/components/ui/separator';
import { AIAlertAnalyzer } from './AIAlertAnalyzer';
import { AIScriptGenerator } from './AIScriptGenerator';
import { AIFaultDiagnosis } from './AIFaultDiagnosis';
import { AIPredictiveAnalytics } from './AIPredictiveAnalytics';
import { AIOpsAssistant } from './AIOpsAssistant';
import {
Brain,
Bot,
Sparkles,
AlertTriangle,
Code,
Search,
TrendingUp,
MessageSquare,
Settings,
Zap,
Cpu,
Activity,
CheckCircle,
Clock,
BarChart3,
ArrowRight,
} from 'lucide-react';
interface AIFeature {
id: string;
name: string;
description: string;
icon: React.ElementType;
component: React.ComponentType;
badge?: string;
badgeColor?: string;
}
const aiFeatures: AIFeature[] = [
{
id: 'assistant',
name: 'AI 智能助手',
description: '自然语言交互,解答运维问题,提供智能建议',
icon: Bot,
component: AIOpsAssistant,
badge: '核心',
badgeColor: 'bg-purple-500/10 text-purple-400 border-purple-500/30',
},
{
id: 'alert-analyzer',
name: '告警智能分析',
description: 'AI分析告警根因,提供自动修复建议',
icon: AlertTriangle,
component: AIAlertAnalyzer,
badge: '热门',
badgeColor: 'bg-red-500/10 text-red-400 border-red-500/30',
},
{
id: 'script-generator',
name: '脚本生成器',
description: '自然语言生成运维脚本,支持多种语言',
icon: Code,
component: AIScriptGenerator,
badge: '新功能',
badgeColor: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30',
},
{
id: 'fault-diagnosis',
name: '故障诊断',
description: '智能分析故障症状,定位根因并提供方案',
icon: Search,
component: AIFaultDiagnosis,
},
{
id: 'predictive',
name: '预测分析',
description: '基于历史数据预测趋势,容量规划预警',
icon: TrendingUp,
component: AIPredictiveAnalytics,
badge: 'AI驱动',
badgeColor: 'bg-blue-500/10 text-blue-400 border-blue-500/30',
},
];
export function AIOpsHub() {
const [activeFeature, setActiveFeature] = useState<string>('assistant');
const ActiveComponent = aiFeatures.find(f => f.id === activeFeature)?.component || AIOpsAssistant;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-purple-600 via-pink-500 to-orange-400 flex items-center justify-center shadow-lg shadow-purple-500/20">
<Brain className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-100">AIOps 智能运维中心</h1>
<p className="text-sm text-slate-500 flex items-center gap-2">
<Sparkles className="h-3 w-3 text-purple-400" />
AI驱动的智能运维平台,让运维更高效、更智能
</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-emerald-500/10 border border-emerald-500/30">
<div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
<span className="text-sm text-emerald-400">AI 引擎运行中</span>
</div>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Settings className="h-4 w-4 mr-2" />
模型配置
</Button>
</div>
</div>
{/* AI Status Overview */}
<div className="grid grid-cols-4 gap-4">
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Activity className="h-5 w-5 text-purple-400" />
</div>
<div>
<p className="text-sm text-slate-400">今日AI处理</p>
<p className="text-xl font-bold text-slate-100">1,247</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-emerald-400" />
</div>
<div>
<p className="text-sm text-slate-400">自动修复成功</p>
<p className="text-xl font-bold text-slate-100">89</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-amber-500/20 flex items-center justify-center">
<Clock className="h-5 w-5 text-amber-400" />
</div>
<div>
<p className="text-sm text-slate-400">平均响应时间</p>
<p className="text-xl font-bold text-slate-100">2.3s</p>
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<BarChart3 className="h-5 w-5 text-blue-400" />
</div>
<div>
<p className="text-sm text-slate-400">预测准确率</p>
<p className="text-xl font-bold text-slate-100">87.3%</p>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Feature Navigation */}
<div className="grid grid-cols-5 gap-3">
{aiFeatures.map((feature) => (
<button
key={feature.id}
onClick={() => setActiveFeature(feature.id)}
className={`p-4 rounded-xl border text-left transition-all ${
activeFeature === feature.id
? 'border-purple-500 bg-purple-500/10'
: 'border-slate-700 bg-slate-800/50 hover:border-slate-600 hover:bg-slate-800'
}`}
>
<div className="flex items-start justify-between mb-2">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center ${
activeFeature === feature.id
? 'bg-purple-500'
: 'bg-slate-700'
}`}
>
<feature.icon
className={`h-5 w-5 ${
activeFeature === feature.id ? 'text-white' : 'text-slate-400'
}`}
/>
</div>
{feature.badge && (
<Badge variant="outline" className={`text-xs ${feature.badgeColor}`}>
{feature.badge}
</Badge>
)}
</div>
<h3
className={`font-medium mb-1 ${
activeFeature === feature.id ? 'text-purple-300' : 'text-slate-200'
}`}
>
{feature.name}
</h3>
<p className="text-xs text-slate-500 line-clamp-2">{feature.description}</p>
</button>
))}
</div>
{/* Active Feature Component */}
<div className="min-h-[600px]">
<ActiveComponent />
</div>
{/* AI Integration Info */}
<Card className="bg-gradient-to-r from-purple-900/30 to-blue-900/30 border-purple-500/30">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-12 h-12 rounded-xl bg-purple-500/20 flex items-center justify-center">
<Zap className="h-6 w-6 text-purple-400" />
</div>
<div>
<h3 className="text-lg font-semibold text-slate-100">AI大模型与运维深度集成</h3>
<p className="text-sm text-slate-400 mt-1">
EZOps平台集成了多种AI能力,包括智能告警分析、自动脚本生成、故障诊断预测等,
支持OpenAI、Anthropic、Azure OpenAI、Ollama等多种大模型提供商。
</p>
</div>
</div>
<Button className="bg-purple-600 hover:bg-purple-700">
了解更多
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,533 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Brain,
TrendingUp,
TrendingDown,
AlertTriangle,
CheckCircle,
Clock,
Server,
Cpu,
MemoryStick,
HardDrive,
Network,
Database,
Activity,
BarChart3,
LineChart,
PieChart,
Calendar,
Sparkles,
Zap,
ArrowUpRight,
ArrowDownRight,
Minus,
Loader2,
} from 'lucide-react';
interface PredictionMetric {
name: string;
current: number;
predicted: number;
unit: string;
trend: 'up' | 'down' | 'stable';
confidence: number;
timeRange: string;
}
interface CapacityForecast {
resource: string;
currentUsage: number;
predictedUsage: number;
threshold: number;
daysUntilThreshold: number;
recommendation: string;
}
interface AnomalyPrediction {
metric: string;
predictedAnomaly: string;
probability: number;
timeframe: string;
suggestedAction: string;
}
const mockPredictions: PredictionMetric[] = [
{ name: 'CPU使用率', current: 45, predicted: 68, unit: '%', trend: 'up', confidence: 87, timeRange: '24小时后' },
{ name: '内存使用率', current: 62, predicted: 58, unit: '%', trend: 'down', confidence: 82, timeRange: '24小时后' },
{ name: '磁盘使用率', current: 78, predicted: 82, unit: '%', trend: 'up', confidence: 95, timeRange: '7天后' },
{ name: '网络流量', current: 450, predicted: 680, unit: 'Mbps', trend: 'up', confidence: 76, timeRange: '1小时后' },
];
const mockCapacityForecasts: CapacityForecast[] = [
{
resource: '数据库磁盘空间',
currentUsage: 78,
predictedUsage: 95,
threshold: 90,
daysUntilThreshold: 12,
recommendation: '建议在未来10天内扩容磁盘空间或清理历史数据',
},
{
resource: '应用服务器内存',
currentUsage: 62,
predictedUsage: 88,
threshold: 85,
daysUntilThreshold: 5,
recommendation: '内存增长趋势明显,建议增加内存或优化应用',
},
{
resource: 'Redis缓存容量',
currentUsage: 45,
predictedUsage: 72,
threshold: 80,
daysUntilThreshold: 21,
recommendation: '缓存使用正常,建议持续监控',
},
];
const mockAnomalyPredictions: AnomalyPrediction[] = [
{
metric: '订单服务响应时间',
predictedAnomaly: '响应时间可能超过阈值',
probability: 73,
timeframe: '未来2小时',
suggestedAction: '检查订单服务负载,考虑扩容',
},
{
metric: '数据库连接数',
predictedAnomaly: '连接数可能达到上限',
probability: 65,
timeframe: '未来4小时',
suggestedAction: '优化连接池配置,检查慢查询',
},
{
metric: '日志写入速率',
predictedAnomaly: '磁盘IO可能达到瓶颈',
probability: 45,
timeframe: '未来6小时',
suggestedAction: '监控磁盘IO,考虑日志归档',
},
];
export function AIPredictiveAnalytics() {
const [selectedTimeRange, setSelectedTimeRange] = useState('24h');
const [isAnalyzing, setIsAnalyzing] = useState(false);
const handleRefresh = () => {
setIsAnalyzing(true);
setTimeout(() => setIsAnalyzing(false), 1500);
};
const getTrendIcon = (trend: string) => {
switch (trend) {
case 'up':
return <TrendingUp className="h-4 w-4 text-red-400" />;
case 'down':
return <TrendingDown className="h-4 w-4 text-emerald-400" />;
default:
return <Minus className="h-4 w-4 text-slate-400" />;
}
};
const getTrendColor = (trend: string) => {
switch (trend) {
case 'up':
return 'text-red-400';
case 'down':
return 'text-emerald-400';
default:
return 'text-slate-400';
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center">
<Brain className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-100">AI 预测分析</h1>
<p className="text-sm text-slate-500">基于历史数据的智能预测和容量规划</p>
</div>
</div>
<div className="flex items-center gap-2">
<Select value={selectedTimeRange} onValueChange={setSelectedTimeRange}>
<SelectTrigger className="w-32 bg-slate-800 border-slate-700 text-slate-100">
<Calendar className="h-4 w-4 mr-2" />
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="1h">1小时</SelectItem>
<SelectItem value="24h">24小时</SelectItem>
<SelectItem value="7d">7天</SelectItem>
<SelectItem value="30d">30天</SelectItem>
</SelectContent>
</Select>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={handleRefresh}>
{isAnalyzing ? <Loader2 className="h-4 w-4 animate-spin" /> : <Activity className="h-4 w-4" />}
</Button>
</div>
</div>
{/* Overview Cards */}
<div className="grid grid-cols-4 gap-4">
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-400">预测准确率</p>
<p className="text-2xl font-bold text-slate-100">87.3%</p>
</div>
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-emerald-400" />
</div>
</div>
<div className="flex items-center gap-1 mt-2 text-xs text-emerald-400">
<ArrowUpRight className="h-3 w-3" />
<span>+2.1% 较上周</span>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-400">预测告警数</p>
<p className="text-2xl font-bold text-slate-100">12</p>
</div>
<div className="w-10 h-10 rounded-lg bg-amber-500/20 flex items-center justify-center">
<AlertTriangle className="h-5 w-5 text-amber-400" />
</div>
</div>
<div className="flex items-center gap-1 mt-2 text-xs text-amber-400">
<span>未来24小时内</span>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-400">容量预警</p>
<p className="text-2xl font-bold text-slate-100">3</p>
</div>
<div className="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
<HardDrive className="h-5 w-5 text-red-400" />
</div>
</div>
<div className="flex items-center gap-1 mt-2 text-xs text-red-400">
<span>需要关注</span>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-400">AI模型版本</p>
<p className="text-2xl font-bold text-slate-100">v2.4.1</p>
</div>
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Sparkles className="h-5 w-5 text-purple-400" />
</div>
</div>
<div className="flex items-center gap-1 mt-2 text-xs text-purple-400">
<span>已是最新版本</span>
</div>
</CardContent>
</Card>
</div>
<Tabs defaultValue="metrics" className="space-y-4">
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="metrics" className="data-[state=active]:bg-slate-700">
<LineChart className="h-4 w-4 mr-2" />
指标预测
</TabsTrigger>
<TabsTrigger value="capacity" className="data-[state=active]:bg-slate-700">
<BarChart3 className="h-4 w-4 mr-2" />
容量规划
</TabsTrigger>
<TabsTrigger value="anomaly" className="data-[state=active]:bg-slate-700">
<AlertTriangle className="h-4 w-4 mr-2" />
异常预测
</TabsTrigger>
</TabsList>
<TabsContent value="metrics" className="space-y-4">
<Card className="bg-slate-800 border-slate-700">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-slate-100 flex items-center gap-2">
<TrendingUp className="h-5 w-5 text-blue-400" />
关键指标预测
</CardTitle>
<Badge variant="outline" className="border-purple-500/30 text-purple-400 bg-purple-500/10">
<Sparkles className="h-3 w-3 mr-1" />
AI 预测
</Badge>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
{mockPredictions.map((prediction, idx) => (
<div
key={idx}
className="p-4 rounded-lg border border-slate-700 bg-slate-800/50"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{prediction.name.includes('CPU') && <Cpu className="h-4 w-4 text-blue-400" />}
{prediction.name.includes('内存') && <MemoryStick className="h-4 w-4 text-emerald-400" />}
{prediction.name.includes('磁盘') && <HardDrive className="h-4 w-4 text-amber-400" />}
{prediction.name.includes('网络') && <Network className="h-4 w-4 text-purple-400" />}
<span className="font-medium text-slate-200">{prediction.name}</span>
</div>
<div className="flex items-center gap-1">
{getTrendIcon(prediction.trend)}
<span className={`text-sm ${getTrendColor(prediction.trend)}`}>
{prediction.trend === 'up' ? '上升' : prediction.trend === 'down' ? '下降' : '平稳'}
</span>
</div>
</div>
<div className="grid grid-cols-2 gap-4 mb-3">
<div>
<p className="text-xs text-slate-500 mb-1">当前值</p>
<p className="text-lg font-bold text-slate-100">
{prediction.current}{prediction.unit}
</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">预测值 ({prediction.timeRange})</p>
<p className={`text-lg font-bold ${getTrendColor(prediction.trend)}`}>
{prediction.predicted}{prediction.unit}
</p>
</div>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-400">置信度</span>
<div className="w-16 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-500 to-purple-500"
style={{ width: `${prediction.confidence}%` }}
/>
</div>
<span className="text-xs text-purple-400">{prediction.confidence}%</span>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="capacity" className="space-y-4">
<Card className="bg-slate-800 border-slate-700">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-slate-100 flex items-center gap-2">
<HardDrive className="h-5 w-5 text-amber-400" />
容量规划预警
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-4">
{mockCapacityForecasts.map((forecast, idx) => (
<div
key={idx}
className="p-4 rounded-lg border border-slate-700 bg-slate-800/50"
>
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Database className="h-5 w-5 text-blue-400" />
<span className="font-medium text-slate-200">{forecast.resource}</span>
</div>
<Badge
variant="outline"
className={
forecast.daysUntilThreshold <= 7
? 'border-red-500/30 text-red-400 bg-red-500/10'
: forecast.daysUntilThreshold <= 14
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
}
>
{forecast.daysUntilThreshold <= 7
? '紧急'
: forecast.daysUntilThreshold <= 14
? '警告'
: '正常'}
</Badge>
</div>
<div className="mb-4">
<div className="flex items-center justify-between text-sm mb-1">
<span className="text-slate-400">使用率趋势</span>
<span className="text-slate-400">
预计 <span className="text-amber-400 font-medium">{forecast.daysUntilThreshold}天</span> 后达到阈值
</span>
</div>
<div className="h-3 bg-slate-700 rounded-full overflow-hidden">
<div className="flex h-full">
<div
className="bg-blue-500"
style={{ width: `${forecast.currentUsage}%` }}
/>
<div
className="bg-gradient-to-r from-blue-500/50 to-amber-500"
style={{ width: `${forecast.predictedUsage - forecast.currentUsage}%` }}
/>
</div>
</div>
<div className="flex items-center justify-between text-xs mt-1">
<span className="text-slate-500">当前 {forecast.currentUsage}%</span>
<span className="text-amber-400">预测 {forecast.predictedUsage}%</span>
<span className="text-red-400">阈值 {forecast.threshold}%</span>
</div>
</div>
<div className="p-3 bg-slate-900 rounded-lg border border-slate-700">
<div className="flex items-start gap-2">
<Zap className="h-4 w-4 text-emerald-400 mt-0.5" />
<div>
<span className="text-sm text-slate-400">AI建议:</span>
<p className="text-sm text-slate-300 mt-1">{forecast.recommendation}</p>
</div>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="anomaly" className="space-y-4">
<Card className="bg-slate-800 border-slate-700">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-slate-100 flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-400" />
异常事件预测
</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{mockAnomalyPredictions.map((anomaly, idx) => (
<div
key={idx}
className="p-4 rounded-lg border border-slate-700 bg-slate-800/50"
>
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div
className={`w-10 h-10 rounded-lg flex items-center justify-center ${
anomaly.probability >= 70
? 'bg-red-500/20'
: anomaly.probability >= 50
? 'bg-amber-500/20'
: 'bg-blue-500/20'
}`}
>
<AlertTriangle
className={`h-5 w-5 ${
anomaly.probability >= 70
? 'text-red-400'
: anomaly.probability >= 50
? 'text-amber-400'
: 'text-blue-400'
}`}
/>
</div>
<div>
<h4 className="font-medium text-slate-200">{anomaly.metric}</h4>
<p className="text-sm text-slate-400 mt-1">{anomaly.predictedAnomaly}</p>
<div className="flex items-center gap-4 mt-2">
<span className="text-xs text-slate-500 flex items-center gap-1">
<Clock className="h-3 w-3" />
{anomaly.timeframe}
</span>
<span className="text-xs text-slate-500">
概率: <span className={anomaly.probability >= 70 ? 'text-red-400' : 'text-amber-400'}>{anomaly.probability}%</span>
</span>
</div>
</div>
</div>
<div className="w-16 h-16 relative">
<svg className="w-full h-full transform -rotate-90">
<circle
cx="32"
cy="32"
r="28"
stroke="currentColor"
strokeWidth="4"
fill="transparent"
className="text-slate-700"
/>
<circle
cx="32"
cy="32"
r="28"
stroke="currentColor"
strokeWidth="4"
fill="transparent"
strokeDasharray={`${anomaly.probability * 1.76} 176`}
className={
anomaly.probability >= 70
? 'text-red-400'
: anomaly.probability >= 50
? 'text-amber-400'
: 'text-blue-400'
}
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-sm font-bold text-slate-200">{anomaly.probability}%</span>
</div>
</div>
</div>
<div className="mt-3 p-3 bg-slate-900 rounded-lg border border-slate-700">
<div className="flex items-start gap-2">
<Zap className="h-4 w-4 text-emerald-400 mt-0.5" />
<div>
<span className="text-sm text-slate-400">建议操作:</span>
<p className="text-sm text-slate-300 mt-1">{anomaly.suggestedAction}</p>
</div>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,600 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Bot,
Code,
Terminal,
Play,
Copy,
Check,
Sparkles,
History,
Star,
Download,
Save,
FileCode,
Shell,
Settings,
Cpu,
Database,
Server,
Cloud,
Loader2,
Wand2,
MessageSquare,
} from 'lucide-react';
interface GeneratedScript {
id: string;
name: string;
description: string;
language: string;
code: string;
createdAt: string;
tags: string[];
}
interface ScriptTemplate {
id: string;
name: string;
description: string;
icon: React.ElementType;
prompt: string;
}
const scriptTemplates: ScriptTemplate[] = [
{
id: 'server-check',
name: '服务器健康检查',
description: '生成系统资源监控脚本',
icon: Server,
prompt: '生成一个Linux服务器健康检查脚本,包括CPU、内存、磁盘、网络状态检查,并输出报告',
},
{
id: 'log-analysis',
name: '日志分析脚本',
description: '分析应用日志,提取错误信息',
icon: FileCode,
prompt: '生成一个日志分析脚本,可以分析应用日志文件,提取错误、警告信息,并生成统计报告',
},
{
id: 'backup-script',
name: '数据备份脚本',
description: '自动化数据库备份',
icon: Database,
prompt: '生成一个MySQL数据库备份脚本,支持全量和增量备份,自动压缩和清理旧备份',
},
{
id: 'deploy-script',
name: '应用部署脚本',
description: '自动化应用发布部署',
icon: Cloud,
prompt: '生成一个Docker应用部署脚本,包括镜像拉取、容器管理、健康检查',
},
{
id: 'k8s-script',
name: 'K8s运维脚本',
description: 'Kubernetes集群管理',
icon: Cpu,
prompt: '生成一个Kubernetes运维脚本,包括Pod状态检查、资源清理、故障排查',
},
{
id: 'security-check',
name: '安全扫描脚本',
description: '系统安全基线检查',
icon: Settings,
prompt: '生成一个Linux系统安全基线检查脚本,检查用户权限、开放端口、敏感文件权限',
},
];
const mockGeneratedScripts: GeneratedScript[] = [
{
id: '1',
name: '服务器巡检脚本',
description: '自动检查服务器CPU、内存、磁盘状态',
language: 'bash',
code: `#!/bin/bash
# 服务器健康检查脚本
# 生成时间: 2025-04-14
echo "========== 服务器健康检查报告 =========="
echo "检查时间: $(date)"
echo "主机名: $(hostname)"
echo ""
# CPU检查
echo "【CPU 状态】"
CPU_USAGE=\$(top -bn1 | grep "Cpu(s)" | awk '{print \$2}' | cut -d'%' -f1)
echo "CPU使用率: \${CPU_USAGE}%"
if (( \$(echo "\$CPU_USAGE > 80" | bc -l) )); then
echo "⚠️ 警告: CPU使用率过高!"
fi
echo ""
# 内存检查
echo "【内存 状态】"
free -h
echo ""
# 磁盘检查
echo "【磁盘 状态】"
df -h | grep -E '^/dev'
echo ""
# 网络检查
echo "【网络连接】"
echo "活动连接数: $(netstat -an | grep ESTABLISHED | wc -l)"
echo ""
echo "========== 检查完成 =========="`,
createdAt: '2025-04-14 10:30:00',
tags: ['巡检', '监控', 'Bash'],
},
{
id: '2',
name: 'MySQL慢查询分析',
description: '分析MySQL慢查询日志',
language: 'python',
code: `#!/usr/bin/env python3
# MySQL慢查询分析脚本
import re
from collections import Counter
from datetime import datetime
def analyze_slow_query_log(log_file):
"""分析MySQL慢查询日志"""
queries = []
with open(log_file, 'r') as f:
content = f.read()
# 解析查询时间
pattern = r'# Query_time: ([\d.]+).*?SELECT.*?FROM.*?WHERE(.*?);'
matches = re.findall(pattern, content, re.DOTALL)
for query_time, where_clause in matches:
queries.append({
'time': float(query_time),
'where': where_clause.strip()
})
# 统计最慢的查询
queries.sort(key=lambda x: x['time'], reverse=True)
print(f"总共发现 {len(queries)} 条慢查询")
print("\n最慢的5条查询:")
for i, q in enumerate(queries[:5], 1):
print(f"{i}. 耗时: {q['time']}s")
print(f" WHERE: {q['where'][:100]}...")
if __name__ == '__main__':
analyze_slow_query_log('/var/log/mysql/slow.log')`,
createdAt: '2025-04-14 11:15:00',
tags: ['MySQL', 'Python', '性能分析'],
},
];
export function AIScriptGenerator() {
const [prompt, setPrompt] = useState('');
const [isGenerating, setIsGenerating] = useState(false);
const [generatedScript, setGeneratedScript] = useState<GeneratedScript | null>(null);
const [selectedLanguage, setSelectedLanguage] = useState('bash');
const [savedScripts, setSavedScripts] = useState<GeneratedScript[]>(mockGeneratedScripts);
const [copied, setCopied] = useState(false);
const [activeTab, setActiveTab] = useState('generate');
const handleGenerate = async () => {
if (!prompt.trim()) return;
setIsGenerating(true);
// 模拟AI生成脚本
setTimeout(() => {
const mockScripts: Record<string, GeneratedScript> = {
bash: {
id: Date.now().toString(),
name: '自动生成的运维脚本',
description: prompt,
language: 'bash',
code: `#!/bin/bash
# AI生成的运维脚本
# 描述: ${prompt}
# 生成时间: ${new Date().toLocaleString()}
echo "🚀 开始执行运维任务..."
# 检查系统信息
echo "系统信息:"
echo "- 主机名: $(hostname)"
echo "- 操作系统: $(uname -o)"
echo "- 内核版本: $(uname -r)"
# 根据用户需求定制的功能
echo ""
echo "📊 执行任务: ${prompt}"
# TODO: 在这里添加具体的业务逻辑
# 这是一个AI生成的脚本框架,请根据实际需求修改
echo ""
echo "✅ 任务执行完成"`,
createdAt: new Date().toISOString(),
tags: ['AI生成', 'Bash'],
},
python: {
id: Date.now().toString(),
name: '自动生成的Python脚本',
description: prompt,
language: 'python',
code: `#!/usr/bin/env python3
# AI生成的运维脚本
# 描述: ${prompt}
# 生成时间: ${new Date().toLocaleString()}
import os
import sys
import subprocess
from datetime import datetime
def main():
"""主函数"""
print("🚀 开始执行运维任务...")
# 系统信息
print("\\n系统信息:")
print(f"- 当前时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
print(f"- 工作目录: {os.getcwd()}")
print(f"- Python版本: {sys.version}")
# 执行任务
print(f"\\n📊 执行任务: ${prompt}")
# TODO: 在这里添加具体的业务逻辑
# 这是一个AI生成的脚本框架,请根据实际需求修改
print("\\n✅ 任务执行完成")
if __name__ == '__main__':
main()`,
createdAt: new Date().toISOString(),
tags: ['AI生成', 'Python'],
},
ansible: {
id: Date.now().toString(),
name: '自动生成的Ansible Playbook',
description: prompt,
language: 'yaml',
code: `---
# AI生成的Ansible Playbook
# 描述: ${prompt}
# 生成时间: ${new Date().toLocaleString()}
- name: AI生成的运维任务
hosts: all
become: yes
gather_facts: yes
vars:
task_description: "${prompt}"
tasks:
- name: 显示任务信息
debug:
msg: "开始执行: {{ task_description }}"
- name: 收集系统信息
setup:
- name: 显示主机信息
debug:
msg:
- "主机名: {{ ansible_hostname }}"
- "操作系统: {{ ansible_distribution }} {{ ansible_distribution_version }}"
- "IP地址: {{ ansible_default_ipv4.address }}"
# TODO: 在这里添加具体的任务
# 这是一个AI生成的Playbook框架,请根据实际需求修改
- name: 任务完成
debug:
msg: "✅ 任务执行完成"`,
createdAt: new Date().toISOString(),
tags: ['AI生成', 'Ansible'],
},
};
setGeneratedScript(mockScripts[selectedLanguage] || mockScripts.bash);
setIsGenerating(false);
}, 2000);
};
const handleTemplateClick = (template: ScriptTemplate) => {
setPrompt(template.prompt);
};
const copyScript = () => {
if (generatedScript) {
navigator.clipboard.writeText(generatedScript.code);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
};
const saveScript = () => {
if (generatedScript) {
setSavedScripts([generatedScript, ...savedScripts]);
setActiveTab('history');
}
};
const getLanguageIcon = (language: string) => {
switch (language) {
case 'bash':
return <Terminal className="h-4 w-4" />;
case 'python':
return <Code className="h-4 w-4" />;
case 'yaml':
return <FileCode className="h-4 w-4" />;
default:
return <Shell className="h-4 w-4" />;
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center">
<Wand2 className="h-5 w-5 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold text-slate-100">AI 脚本生成器</h1>
<p className="text-sm text-slate-500">自然语言描述,AI自动生成运维脚本</p>
</div>
</div>
<Badge variant="outline" className="border-purple-500/30 text-purple-400 bg-purple-500/10">
<Sparkles className="h-3 w-3 mr-1" />
AI 驱动
</Badge>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="generate" className="data-[state=active]:bg-slate-700">
<Sparkles className="h-4 w-4 mr-2" />
生成脚本
</TabsTrigger>
<TabsTrigger value="history" className="data-[state=active]:bg-slate-700">
<History className="h-4 w-4 mr-2" />
历史记录
</TabsTrigger>
<TabsTrigger value="templates" className="data-[state=active]:bg-slate-700">
<Star className="h-4 w-4 mr-2" />
模板库
</TabsTrigger>
</TabsList>
<TabsContent value="generate" className="space-y-4">
<div className="grid grid-cols-2 gap-6">
{/* Input Area */}
<Card className="bg-slate-800 border-slate-700">
<CardHeader className="pb-3">
<CardTitle className="text-slate-100 flex items-center gap-2">
<MessageSquare className="h-5 w-5 text-blue-400" />
描述你的需求
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div>
<Label className="text-slate-300 mb-2 block">脚本语言</Label>
<Select value={selectedLanguage} onValueChange={setSelectedLanguage}>
<SelectTrigger className="bg-slate-900 border-slate-700 text-slate-100">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="bash">
<div className="flex items-center gap-2">
<Terminal className="h-4 w-4" />
Bash Shell
</div>
</SelectItem>
<SelectItem value="python">
<div className="flex items-center gap-2">
<Code className="h-4 w-4" />
Python
</div>
</SelectItem>
<SelectItem value="ansible">
<div className="flex items-center gap-2">
<Cpu className="h-4 w-4" />
Ansible Playbook
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label className="text-slate-300 mb-2 block">需求描述</Label>
<Textarea
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="例如:生成一个检查服务器磁盘空间的脚本,当使用率超过90%时发送告警邮件..."
className="bg-slate-900 border-slate-700 text-slate-100 min-h-[200px]"
/>
</div>
<Button
className="w-full bg-blue-600 hover:bg-blue-700"
onClick={handleGenerate}
disabled={isGenerating || !prompt.trim()}
>
{isGenerating ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
AI生成中...
</>
) : (
<>
<Sparkles className="h-4 w-4 mr-2" />
生成脚本
</>
)}
</Button>
</CardContent>
</Card>
{/* Output Area */}
<Card className="bg-slate-800 border-slate-700 flex flex-col">
<CardHeader className="pb-3 border-b border-slate-700">
<div className="flex items-center justify-between">
<CardTitle className="text-slate-100 flex items-center gap-2">
<Terminal className="h-5 w-5 text-emerald-400" />
生成的脚本
</CardTitle>
{generatedScript && (
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={copyScript} className="text-slate-400">
{copied ? <Check className="h-4 w-4" /> : <Copy className="h-4 w-4" />}
</Button>
<Button variant="ghost" size="sm" onClick={saveScript} className="text-slate-400">
<Save className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="text-slate-400">
<Download className="h-4 w-4" />
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent className="flex-1 p-0">
<ScrollArea className="h-[400px]">
{generatedScript ? (
<div className="p-4">
<div className="flex items-center gap-2 mb-3">
{getLanguageIcon(generatedScript.language)}
<span className="text-sm text-slate-400">{generatedScript.name}</span>
</div>
<pre className="text-sm text-slate-300 whitespace-pre-wrap font-mono">
{generatedScript.code}
</pre>
</div>
) : (
<div className="flex flex-col items-center justify-center h-full text-slate-500">
<Bot className="h-16 w-16 mb-4 opacity-30" />
<p>在左侧描述需求,AI将为你生成脚本</p>
</div>
)}
</ScrollArea>
</CardContent>
{generatedScript && (
<div className="p-4 border-t border-slate-700 flex gap-2">
<Button className="flex-1 bg-emerald-600 hover:bg-emerald-700">
<Play className="h-4 w-4 mr-2" />
测试运行
</Button>
<Button variant="outline" className="flex-1 border-slate-700 text-slate-300">
<Save className="h-4 w-4 mr-2" />
保存到脚本库
</Button>
</div>
)}
</Card>
</div>
</TabsContent>
<TabsContent value="history">
<Card className="bg-slate-800 border-slate-700">
<CardHeader>
<CardTitle className="text-slate-100">历史生成的脚本</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{savedScripts.map((script) => (
<div
key={script.id}
className="p-4 rounded-lg border border-slate-700 bg-slate-800/50 hover:border-slate-600 cursor-pointer"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-slate-700 flex items-center justify-center">
{getLanguageIcon(script.language)}
</div>
<div>
<h4 className="font-medium text-slate-100">{script.name}</h4>
<p className="text-sm text-slate-400">{script.description}</p>
<div className="flex items-center gap-2 mt-1">
<Badge variant="outline" className="text-xs border-slate-600 text-slate-400">
{script.language.toUpperCase()}
</Badge>
<span className="text-xs text-slate-500">{script.createdAt}</span>
</div>
</div>
</div>
<div className="flex gap-1">
{script.tags.map((tag) => (
<Badge key={tag} variant="outline" className="text-xs border-slate-600 text-slate-400">
{tag}
</Badge>
))}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="templates">
<Card className="bg-slate-800 border-slate-700">
<CardHeader>
<CardTitle className="text-slate-100">脚本模板库</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
{scriptTemplates.map((template) => (
<div
key={template.id}
className="p-4 rounded-lg border border-slate-700 bg-slate-800/50 hover:border-blue-500/50 hover:bg-slate-700/50 cursor-pointer transition-all"
onClick={() => handleTemplateClick(template)}
>
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center mb-3">
<template.icon className="h-5 w-5 text-blue-400" />
</div>
<h4 className="font-medium text-slate-100 mb-1">{template.name}</h4>
<p className="text-sm text-slate-400">{template.description}</p>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,459 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
CheckSquare,
Plus,
Search,
Clock,
CheckCircle,
XCircle,
User,
ArrowRight,
Settings,
Eye,
Edit,
GitBranch,
} from 'lucide-react';
import { approvalFlows, approvalInstances } from '@/mocks/data';
import type { ApprovalFlow as ApprovalFlowType, ApprovalInstance } from '@/types';
export function ApprovalFlowManager() {
const [activeTab, setActiveTab] = useState('instances');
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [selectedInstance, setSelectedInstance] = useState<ApprovalInstance | null>(null);
const filteredInstances = approvalInstances.filter((instance) => {
const matchesSearch =
instance.flowName.toLowerCase().includes(searchQuery.toLowerCase()) ||
instance.applicantName.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = statusFilter === 'all' || instance.status === statusFilter;
return matchesSearch && matchesStatus;
});
const getStatusBadge = (status: ApprovalInstance['status']) => {
const configs = {
pending: { label: '审批中', className: 'border-amber-500/30 text-amber-400 bg-amber-500/10', icon: Clock },
approved: { label: '已通过', className: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10', icon: CheckCircle },
rejected: { label: '已拒绝', className: 'border-red-500/30 text-red-400 bg-red-500/10', icon: XCircle },
};
const config = configs[status];
return (
<Badge variant="outline" className={config.className}>
<config.icon className="h-3 w-3 mr-1" />
{config.label}
</Badge>
);
};
// 模拟审批流程图
const renderFlowChart = (flow: ApprovalFlowType) => {
return (
<div className="flex items-center gap-2 overflow-x-auto py-4">
{flow.nodes.map((node, index) => (
<div key={node.id} className="flex items-center">
<div
className={cn(
'px-4 py-2 rounded-lg border min-w-[100px] text-center',
node.type === 'start' && 'bg-emerald-500/10 border-emerald-500/30 text-emerald-400',
node.type === 'approval' && 'bg-blue-500/10 border-blue-500/30 text-blue-400',
node.type === 'condition' && 'bg-amber-500/10 border-amber-500/30 text-amber-400',
node.type === 'end' && 'bg-slate-500/10 border-slate-500/30 text-slate-400'
)}
>
<p className="text-sm font-medium">{node.name}</p>
{node.approvers && (
<p className="text-xs text-slate-500 mt-1">{node.approvers.join(', ')}</p>
)}
</div>
{index < flow.nodes.length - 1 && (
<ArrowRight className="h-4 w-4 text-slate-600 mx-2" />
)}
</div>
))}
</div>
);
};
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">审批流程</h1>
<p className="text-sm text-slate-500 mt-1">工作流管理与审批处理</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<Settings className="h-4 w-4 mr-2" />
流程设计器
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
新建流程
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">待审批</p>
<p className="text-2xl font-bold text-amber-400">
{approvalInstances.filter((i) => i.status === 'pending').length}
</p>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
<Clock className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">已通过</p>
<p className="text-2xl font-bold text-emerald-400">
{approvalInstances.filter((i) => i.status === 'approved').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<CheckCircle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">已拒绝</p>
<p className="text-2xl font-bold text-red-400">
{approvalInstances.filter((i) => i.status === 'rejected').length}
</p>
</div>
<div className="p-3 rounded-lg bg-red-500/10 text-red-400">
<XCircle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">流程模板</p>
<p className="text-2xl font-bold text-slate-100">{approvalFlows.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<GitBranch className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="instances" className="data-[state=active]:bg-slate-700">
审批实例
</TabsTrigger>
<TabsTrigger value="flows" className="data-[state=active]:bg-slate-700">
流程模板
</TabsTrigger>
<TabsTrigger value="my-approvals" className="data-[state=active]:bg-slate-700">
我的审批
</TabsTrigger>
</TabsList>
<TabsContent value="instances" className="mt-4 space-y-4">
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索流程名称或申请人..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
>
<option value="all">全部状态</option>
<option value="pending">审批中</option>
<option value="approved">已通过</option>
<option value="rejected">已拒绝</option>
</select>
</div>
{/* Instances Table */}
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">流程名称</TableHead>
<TableHead className="text-slate-400">申请人</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">当前节点</TableHead>
<TableHead className="text-slate-400">创建时间</TableHead>
<TableHead className="text-slate-400">完成时间</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredInstances.map((instance) => (
<TableRow key={instance.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-slate-500" />
<span className="text-sm font-medium text-slate-200">{instance.flowName}</span>
</div>
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-sm text-slate-300">
<User className="h-3.5 w-3.5 text-slate-500" />
{instance.applicantName}
</div>
</TableCell>
<TableCell>{getStatusBadge(instance.status)}</TableCell>
<TableCell>
<Badge variant="outline" className="border-slate-700 text-slate-400">
{instance.currentNode}
</Badge>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{new Date(instance.createdAt).toLocaleString()}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{instance.completedAt
? new Date(instance.completedAt).toLocaleString()
: '--'}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 text-slate-400 hover:text-slate-100"
onClick={() => setSelectedInstance(instance)}
>
<Eye className="h-4 w-4 mr-1" />
查看
</Button>
{instance.status === 'pending' && (
<>
<Button variant="ghost" size="sm" className="h-8 text-emerald-400 hover:text-emerald-400">
<CheckCircle className="h-4 w-4 mr-1" />
通过
</Button>
<Button variant="ghost" size="sm" className="h-8 text-red-400 hover:text-red-400">
<XCircle className="h-4 w-4 mr-1" />
拒绝
</Button>
</>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="flows" className="mt-4">
<div className="grid grid-cols-1 gap-4">
{approvalFlows.map((flow) => (
<Card key={flow.id} className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
<GitBranch className="h-5 w-5 text-blue-400" />
</div>
<div>
<CardTitle className="text-base text-slate-100">{flow.name}</CardTitle>
<CardDescription className="text-slate-500">{flow.description}</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={
flow.enabled
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-slate-500/30 text-slate-400 bg-slate-500/10'
}
>
{flow.enabled ? '已启用' : '已停用'}
</Badge>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Edit className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="bg-slate-950 rounded-lg p-4 overflow-x-auto">
{renderFlowChart(flow)}
</div>
<div className="flex items-center justify-between mt-4 text-sm text-slate-500">
<span>创建于 {new Date(flow.createdAt).toLocaleDateString()}</span>
<span>{flow.nodes.length} 个节点</span>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="my-approvals" className="mt-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-8">
<div className="text-center">
<CheckSquare className="h-12 w-12 mx-auto text-slate-500 mb-4" />
<h3 className="text-lg font-medium text-slate-300 mb-2">暂无待审批事项</h3>
<p className="text-sm text-slate-500">当前没有需要您审批的流程</p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Instance Detail Dialog */}
<Dialog open={!!selectedInstance} onOpenChange={() => setSelectedInstance(null)}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<GitBranch className="h-5 w-5 text-blue-400" />
{selectedInstance?.flowName}
</DialogTitle>
<DialogDescription className="text-slate-500">
申请人: {selectedInstance?.applicantName} · 创建于{' '}
{selectedInstance && new Date(selectedInstance.createdAt).toLocaleString()}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Approval Timeline */}
<div className="space-y-4">
<h4 className="text-sm font-medium text-slate-300">审批进度</h4>
<div className="space-y-4">
<div className="flex gap-4">
<div className="flex flex-col items-center">
<div className="w-8 h-8 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center">
<CheckCircle className="h-4 w-4" />
</div>
<div className="w-0.5 h-full bg-slate-700 mt-2" />
</div>
<div className="flex-1 pb-4">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-200">提交申请</span>
<span className="text-xs text-slate-500">
{selectedInstance && new Date(selectedInstance.createdAt).toLocaleString()}
</span>
</div>
<p className="text-sm text-slate-400 mt-1">申请人: {selectedInstance?.applicantName}</p>
</div>
</div>
<div className="flex gap-4">
<div className="flex flex-col items-center">
<div
className={cn(
'w-8 h-8 rounded-full flex items-center justify-center',
selectedInstance?.status === 'approved'
? 'bg-emerald-500/20 text-emerald-400'
: selectedInstance?.status === 'rejected'
? 'bg-red-500/20 text-red-400'
: 'bg-amber-500/20 text-amber-400'
)}
>
{selectedInstance?.status === 'approved' ? (
<CheckCircle className="h-4 w-4" />
) : selectedInstance?.status === 'rejected' ? (
<XCircle className="h-4 w-4" />
) : (
<Clock className="h-4 w-4" />
)}
</div>
</div>
<div className="flex-1">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-200">{selectedInstance?.currentNode}</span>
<span className="text-xs text-slate-500">
{selectedInstance?.completedAt
? new Date(selectedInstance.completedAt).toLocaleString()
: '进行中'}
</span>
</div>
<p className="text-sm text-slate-400 mt-1">
{selectedInstance?.status === 'pending'
? '等待审批人处理'
: selectedInstance?.status === 'approved'
? '审批已通过'
: '审批已拒绝'}
</p>
</div>
</div>
</div>
</div>
</div>
<DialogFooter className="gap-2">
{selectedInstance?.status === 'pending' && (
<>
<Button variant="outline" className="border-red-700 text-red-400 hover:bg-red-500/10">
<XCircle className="h-4 w-4 mr-2" />
拒绝
</Button>
<Button className="bg-emerald-600 hover:bg-emerald-700 text-white">
<CheckCircle className="h-4 w-4 mr-2" />
通过
</Button>
</>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,599 @@
import React, { useState, useEffect } from 'react';
import {
Search, Filter, Eye, Download, Shield, AlertTriangle, CheckCircle, XCircle,
Database, FileText, Clock, User, Server, Code, AlertOctagon, RotateCcw,
ChevronDown, ChevronUp, Play, Pause, Settings, Trash2, RefreshCw, History
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { toast } from 'sonner';
import { sqlAuditApi } from '@/services/api';
interface SQLAuditRecord {
id: string;
sqlContent: string;
sqlType: string;
databaseName: string;
tableName: string;
riskLevel: 'high' | 'medium' | 'low' | 'safe';
riskRules: string[];
status: 'pending' | 'approved' | 'rejected' | 'executed';
executor: string;
approver?: string;
executeTime?: string;
createdAt: string;
affectedRows?: number;
executionTime?: number;
sourceIP: string;
application: string;
ticketId?: string;
}
interface AuditRule {
id: string;
name: string;
description: string;
enabled: boolean;
severity: 'high' | 'medium' | 'low';
category: string;
}
const SQLAuditManager: React.FC = () => {
const [records, setRecords] = useState<SQLAuditRecord[]>([]);
const [rules, setRules] = useState<AuditRule[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [riskFilter, setRiskFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [selectedRecord, setSelectedRecord] = useState<SQLAuditRecord | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [newAuditOpen, setNewAuditOpen] = useState(false);
const [rulesOpen, setRulesOpen] = useState(false);
const [sqlContent, setSqlContent] = useState('');
const [activeTab, setActiveTab] = useState('records');
useEffect(() => {
fetchRecords();
fetchRules();
}, []);
const fetchRecords = async () => {
setLoading(true);
try {
const res = await sqlAuditApi.getRecords();
setRecords(res.data || []);
} catch (error) {
toast.error('获取SQL审计记录失败');
} finally {
setLoading(false);
}
};
const fetchRules = async () => {
try {
const res = await sqlAuditApi.getRules();
setRules(res.data || []);
} catch (error) {
toast.error('获取审计规则失败');
}
};
const handleAnalyzeSQL = async () => {
if (!sqlContent.trim()) {
toast.error('请输入SQL语句');
return;
}
try {
const res = await sqlAuditApi.analyzeSQL({ sql: sqlContent });
toast.success('SQL分析完成');
setNewAuditOpen(false);
setSqlContent('');
fetchRecords();
} catch (error) {
toast.error('SQL分析失败');
}
};
const handleApprove = async (id: string) => {
try {
await sqlAuditApi.approve(id);
toast.success('SQL已审批通过');
fetchRecords();
} catch (error) {
toast.error('审批失败');
}
};
const handleReject = async (id: string) => {
try {
await sqlAuditApi.reject(id);
toast.success('SQL已驳回');
fetchRecords();
} catch (error) {
toast.error('驳回失败');
}
};
const handleExecute = async (id: string) => {
try {
await sqlAuditApi.execute(id);
toast.success('SQL执行成功');
fetchRecords();
} catch (error) {
toast.error('SQL执行失败');
}
};
const toggleRule = async (ruleId: string, enabled: boolean) => {
try {
await sqlAuditApi.toggleRule(ruleId, enabled);
setRules(rules.map(r => r.id === ruleId ? { ...r, enabled } : r));
toast.success('规则状态已更新');
} catch (error) {
toast.error('更新规则失败');
}
};
const getRiskBadge = (level: string) => {
switch (level) {
case 'high':
return <Badge variant="destructive" className="gap-1"><AlertOctagon className="w-3 h-3" />高危</Badge>;
case 'medium':
return <Badge variant="default" className="bg-orange-500 gap-1"><AlertTriangle className="w-3 h-3" />中危</Badge>;
case 'low':
return <Badge variant="default" className="bg-yellow-500 gap-1"><AlertTriangle className="w-3 h-3" />低危</Badge>;
case 'safe':
return <Badge variant="default" className="bg-green-500 gap-1"><CheckCircle className="w-3 h-3" />安全</Badge>;
default:
return <Badge variant="secondary">未知</Badge>;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'pending':
return <Badge variant="outline" className="gap-1"><Clock className="w-3 h-3" />待审批</Badge>;
case 'approved':
return <Badge variant="default" className="bg-blue-500 gap-1"><CheckCircle className="w-3 h-3" />已批准</Badge>;
case 'rejected':
return <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" />已驳回</Badge>;
case 'executed':
return <Badge variant="default" className="bg-green-500 gap-1"><CheckCircle className="w-3 h-3" />已执行</Badge>;
default:
return <Badge variant="secondary">未知</Badge>;
}
};
const filteredRecords = records.filter(record => {
const matchSearch =
record.sqlContent.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.databaseName.toLowerCase().includes(searchQuery.toLowerCase()) ||
record.executor.toLowerCase().includes(searchQuery.toLowerCase());
const matchRisk = riskFilter === 'all' || record.riskLevel === riskFilter;
const matchStatus = statusFilter === 'all' || record.status === statusFilter;
return matchSearch && matchRisk && matchStatus;
});
const stats = {
total: records.length,
highRisk: records.filter(r => r.riskLevel === 'high').length,
pending: records.filter(r => r.status === 'pending').length,
executed: records.filter(r => r.status === 'executed').length
};
return (
<div className="space-y-6">
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">总审计数</p>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
<div className="p-3 bg-blue-100 rounded-lg">
<Database className="w-5 h-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">高危SQL</p>
<p className="text-2xl font-bold text-red-600">{stats.highRisk}</p>
</div>
<div className="p-3 bg-red-100 rounded-lg">
<AlertOctagon className="w-5 h-5 text-red-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">待审批</p>
<p className="text-2xl font-bold text-orange-600">{stats.pending}</p>
</div>
<div className="p-3 bg-orange-100 rounded-lg">
<Clock className="w-5 h-5 text-orange-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">已执行</p>
<p className="text-2xl font-bold text-green-600">{stats.executed}</p>
</div>
<div className="p-3 bg-green-100 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
</div>
</CardContent>
</Card>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-2 lg:w-[400px]">
<TabsTrigger value="records">审计记录</TabsTrigger>
<TabsTrigger value="rules">审计规则</TabsTrigger>
</TabsList>
<TabsContent value="records" className="space-y-4">
{/* 操作栏 */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索SQL内容、数据库、执行人..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Select value={riskFilter} onValueChange={setRiskFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="风险等级" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部风险</SelectItem>
<SelectItem value="high">高危</SelectItem>
<SelectItem value="medium">中危</SelectItem>
<SelectItem value="low">低危</SelectItem>
<SelectItem value="safe">安全</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="pending">待审批</SelectItem>
<SelectItem value="approved">已批准</SelectItem>
<SelectItem value="rejected">已驳回</SelectItem>
<SelectItem value="executed">已执行</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchRecords}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
</Button>
<Button onClick={() => setNewAuditOpen(true)}>
<Shield className="w-4 h-4 mr-2" />
新建审计
</Button>
</div>
</div>
{/* 记录列表 */}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">SQL内容</th>
<th className="px-4 py-3 text-left text-sm font-medium">数据库</th>
<th className="px-4 py-3 text-left text-sm font-medium">风险等级</th>
<th className="px-4 py-3 text-left text-sm font-medium">状态</th>
<th className="px-4 py-3 text-left text-sm font-medium">执行人</th>
<th className="px-4 py-3 text-left text-sm font-medium">创建时间</th>
<th className="px-4 py-3 text-left text-sm font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
加载中...
</td>
</tr>
) : filteredRecords.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">
暂无SQL审计记录
</td>
</tr>
) : (
filteredRecords.map((record) => (
<tr key={record.id} className="hover:bg-muted/50">
<td className="px-4 py-3">
<div className="max-w-[300px] truncate font-mono text-sm" title={record.sqlContent}>
{record.sqlContent}
</div>
<div className="flex gap-1 mt-1">
{record.riskRules.map((rule, idx) => (
<Badge key={idx} variant="outline" className="text-xs">{rule}</Badge>
))}
</div>
</td>
<td className="px-4 py-3 text-sm">
<div>{record.databaseName}</div>
<div className="text-muted-foreground text-xs">{record.tableName}</div>
</td>
<td className="px-4 py-3">{getRiskBadge(record.riskLevel)}</td>
<td className="px-4 py-3">{getStatusBadge(record.status)}</td>
<td className="px-4 py-3 text-sm">{record.executor}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{record.createdAt}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => { setSelectedRecord(record); setDetailOpen(true); }}
>
<Eye className="w-4 h-4" />
</Button>
{record.status === 'pending' && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => handleApprove(record.id)}
>
<CheckCircle className="w-4 h-4 text-green-600" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleReject(record.id)}
>
<XCircle className="w-4 h-4 text-red-600" />
</Button>
</>
)}
{record.status === 'approved' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleExecute(record.id)}
>
<Play className="w-4 h-4 text-blue-600" />
</Button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="rules" className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="w-5 h-5" />
SQL审计规则配置
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{rules.map((rule) => (
<div key={rule.id} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{rule.name}</span>
<Badge variant={rule.severity === 'high' ? 'destructive' : rule.severity === 'medium' ? 'default' : 'secondary'}>
{rule.severity === 'high' ? '高危' : rule.severity === 'medium' ? '中危' : '低危'}
</Badge>
<Badge variant="outline">{rule.category}</Badge>
</div>
<p className="text-sm text-muted-foreground mt-1">{rule.description}</p>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<Switch
checked={rule.enabled}
onCheckedChange={(checked) => toggleRule(rule.id, checked)}
/>
<Label className="text-sm">{rule.enabled ? '已启用' : '已禁用'}</Label>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 新建审计对话框 */}
<Dialog open={newAuditOpen} onOpenChange={setNewAuditOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>新建SQL审计</DialogTitle>
<DialogDescription>输入SQL语句进行风险分析和审计</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>SQL语句</Label>
<Textarea
value={sqlContent}
onChange={(e) => setSqlContent(e.target.value)}
placeholder="请输入需要审计的SQL语句..."
className="min-h-[200px] font-mono text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>目标数据库</Label>
<Select>
<SelectTrigger>
<SelectValue placeholder="选择数据库" />
</SelectTrigger>
<SelectContent>
<SelectItem value="db1">生产数据库</SelectItem>
<SelectItem value="db2">测试数据库</SelectItem>
<SelectItem value="db3">开发数据库</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>执行环境</Label>
<Select>
<SelectTrigger>
<SelectValue placeholder="选择环境" />
</SelectTrigger>
<SelectContent>
<SelectItem value="prod">生产环境</SelectItem>
<SelectItem value="staging">预发环境</SelectItem>
<SelectItem value="dev">开发环境</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setNewAuditOpen(false)}>取消</Button>
<Button onClick={handleAnalyzeSQL}>
<Shield className="w-4 h-4 mr-2" />
分析SQL
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 详情对话框 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>SQL审计详情</DialogTitle>
</DialogHeader>
{selectedRecord && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-muted-foreground">数据库</Label>
<p className="font-medium">{selectedRecord.databaseName}</p>
</div>
<div>
<Label className="text-muted-foreground">数据表</Label>
<p className="font-medium">{selectedRecord.tableName}</p>
</div>
<div>
<Label className="text-muted-foreground">SQL类型</Label>
<p className="font-medium">{selectedRecord.sqlType}</p>
</div>
<div>
<Label className="text-muted-foreground">风险等级</Label>
<p>{getRiskBadge(selectedRecord.riskLevel)}</p>
</div>
<div>
<Label className="text-muted-foreground">状态</Label>
<p>{getStatusBadge(selectedRecord.status)}</p>
</div>
<div>
<Label className="text-muted-foreground">执行人</Label>
<p className="font-medium">{selectedRecord.executor}</p>
</div>
</div>
<div>
<Label className="text-muted-foreground">SQL内容</Label>
<div className="p-3 bg-muted rounded-lg font-mono text-sm mt-1">
<pre>{selectedRecord.sqlContent}</pre>
</div>
</div>
{selectedRecord.riskRules.length > 0 && (
<div>
<Label className="text-muted-foreground">触发规则</Label>
<div className="flex gap-2 mt-1">
{selectedRecord.riskRules.map((rule, idx) => (
<Badge key={idx} variant="destructive">{rule}</Badge>
))}
</div>
</div>
)}
{selectedRecord.executionTime && (
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-muted-foreground">执行时间</Label>
<p className="font-medium">{selectedRecord.executeTime}</p>
</div>
<div>
<Label className="text-muted-foreground">执行耗时</Label>
<p className="font-medium">{selectedRecord.executionTime}ms</p>
</div>
<div>
<Label className="text-muted-foreground">影响行数</Label>
<p className="font-medium">{selectedRecord.affectedRows}</p>
</div>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDetailOpen(false)}>关闭</Button>
{selectedRecord?.status === 'pending' && (
<>
<Button variant="destructive" onClick={() => { handleReject(selectedRecord.id); setDetailOpen(false); }}>
<XCircle className="w-4 h-4 mr-2" />
驳回
</Button>
<Button onClick={() => { handleApprove(selectedRecord.id); setDetailOpen(false); }}>
<CheckCircle className="w-4 h-4 mr-2" />
批准
</Button>
</>
)}
{selectedRecord?.status === 'approved' && (
<Button onClick={() => { handleExecute(selectedRecord.id); setDetailOpen(false); }}>
<Play className="w-4 h-4 mr-2" />
执行SQL
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default SQLAuditManager;
@@ -0,0 +1,703 @@
import React, { useState, useEffect } from 'react';
import {
Play, FileCode, Server, Clock, CheckCircle, XCircle, RotateCcw, Eye,
Download, Plus, Search, Filter, Trash2, Edit, Copy, Terminal,
BookOpen, Settings, ChevronRight, Pause, RefreshCw, FolderOpen,
Zap, AlertTriangle, History, Code, Users, Globe, Shield
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { toast } from 'sonner';
import { ansibleApi } from '@/services/api';
interface Playbook {
id: string;
name: string;
description: string;
content: string;
tags: string[];
category: string;
createdAt: string;
updatedAt: string;
createdBy: string;
variables: PlaybookVariable[];
}
interface PlaybookVariable {
name: string;
defaultValue: string;
description: string;
required: boolean;
}
interface Job {
id: string;
playbookId: string;
playbookName: string;
status: 'pending' | 'running' | 'success' | 'failed' | 'cancelled';
targetHosts: string[];
variables: Record<string, string>;
startedAt: string;
completedAt?: string;
duration?: number;
output: string;
triggeredBy: string;
}
interface Inventory {
id: string;
name: string;
hosts: Host[];
groups: Group[];
}
interface Host {
id: string;
name: string;
ip: string;
port: number;
user: string;
os: string;
}
interface Group {
id: string;
name: string;
hosts: string[];
}
const AnsibleManager: React.FC = () => {
const [playbooks, setPlaybooks] = useState<Playbook[]>([]);
const [jobs, setJobs] = useState<Job[]>([]);
const [inventories, setInventories] = useState<Inventory[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState<string>('all');
const [activeTab, setActiveTab] = useState('playbooks');
// Dialog states
const [createOpen, setCreateOpen] = useState(false);
const [executeOpen, setExecuteOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [logsOpen, setLogsOpen] = useState(false);
const [selectedPlaybook, setSelectedPlaybook] = useState<Playbook | null>(null);
const [selectedJob, setSelectedJob] = useState<Job | null>(null);
// Form states
const [newPlaybook, setNewPlaybook] = useState({
name: '',
description: '',
content: '',
category: 'deployment',
tags: ''
});
const [executionConfig, setExecutionConfig] = useState({
inventoryId: '',
targetHosts: [] as string[],
variables: {} as Record<string, string>
});
useEffect(() => {
fetchPlaybooks();
fetchJobs();
fetchInventories();
}, []);
const fetchPlaybooks = async () => {
setLoading(true);
try {
const res = await ansibleApi.getPlaybooks();
setPlaybooks(res.data || []);
} catch (error) {
toast.error('获取Playbook列表失败');
} finally {
setLoading(false);
}
};
const fetchJobs = async () => {
try {
const res = await ansibleApi.getJobs();
setJobs(res.data || []);
} catch (error) {
toast.error('获取执行记录失败');
}
};
const fetchInventories = async () => {
try {
const res = await ansibleApi.getInventories();
setInventories(res.data || []);
} catch (error) {
toast.error('获取主机清单失败');
}
};
const handleCreatePlaybook = async () => {
if (!newPlaybook.name || !newPlaybook.content) {
toast.error('请填写完整信息');
return;
}
try {
await ansibleApi.createPlaybook({
...newPlaybook,
tags: newPlaybook.tags.split(',').map(t => t.trim())
});
toast.success('Playbook创建成功');
setCreateOpen(false);
setNewPlaybook({ name: '', description: '', content: '', category: 'deployment', tags: '' });
fetchPlaybooks();
} catch (error) {
toast.error('创建失败');
}
};
const handleExecute = async () => {
if (!selectedPlaybook || !executionConfig.inventoryId) {
toast.error('请选择目标主机清单');
return;
}
try {
await ansibleApi.executePlaybook(selectedPlaybook.id, executionConfig);
toast.success('Playbook执行已启动');
setExecuteOpen(false);
fetchJobs();
} catch (error) {
toast.error('执行失败');
}
};
const handleDelete = async (id: string) => {
if (!confirm('确定删除此Playbook吗?')) return;
try {
await ansibleApi.deletePlaybook(id);
toast.success('删除成功');
fetchPlaybooks();
} catch (error) {
toast.error('删除失败');
}
};
const handleClone = async (playbook: Playbook) => {
try {
await ansibleApi.createPlaybook({
...playbook,
name: `${playbook.name} (副本)`,
id: undefined
});
toast.success('复制成功');
fetchPlaybooks();
} catch (error) {
toast.error('复制失败');
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'pending':
return <Badge variant="outline" className="gap-1"><Clock className="w-3 h-3" />等待中</Badge>;
case 'running':
return <Badge variant="default" className="bg-blue-500 gap-1"><RefreshCw className="w-3 h-3 animate-spin" />执行中</Badge>;
case 'success':
return <Badge variant="default" className="bg-green-500 gap-1"><CheckCircle className="w-3 h-3" />成功</Badge>;
case 'failed':
return <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" />失败</Badge>;
case 'cancelled':
return <Badge variant="secondary" className="gap-1"><Pause className="w-3 h-3" />已取消</Badge>;
default:
return <Badge variant="secondary">未知</Badge>;
}
};
const filteredPlaybooks = playbooks.filter(pb => {
const matchSearch = pb.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
pb.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
pb.tags.some(t => t.toLowerCase().includes(searchQuery.toLowerCase()));
const matchCategory = categoryFilter === 'all' || pb.category === categoryFilter;
return matchSearch && matchCategory;
});
const categories = [
{ value: 'deployment', label: '应用部署' },
{ value: 'configuration', label: '配置管理' },
{ value: 'maintenance', label: '系统维护' },
{ value: 'security', label: '安全加固' },
{ value: 'backup', label: '备份恢复' },
{ value: 'monitoring', label: '监控配置' }
];
const samplePlaybook = `---
- name: Deploy Application
hosts: all
become: yes
vars:
app_name: myapp
app_version: "1.0.0"
deploy_path: /opt/{{ app_name }}
tasks:
- name: Install dependencies
apt:
name:
- docker.io
- docker-compose
state: present
update_cache: yes
- name: Create deploy directory
file:
path: "{{ deploy_path }}"
state: directory
mode: '0755'
- name: Deploy application
template:
src: docker-compose.yml.j2
dest: "{{ deploy_path }}/docker-compose.yml"
notify: restart app
handlers:
- name: restart app
command: docker-compose up -d
args:
chdir: "{{ deploy_path }}"`;
return (
<div className="space-y-6">
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">Playbook总数</p>
<p className="text-2xl font-bold">{playbooks.length}</p>
</div>
<div className="p-3 bg-blue-100 rounded-lg">
<FileCode className="w-5 h-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">执行中</p>
<p className="text-2xl font-bold text-blue-600">
{jobs.filter(j => j.status === 'running').length}
</p>
</div>
<div className="p-3 bg-blue-100 rounded-lg">
<RefreshCw className="w-5 h-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">今日成功</p>
<p className="text-2xl font-bold text-green-600">
{jobs.filter(j => j.status === 'success').length}
</p>
</div>
<div className="p-3 bg-green-100 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">执行失败</p>
<p className="text-2xl font-bold text-red-600">
{jobs.filter(j => j.status === 'failed').length}
</p>
</div>
<div className="p-3 bg-red-100 rounded-lg">
<XCircle className="w-5 h-5 text-red-600" />
</div>
</div>
</CardContent>
</Card>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3 lg:w-[450px]">
<TabsTrigger value="playbooks">Playbook管理</TabsTrigger>
<TabsTrigger value="jobs">执行记录</TabsTrigger>
<TabsTrigger value="inventories">主机清单</TabsTrigger>
</TabsList>
<TabsContent value="playbooks" className="space-y-4">
{/* 操作栏 */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索Playbook名称、标签..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="分类" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部分类</SelectItem>
{categories.map(c => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchPlaybooks}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
</Button>
<Button onClick={() => { setNewPlaybook({ ...newPlaybook, content: samplePlaybook }); setCreateOpen(true); }}>
<Plus className="w-4 h-4 mr-2" />
新建Playbook
</Button>
</div>
</div>
{/* Playbook列表 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{loading ? (
<div className="col-span-full text-center py-8 text-muted-foreground">加载中...</div>
) : filteredPlaybooks.length === 0 ? (
<div className="col-span-full text-center py-8 text-muted-foreground">暂无Playbook</div>
) : (
filteredPlaybooks.map((playbook) => (
<Card key={playbook.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
<FileCode className="w-5 h-5 text-blue-500" />
<CardTitle className="text-base">{playbook.name}</CardTitle>
</div>
<Badge variant="outline">
{categories.find(c => c.value === playbook.category)?.label || playbook.category}
</Badge>
</div>
<CardDescription className="line-clamp-2">{playbook.description}</CardDescription>
</CardHeader>
<CardContent className="pt-0">
<div className="flex flex-wrap gap-1 mb-3">
{playbook.tags.map((tag, idx) => (
<Badge key={idx} variant="secondary" className="text-xs">{tag}</Badge>
))}
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground mb-3">
<span>创建者: {playbook.createdBy}</span>
<span>更新: {playbook.updatedAt}</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => { setSelectedPlaybook(playbook); setDetailOpen(true); }}
>
<Eye className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => { setSelectedPlaybook(playbook); setExecutionConfig({ ...executionConfig, variables: {} }); setExecuteOpen(true); }}
>
<Play className="w-4 h-4 text-green-600" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleClone(playbook)}
>
<Copy className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(playbook.id)}
>
<Trash2 className="w-4 h-4 text-red-600" />
</Button>
</div>
</CardContent>
</Card>
))
)}
</div>
</TabsContent>
<TabsContent value="jobs" className="space-y-4">
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">任务ID</th>
<th className="px-4 py-3 text-left text-sm font-medium">Playbook</th>
<th className="px-4 py-3 text-left text-sm font-medium">状态</th>
<th className="px-4 py-3 text-left text-sm font-medium">目标主机</th>
<th className="px-4 py-3 text-left text-sm font-medium">执行人</th>
<th className="px-4 py-3 text-left text-sm font-medium">开始时间</th>
<th className="px-4 py-3 text-left text-sm font-medium">耗时</th>
<th className="px-4 py-3 text-left text-sm font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y">
{jobs.length === 0 ? (
<tr>
<td colSpan={8} className="px-4 py-8 text-center text-muted-foreground">
暂无执行记录
</td>
</tr>
) : (
jobs.map((job) => (
<tr key={job.id} className="hover:bg-muted/50">
<td className="px-4 py-3 text-sm font-mono">{job.id.slice(0, 8)}</td>
<td className="px-4 py-3 text-sm">{job.playbookName}</td>
<td className="px-4 py-3">{getStatusBadge(job.status)}</td>
<td className="px-4 py-3 text-sm">{job.targetHosts.length} 台</td>
<td className="px-4 py-3 text-sm">{job.triggeredBy}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{job.startedAt}</td>
<td className="px-4 py-3 text-sm">
{job.duration ? `${Math.floor(job.duration / 1000)}s` : '-'}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => { setSelectedJob(job); setLogsOpen(true); }}
>
<Terminal className="w-4 h-4" />
</Button>
{job.status === 'running' && (
<Button variant="ghost" size="sm">
<Pause className="w-4 h-4 text-orange-600" />
</Button>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="inventories" className="space-y-4">
<div className="flex justify-end">
<Button>
<Plus className="w-4 h-4 mr-2" />
新建清单
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{inventories.map((inventory) => (
<Card key={inventory.id}>
<CardHeader>
<div className="flex items-center gap-2">
<Server className="w-5 h-5 text-blue-500" />
<CardTitle className="text-base">{inventory.name}</CardTitle>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">主机数量</span>
<span className="font-medium">{inventory.hosts.length}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">分组数量</span>
<span className="font-medium">{inventory.groups.length}</span>
</div>
</div>
<div className="mt-4 flex gap-2">
<Button variant="outline" size="sm" className="flex-1">
<Eye className="w-4 h-4 mr-2" />
查看
</Button>
<Button variant="outline" size="sm" className="flex-1">
<Edit className="w-4 h-4 mr-2" />
编辑
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
</Tabs>
{/* 创建Playbook对话框 */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle>新建Playbook</DialogTitle>
<DialogDescription>编写Ansible Playbook实现自动化运维</DialogDescription>
</DialogHeader>
<div className="space-y-4 overflow-y-auto max-h-[60vh]">
<div className="grid grid-cols-2 gap-4">
<div>
<Label>名称</Label>
<Input
value={newPlaybook.name}
onChange={(e) => setNewPlaybook({ ...newPlaybook, name: e.target.value })}
placeholder="Playbook名称"
/>
</div>
<div>
<Label>分类</Label>
<Select
value={newPlaybook.category}
onValueChange={(v) => setNewPlaybook({ ...newPlaybook, category: v })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{categories.map(c => (
<SelectItem key={c.value} value={c.value}>{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label>描述</Label>
<Input
value={newPlaybook.description}
onChange={(e) => setNewPlaybook({ ...newPlaybook, description: e.target.value })}
placeholder="Playbook描述"
/>
</div>
<div>
<Label>标签(逗号分隔)</Label>
<Input
value={newPlaybook.tags}
onChange={(e) => setNewPlaybook({ ...newPlaybook, tags: e.target.value })}
placeholder="如: deployment, docker, production"
/>
</div>
<div>
<Label>Playbook内容 (YAML)</Label>
<Textarea
value={newPlaybook.content}
onChange={(e) => setNewPlaybook({ ...newPlaybook, content: e.target.value })}
className="min-h-[400px] font-mono text-sm"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
<Button onClick={handleCreatePlaybook}>
<CheckCircle className="w-4 h-4 mr-2" />
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 执行Playbook对话框 */}
<Dialog open={executeOpen} onOpenChange={setExecuteOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>执行Playbook</DialogTitle>
<DialogDescription>{selectedPlaybook?.name}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>目标主机清单</Label>
<Select
value={executionConfig.inventoryId}
onValueChange={(v) => setExecutionConfig({ ...executionConfig, inventoryId: v })}
>
<SelectTrigger>
<SelectValue placeholder="选择主机清单" />
</SelectTrigger>
<SelectContent>
{inventories.map(inv => (
<SelectItem key={inv.id} value={inv.id}>{inv.name} ({inv.hosts.length}台主机)</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedPlaybook?.variables.map((variable) => (
<div key={variable.name}>
<Label>{variable.name} {variable.required && <span className="text-red-500">*</span>}</Label>
<Input
placeholder={variable.description}
value={executionConfig.variables[variable.name] || ''}
onChange={(e) => setExecutionConfig({
...executionConfig,
variables: { ...executionConfig.variables, [variable.name]: e.target.value }
})}
/>
<p className="text-xs text-muted-foreground mt-1">{variable.description}</p>
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setExecuteOpen(false)}>取消</Button>
<Button onClick={handleExecute}>
<Play className="w-4 h-4 mr-2" />
开始执行
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 执行日志对话框 */}
<Dialog open={logsOpen} onOpenChange={setLogsOpen}>
<DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader>
<DialogTitle>执行日志</DialogTitle>
<DialogDescription>任务ID: {selectedJob?.id}</DialogDescription>
</DialogHeader>
<ScrollArea className="h-[500px] w-full rounded-md border bg-black">
<div className="p-4 font-mono text-sm text-green-400 whitespace-pre-wrap">
{selectedJob?.output || '暂无日志输出...'}
</div>
</ScrollArea>
<DialogFooter>
<Button variant="outline" onClick={() => setLogsOpen(false)}>关闭</Button>
<Button variant="outline">
<Download className="w-4 h-4 mr-2" />
下载日志
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
export default AnsibleManager;
@@ -0,0 +1,475 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
PlayCircle,
Plus,
Search,
Clock,
Play,
Pause,
RotateCw,
FileCode,
History,
Eye,
Edit,
Copy,
Download,
} from 'lucide-react';
import { cronJobs, scripts } from '@/mocks/data';
import type { CronJob, Script } from '@/types';
export function AutomationManager() {
const [activeTab, setActiveTab] = useState('cronjobs');
const [searchQuery, setSearchQuery] = useState('');
const [selectedScript, setSelectedScript] = useState<Script | null>(null);
const [isExecuteDialogOpen, setIsExecuteDialogOpen] = useState(false);
const filteredJobs = cronJobs.filter(
(job) =>
job.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
job.description.toLowerCase().includes(searchQuery.toLowerCase())
);
const filteredScripts = scripts.filter(
(script) =>
script.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
script.description.toLowerCase().includes(searchQuery.toLowerCase())
);
const getJobStatusBadge = (status: CronJob['status']) => {
return status === 'running' ? (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<Play className="h-3 w-3 mr-1" />
运行中
</Badge>
) : (
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
<Pause className="h-3 w-3 mr-1" />
已暂停
</Badge>
);
};
const getLanguageBadge = (language: Script['language']) => {
const colors = {
bash: 'border-green-500/30 text-green-400 bg-green-500/10',
python: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
powershell: 'border-cyan-500/30 text-cyan-400 bg-cyan-500/10',
};
return (
<Badge variant="outline" className={colors[language]}>
{language.toUpperCase()}
</Badge>
);
};
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">自动化运维</h1>
<p className="text-sm text-slate-500 mt-1">定时任务与脚本管理</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<History className="h-4 w-4 mr-2" />
执行历史
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
新建任务
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">定时任务</p>
<p className="text-2xl font-bold text-slate-100">{cronJobs.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Clock className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">运行中</p>
<p className="text-2xl font-bold text-emerald-400">
{cronJobs.filter((j) => j.status === 'running').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<Play className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">脚本数量</p>
<p className="text-2xl font-bold text-slate-100">{scripts.length}</p>
</div>
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
<FileCode className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">今日执行</p>
<p className="text-2xl font-bold text-blue-400">156</p>
</div>
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
<RotateCw className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-3">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索任务或脚本..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="cronjobs" className="data-[state=active]:bg-slate-700">
<Clock className="h-4 w-4 mr-2" />
定时任务
</TabsTrigger>
<TabsTrigger value="scripts" className="data-[state=active]:bg-slate-700">
<FileCode className="h-4 w-4 mr-2" />
脚本库
</TabsTrigger>
<TabsTrigger value="playbooks" className="data-[state=active]:bg-slate-700">
<PlayCircle className="h-4 w-4 mr-2" />
任务编排
</TabsTrigger>
</TabsList>
<TabsContent value="cronjobs" className="mt-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">任务名称</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">Cron表达式</TableHead>
<TableHead className="text-slate-400">上次执行</TableHead>
<TableHead className="text-slate-400">下次执行</TableHead>
<TableHead className="text-slate-400">执行统计</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredJobs.map((job) => (
<TableRow key={job.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<div>
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-slate-500" />
<span className="text-sm font-medium text-slate-200">{job.name}</span>
</div>
<p className="text-xs text-slate-500 mt-0.5">{job.description}</p>
</div>
</TableCell>
<TableCell>{getJobStatusBadge(job.status)}</TableCell>
<TableCell>
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded">
{job.cron}
</code>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{job.lastRunAt ? new Date(job.lastRunAt).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{job.nextRunAt ? new Date(job.nextRunAt).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<div className="text-sm">
<span className="text-emerald-400">{job.successCount}</span>
<span className="text-slate-500 mx-1">/</span>
<span className="text-red-400">{job.failCount}</span>
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Play className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Edit className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="scripts" className="mt-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredScripts.map((script) => (
<Card key={script.id} className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center">
<FileCode className="h-5 w-5 text-purple-400" />
</div>
<div>
<CardTitle className="text-base text-slate-100">{script.name}</CardTitle>
<p className="text-slate-500 text-sm">{script.description}</p>
</div>
</div>
<div className="flex items-center gap-1">
{getLanguageBadge(script.language)}
</div>
</div>
</CardHeader>
<CardContent>
<div className="bg-slate-950 rounded-lg p-3 font-mono text-xs text-slate-400 overflow-x-auto">
<pre className="whitespace-pre-wrap">{script.content.slice(0, 200)}...</pre>
</div>
<div className="flex items-center justify-between mt-4">
<div className="flex gap-2">
{script.params.map((param) => (
<Badge key={param.name} variant="outline" className="border-slate-700 text-slate-400 text-xs">
{param.name}
{param.required && <span className="text-red-400 ml-0.5">*</span>}
</Badge>
))}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => setSelectedScript(script)}
>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 text-blue-400 hover:text-blue-400"
onClick={() => {
setSelectedScript(script);
setIsExecuteDialogOpen(true);
}}
>
<Play className="h-4 w-4 mr-1" />
执行
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="playbooks" className="mt-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-8">
<div className="text-center">
<PlayCircle className="h-12 w-12 mx-auto text-slate-500 mb-4" />
<h3 className="text-lg font-medium text-slate-300 mb-2">任务编排</h3>
<p className="text-sm text-slate-500 mb-4">可视化编排复杂运维任务流程</p>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
创建编排
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Script Detail Dialog */}
<Dialog open={!!selectedScript && !isExecuteDialogOpen} onOpenChange={() => setSelectedScript(null)}>
<DialogContent className="max-w-3xl bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<FileCode className="h-5 w-5 text-purple-400" />
{selectedScript?.name}
</DialogTitle>
<DialogDescription className="text-slate-500">
{selectedScript?.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="flex items-center gap-2">
{selectedScript && getLanguageBadge(selectedScript.language)}
<span className="text-sm text-slate-500">
更新于 {selectedScript && new Date(selectedScript.updatedAt).toLocaleDateString()}
</span>
</div>
<div className="bg-slate-950 rounded-lg p-4 font-mono text-sm text-slate-300 overflow-x-auto max-h-80">
<pre className="whitespace-pre">{selectedScript?.content}</pre>
</div>
{selectedScript && selectedScript.params.length > 0 && (
<div>
<h4 className="text-sm font-medium text-slate-300 mb-2">参数</h4>
<div className="space-y-2">
{selectedScript.params.map((param) => (
<div key={param.name} className="flex items-center gap-2 p-2 rounded-lg bg-slate-800/50">
<code className="text-sm text-slate-300">{param.name}</code>
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
{param.type}
</Badge>
{param.required && (
<Badge variant="outline" className="border-red-700 text-red-400 text-xs">
必填
</Badge>
)}
<span className="text-sm text-slate-500">{param.description}</span>
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Copy className="h-4 w-4 mr-2" />
复制
</Button>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Download className="h-4 w-4 mr-2" />
下载
</Button>
<Button
className="bg-blue-600 hover:bg-blue-700 text-white"
onClick={() => setIsExecuteDialogOpen(true)}
>
<Play className="h-4 w-4 mr-2" />
执行
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Execute Script Dialog */}
<Dialog open={isExecuteDialogOpen} onOpenChange={setIsExecuteDialogOpen}>
<DialogContent className="bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100">执行脚本</DialogTitle>
<DialogDescription className="text-slate-500">
{selectedScript?.name}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium text-slate-300">目标服务器</label>
<Select>
<SelectTrigger className="mt-1 bg-slate-800 border-slate-700 text-slate-200">
<SelectValue placeholder="选择服务器" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all">全部服务器</SelectItem>
<SelectItem value="web">Web服务器组</SelectItem>
<SelectItem value="app">应用服务器组</SelectItem>
<SelectItem value="db">数据库服务器组</SelectItem>
</SelectContent>
</Select>
</div>
{selectedScript?.params.map((param) => (
<div key={param.name}>
<label className="text-sm font-medium text-slate-300">
{param.name}
{param.required && <span className="text-red-400">*</span>}
</label>
<Input
placeholder={param.description}
defaultValue={param.default}
className="mt-1 bg-slate-800 border-slate-700 text-slate-200"
/>
</div>
))}
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setIsExecuteDialogOpen(false)}>
取消
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Play className="h-4 w-4 mr-2" />
开始执行
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,474 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Play,
Plus,
Search,
Clock,
Pause,
RotateCw,
Edit,
Trash2,
History,
CheckCircle,
XCircle,
Loader2,
Server,
Calendar,
Terminal,
MoreHorizontal,
Eye,
Copy,
} from 'lucide-react';
import { cronJobs } from '@/mocks/data';
interface CronJob {
id: string;
name: string;
description: string;
schedule: string;
command: string;
status: 'enabled' | 'disabled';
lastRun: string;
lastStatus: 'success' | 'failed' | 'running' | 'never';
nextRun: string;
targetServers: string[];
createdAt: string;
createdBy: string;
}
const mockCronJobs: CronJob[] = [
{
id: 'cron-001',
name: '数据库备份',
description: '每日凌晨2点备份数据库',
schedule: '0 2 * * *',
command: '/scripts/backup-db.sh --type=full',
status: 'enabled',
lastRun: '2025-04-14 02:00:00',
lastStatus: 'success',
nextRun: '2025-04-15 02:00:00',
targetServers: ['db-server-01', 'db-server-02'],
createdAt: '2025-01-15',
createdBy: 'admin',
},
{
id: 'cron-002',
name: '日志清理',
description: '每周日凌晨3点清理过期日志',
schedule: '0 3 * * 0',
command: '/scripts/clean-logs.sh --days=30',
status: 'enabled',
lastRun: '2025-04-13 03:00:00',
lastStatus: 'success',
nextRun: '2025-04-20 03:00:00',
targetServers: ['all'],
createdAt: '2025-01-20',
createdBy: 'admin',
},
{
id: 'cron-003',
name: '健康检查',
description: '每5分钟执行健康检查',
schedule: '*/5 * * * *',
command: '/scripts/health-check.sh',
status: 'enabled',
lastRun: '2025-04-14 11:55:00',
lastStatus: 'running',
nextRun: '2025-04-14 12:00:00',
targetServers: ['web-server-01', 'web-server-02', 'app-server-01'],
createdAt: '2025-02-01',
createdBy: 'ops01',
},
{
id: 'cron-004',
name: '数据同步',
description: '每小时同步数据到备份服务器',
schedule: '0 * * * *',
command: '/scripts/sync-data.sh --target=backup',
status: 'disabled',
lastRun: '2025-04-14 10:00:00',
lastStatus: 'failed',
nextRun: '-',
targetServers: ['app-server-01'],
createdAt: '2025-02-15',
createdBy: 'ops02',
},
{
id: 'cron-005',
name: '生成报表',
description: '每周一上午9点生成业务报表',
schedule: '0 9 * * 1',
command: '/scripts/generate-report.sh --type=weekly',
status: 'enabled',
lastRun: '2025-04-07 09:00:00',
lastStatus: 'success',
nextRun: '2025-04-21 09:00:00',
targetServers: ['report-server'],
createdAt: '2025-03-01',
createdBy: 'admin',
},
];
const executionLogs = [
{ id: 1, jobName: '数据库备份', startTime: '2025-04-14 02:00:00', endTime: '2025-04-14 02:15:30', duration: '15m 30s', status: 'success', output: '备份完成: 15.2GB' },
{ id: 2, jobName: '健康检查', startTime: '2025-04-14 11:55:00', endTime: '2025-04-14 11:55:05', duration: '5s', status: 'success', output: '所有服务正常' },
{ id: 3, jobName: '数据同步', startTime: '2025-04-14 10:00:00', endTime: '2025-04-14 10:05:23', duration: '5m 23s', status: 'failed', output: '连接超时' },
{ id: 4, jobName: '日志清理', startTime: '2025-04-13 03:00:00', endTime: '2025-04-13 03:02:15', duration: '2m 15s', status: 'success', output: '清理完成: 2.3GB' },
];
export function CronJobManager() {
const [searchQuery, setSearchQuery] = useState('');
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isLogDialogOpen, setIsLogDialogOpen] = useState(false);
const [selectedJob, setSelectedJob] = useState<CronJob | null>(null);
const [newJob, setNewJob] = useState({
name: '',
description: '',
schedule: '',
command: '',
targetServers: '',
});
const filteredJobs = mockCronJobs.filter(
(job) =>
job.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
job.description.toLowerCase().includes(searchQuery.toLowerCase())
);
const getStatusBadge = (status: CronJob['lastStatus']) => {
switch (status) {
case 'success':
return (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<CheckCircle className="h-3 w-3 mr-1" />
成功
</Badge>
);
case 'failed':
return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
<XCircle className="h-3 w-3 mr-1" />
失败
</Badge>
);
case 'running':
return (
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10 animate-pulse">
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
执行中
</Badge>
);
default:
return (
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
从未执行
</Badge>
);
}
};
const parseCron = (schedule: string) => {
const patterns: Record<string, string> = {
'0 2 * * *': '每天 02:00',
'0 3 * * 0': '每周日 03:00',
'*/5 * * * *': '每 5 分钟',
'0 * * * *': '每小时',
'0 9 * * 1': '每周一 09:00',
};
return patterns[schedule] || schedule;
};
const handleCreateJob = () => {
setIsCreateDialogOpen(false);
setNewJob({ name: '', description: '', schedule: '', command: '', targetServers: '' });
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">定时任务</h1>
<p className="text-sm text-slate-500 mt-1">Cron 任务管理与执行</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setIsLogDialogOpen(true)}>
<History className="h-4 w-4 mr-2" />
执行历史
</Button>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
新建任务
</Button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<p className="text-slate-400 text-sm">任务总数</p>
<p className="text-2xl font-bold text-slate-100">{mockCronJobs.length}</p>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<p className="text-slate-400 text-sm">已启用</p>
<p className="text-2xl font-bold text-emerald-400">
{mockCronJobs.filter((j) => j.status === 'enabled').length}
</p>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<p className="text-slate-400 text-sm">执行中</p>
<p className="text-2xl font-bold text-blue-400">
{mockCronJobs.filter((j) => j.lastStatus === 'running').length}
</p>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<p className="text-slate-400 text-sm">今日失败</p>
<p className="text-2xl font-bold text-red-400">
{mockCronJobs.filter((j) => j.lastStatus === 'failed').length}
</p>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<Input
placeholder="搜索任务..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-slate-800 border-slate-700 text-slate-100"
/>
</div>
</div>
{/* Job List */}
<div className="space-y-3">
{filteredJobs.map((job) => (
<Card key={job.id} className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="font-medium text-slate-100">{job.name}</h3>
<Badge
variant="outline"
className={
job.status === 'enabled'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-slate-500/30 text-slate-400 bg-slate-500/10'
}
>
{job.status === 'enabled' ? '已启用' : '已禁用'}
</Badge>
{getStatusBadge(job.lastStatus)}
</div>
<p className="text-sm text-slate-400 mt-1">{job.description}</p>
<div className="flex items-center gap-4 mt-2 text-sm">
<span className="flex items-center gap-1 text-slate-400">
<Clock className="h-3 w-3" />
{parseCron(job.schedule)}
</span>
<span className="flex items-center gap-1 text-slate-400">
<Terminal className="h-3 w-3" />
<code className="text-xs bg-slate-900 px-2 py-0.5 rounded">{job.command}</code>
</span>
</div>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span>上次执行: {job.lastRun}</span>
<span>下次执行: {job.nextRun}</span>
<span>目标: {job.targetServers.join(', ')}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="border-emerald-600 text-emerald-400 hover:bg-emerald-600/20">
<Play className="h-4 w-4 mr-1" />
立即执行
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400"
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* Create Job Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-700 text-slate-100">
<DialogHeader>
<DialogTitle>新建定时任务</DialogTitle>
<DialogDescription className="text-slate-500">配置 Cron 定时任务</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label className="text-slate-300">任务名称</Label>
<Input
value={newJob.name}
onChange={(e) => setNewJob({ ...newJob, name: e.target.value })}
placeholder="例如: 数据库备份"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
<div>
<Label className="text-slate-300">描述</Label>
<Textarea
value={newJob.description}
onChange={(e) => setNewJob({ ...newJob, description: e.target.value })}
placeholder="任务描述..."
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
<div>
<Label className="text-slate-300">Cron 表达式</Label>
<div className="flex gap-2">
<Input
value={newJob.schedule}
onChange={(e) => setNewJob({ ...newJob, schedule: e.target.value })}
placeholder="0 2 * * *"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
<Select onValueChange={(v) => setNewJob({ ...newJob, schedule: v })}>
<SelectTrigger className="w-48 bg-slate-800 border-slate-700 text-slate-100 mt-1">
<SelectValue placeholder="快速选择" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="*/5 * * * *" className="text-slate-100">每 5 分钟</SelectItem>
<SelectItem value="0 * * * *" className="text-slate-100">每小时</SelectItem>
<SelectItem value="0 2 * * *" className="text-slate-100">每天 02:00</SelectItem>
<SelectItem value="0 3 * * 0" className="text-slate-100">每周日 03:00</SelectItem>
<SelectItem value="0 9 * * 1" className="text-slate-100">每周一 09:00</SelectItem>
</SelectContent>
</Select>
</div>
<p className="text-xs text-slate-500 mt-1">格式: 分 时 日 月 周</p>
</div>
<div>
<Label className="text-slate-300">执行命令</Label>
<Textarea
value={newJob.command}
onChange={(e) => setNewJob({ ...newJob, command: e.target.value })}
placeholder="/scripts/backup.sh"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1 font-mono"
/>
</div>
<div>
<Label className="text-slate-300">目标服务器</Label>
<Input
value={newJob.targetServers}
onChange={(e) => setNewJob({ ...newJob, targetServers: e.target.value })}
placeholder="server1,server2 或 all"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)} className="border-slate-700 text-slate-300">
取消
</Button>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={handleCreateJob}>
创建任务
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Execution Log Dialog */}
<Dialog open={isLogDialogOpen} onOpenChange={setIsLogDialogOpen}>
<DialogContent className="max-w-4xl bg-slate-900 border-slate-700 text-slate-100">
<DialogHeader>
<DialogTitle>执行历史</DialogTitle>
<DialogDescription className="text-slate-500">定时任务执行记录</DialogDescription>
</DialogHeader>
<div className="mt-4">
<Table>
<TableHeader>
<TableRow className="border-slate-700">
<TableHead className="text-slate-400">任务</TableHead>
<TableHead className="text-slate-400">开始时间</TableHead>
<TableHead className="text-slate-400">耗时</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">输出</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{executionLogs.map((log) => (
<TableRow key={log.id} className="border-slate-700">
<TableCell className="text-slate-200">{log.jobName}</TableCell>
<TableCell className="text-slate-400">{log.startTime}</TableCell>
<TableCell className="text-slate-400">{log.duration}</TableCell>
<TableCell>
<Badge
variant="outline"
className={
log.status === 'success'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-red-500/30 text-red-400 bg-red-500/10'
}
>
{log.status === 'success' ? '成功' : '失败'}
</Badge>
</TableCell>
<TableCell className="text-slate-400 text-sm">{log.output}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,231 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Plus,
Search,
Edit2,
Trash2,
Key,
Cloud,
CheckCircle2,
XCircle,
RefreshCw
} from 'lucide-react';
import { cloudAccounts } from '@/mocks/data';
interface CloudAccount {
id: string;
name: string;
provider: string;
accessKey: string;
status: 'active' | 'inactive' | 'error';
regionCount: number;
serverCount: number;
createdAt: string;
lastSync: string;
}
const providerIcons: Record<string, string> = {
aws: 'AWS',
aliyun: '阿里云',
tencent: '腾讯云',
huawei: '华为云',
azure: 'Azure',
};
const providerColors: Record<string, string> = {
aws: 'bg-orange-500',
aliyun: 'bg-orange-600',
tencent: 'bg-blue-500',
huawei: 'bg-red-500',
azure: 'bg-blue-600',
};
export function CloudAccountManager() {
const [searchQuery, setSearchQuery] = useState('');
const [selectedProvider, setSelectedProvider] = useState<string>('all');
const filteredAccounts = cloudAccounts.filter(account => {
const matchesSearch = account.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
account.accessKey.toLowerCase().includes(searchQuery.toLowerCase());
const matchesProvider = selectedProvider === 'all' || account.provider === selectedProvider;
return matchesSearch && matchesProvider;
});
const stats = {
total: cloudAccounts.length,
active: cloudAccounts.filter(a => a.status === 'active').length,
inactive: cloudAccounts.filter(a => a.status === 'inactive').length,
error: cloudAccounts.filter(a => a.status === 'error').length,
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">云账号管理</h1>
<p className="text-slate-400 mt-1">管理多云平台的访问凭证和账号信息</p>
</div>
<Button className="bg-blue-600 hover:bg-blue-700">
<Plus className="h-4 w-4 mr-2" />
添加账号
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">账号总数</p>
<p className="text-2xl font-bold text-slate-100 mt-1">{stats.total}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Cloud className="h-5 w-5 text-blue-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">正常</p>
<p className="text-2xl font-bold text-green-400 mt-1">{stats.active}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
<CheckCircle2 className="h-5 w-5 text-green-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">已停用</p>
<p className="text-2xl font-bold text-slate-400 mt-1">{stats.inactive}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-slate-500/20 flex items-center justify-center">
<XCircle className="h-5 w-5 text-slate-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">异常</p>
<p className="text-2xl font-bold text-red-400 mt-1">{stats.error}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
<Key className="h-5 w-5 text-red-400" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<Input
placeholder="搜索账号名称或AccessKey..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-slate-800 border-slate-700 text-slate-100"
/>
</div>
<select
value={selectedProvider}
onChange={(e) => setSelectedProvider(e.target.value)}
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded-md text-slate-100 text-sm"
>
<option value="all">全部平台</option>
<option value="aws">AWS</option>
<option value="aliyun">阿里云</option>
<option value="tencent">腾讯云</option>
<option value="huawei">华为云</option>
<option value="azure">Azure</option>
</select>
</div>
{/* Account List */}
<div className="grid grid-cols-2 gap-4">
{filteredAccounts.map((account) => (
<Card key={account.id} className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg ${providerColors[account.provider]} flex items-center justify-center text-white font-bold text-xs`}>
{providerIcons[account.provider]?.slice(0, 2) || account.provider.slice(0, 2).toUpperCase()}
</div>
<div>
<h3 className="font-medium text-slate-100">{account.name}</h3>
<p className="text-sm text-slate-400 mt-0.5">{providerIcons[account.provider]}</p>
<div className="flex items-center gap-3 mt-2">
<Badge variant="secondary" className="bg-slate-700 text-slate-300">
<Key className="h-3 w-3 mr-1" />
{account.accessKey.slice(0, 8)}...
</Badge>
{account.status === 'active' && (
<Badge className="bg-green-500/20 text-green-400 border-0">
<CheckCircle2 className="h-3 w-3 mr-1" />
正常
</Badge>
)}
{account.status === 'inactive' && (
<Badge className="bg-slate-500/20 text-slate-400 border-0">
<XCircle className="h-3 w-3 mr-1" />
已停用
</Badge>
)}
{account.status === 'error' && (
<Badge className="bg-red-500/20 text-red-400 border-0">
<Key className="h-3 w-3 mr-1" />
异常
</Badge>
)}
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<RefreshCw className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-slate-700">
<div>
<p className="text-xs text-slate-400">区域数</p>
<p className="text-lg font-medium text-slate-100">{account.regionCount}</p>
</div>
<div>
<p className="text-xs text-slate-400">服务器数</p>
<p className="text-lg font-medium text-slate-100">{account.serverCount}</p>
</div>
<div>
<p className="text-xs text-slate-400">最后同步</p>
<p className="text-sm text-slate-300">{account.lastSync}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
+235
View File
@@ -0,0 +1,235 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Plus,
Search,
Edit2,
Trash2,
Globe,
CheckCircle2,
XCircle,
Clock,
ExternalLink
} from 'lucide-react';
import { domains } from '@/mocks/data';
interface Domain {
id: string;
name: string;
registrar: string;
status: 'active' | 'expired' | 'pending';
expireDate: string;
dnsProvider: string;
recordCount: number;
sslEnabled: boolean;
sslExpireDate?: string;
}
export function DomainManager() {
const [searchQuery, setSearchQuery] = useState('');
const [selectedStatus, setSelectedStatus] = useState<string>('all');
const filteredDomains = domains.filter(domain => {
const matchesSearch = domain.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
domain.registrar.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = selectedStatus === 'all' || domain.status === selectedStatus;
return matchesSearch && matchesStatus;
});
const stats = {
total: domains.length,
active: domains.filter(d => d.status === 'active').length,
expired: domains.filter(d => d.status === 'expired').length,
pending: domains.filter(d => d.status === 'pending').length,
sslEnabled: domains.filter(d => d.sslEnabled).length,
};
const getDaysUntilExpiry = (expireDate: string) => {
const days = Math.ceil((new Date(expireDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
return days;
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">域名管理</h1>
<p className="text-slate-400 mt-1">管理域名注册信息和DNS解析记录</p>
</div>
<Button className="bg-blue-600 hover:bg-blue-700">
<Plus className="h-4 w-4 mr-2" />
添加域名
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-5 gap-4">
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">域名总数</p>
<p className="text-2xl font-bold text-slate-100 mt-1">{stats.total}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Globe className="h-5 w-5 text-blue-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">正常</p>
<p className="text-2xl font-bold text-green-400 mt-1">{stats.active}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
<CheckCircle2 className="h-5 w-5 text-green-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">已过期</p>
<p className="text-2xl font-bold text-red-400 mt-1">{stats.expired}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
<XCircle className="h-5 w-5 text-red-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">待审核</p>
<p className="text-2xl font-bold text-yellow-400 mt-1">{stats.pending}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
<Clock className="h-5 w-5 text-yellow-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">SSL证书</p>
<p className="text-2xl font-bold text-cyan-400 mt-1">{stats.sslEnabled}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-cyan-500/20 flex items-center justify-center">
<CheckCircle2 className="h-5 w-5 text-cyan-400" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<Input
placeholder="搜索域名..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-slate-800 border-slate-700 text-slate-100"
/>
</div>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded-md text-slate-100 text-sm"
>
<option value="all">全部状态</option>
<option value="active">正常</option>
<option value="expired">已过期</option>
<option value="pending">待审核</option>
</select>
</div>
{/* Domain List */}
<div className="space-y-3">
{filteredDomains.map((domain) => {
const daysUntilExpiry = getDaysUntilExpiry(domain.expireDate);
return (
<Card key={domain.id} className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center">
<Globe className="h-5 w-5 text-white" />
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium text-slate-100 text-lg">{domain.name}</h3>
{domain.status === 'active' && (
<Badge className="bg-green-500/20 text-green-400 border-0">
<CheckCircle2 className="h-3 w-3 mr-1" />
正常
</Badge>
)}
{domain.status === 'expired' && (
<Badge className="bg-red-500/20 text-red-400 border-0">
<XCircle className="h-3 w-3 mr-1" />
已过期
</Badge>
)}
{domain.status === 'pending' && (
<Badge className="bg-yellow-500/20 text-yellow-400 border-0">
<Clock className="h-3 w-3 mr-1" />
待审核
</Badge>
)}
{domain.sslEnabled && (
<Badge className="bg-cyan-500/20 text-cyan-400 border-0">
<CheckCircle2 className="h-3 w-3 mr-1" />
SSL
</Badge>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-sm text-slate-400">
<span>注册商: {domain.registrar}</span>
<span>DNS: {domain.dnsProvider}</span>
<span>解析记录: {domain.recordCount}条</span>
</div>
</div>
</div>
<div className="flex items-center gap-4">
<div className="text-right">
<p className="text-sm text-slate-400">到期时间</p>
<p className={`text-sm font-medium ${daysUntilExpiry < 30 ? 'text-red-400' : 'text-slate-200'}`}>
{domain.expireDate} ({daysUntilExpiry}天)
</p>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<ExternalLink className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
);
})}
</div>
</div>
);
}
@@ -0,0 +1,219 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Plus,
Search,
Edit2,
Trash2,
CloudCog,
Server,
CheckCircle2,
XCircle,
ExternalLink
} from 'lucide-react';
import { platforms } from '@/mocks/data';
interface Platform {
id: string;
name: string;
code: string;
type: 'public' | 'private' | 'hybrid';
status: 'active' | 'inactive';
accountCount: number;
regionCount: number;
serverCount: number;
website: string;
description: string;
}
const typeLabels: Record<string, string> = {
public: '公有云',
private: '私有云',
hybrid: '混合云',
};
const typeColors: Record<string, string> = {
public: 'bg-blue-500/20 text-blue-400',
private: 'bg-purple-500/20 text-purple-400',
hybrid: 'bg-cyan-500/20 text-cyan-400',
};
export function PlatformManager() {
const [searchQuery, setSearchQuery] = useState('');
const [selectedType, setSelectedType] = useState<string>('all');
const filteredPlatforms = platforms.filter(platform => {
const matchesSearch = platform.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
platform.code.toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = selectedType === 'all' || platform.type === selectedType;
return matchesSearch && matchesType;
});
const stats = {
total: platforms.length,
public: platforms.filter(p => p.type === 'public').length,
private: platforms.filter(p => p.type === 'private').length,
totalServers: platforms.reduce((sum, p) => sum + p.serverCount, 0),
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">云平台管理</h1>
<p className="text-slate-400 mt-1">管理多云平台和云服务提供商</p>
</div>
<Button className="bg-blue-600 hover:bg-blue-700">
<Plus className="h-4 w-4 mr-2" />
添加平台
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">平台总数</p>
<p className="text-2xl font-bold text-slate-100 mt-1">{stats.total}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<CloudCog className="h-5 w-5 text-blue-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">公有云</p>
<p className="text-2xl font-bold text-blue-400 mt-1">{stats.public}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<CloudCog className="h-5 w-5 text-blue-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">私有云</p>
<p className="text-2xl font-bold text-purple-400 mt-1">{stats.private}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<CloudCog className="h-5 w-5 text-purple-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">服务器总数</p>
<p className="text-2xl font-bold text-green-400 mt-1">{stats.totalServers}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
<Server className="h-5 w-5 text-green-400" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<Input
placeholder="搜索平台名称或代码..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-slate-800 border-slate-700 text-slate-100"
/>
</div>
<select
value={selectedType}
onChange={(e) => setSelectedType(e.target.value)}
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded-md text-slate-100 text-sm"
>
<option value="all">全部类型</option>
<option value="public">公有云</option>
<option value="private">私有云</option>
<option value="hybrid">混合云</option>
</select>
</div>
{/* Platform List */}
<div className="grid grid-cols-2 gap-4">
{filteredPlatforms.map((platform) => (
<Card key={platform.id} className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="w-12 h-12 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center text-white font-bold text-lg">
{platform.name.slice(0, 2)}
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium text-slate-100">{platform.name}</h3>
<Badge className={typeColors[platform.type]}>
{typeLabels[platform.type]}
</Badge>
{platform.status === 'active' ? (
<Badge className="bg-green-500/20 text-green-400 border-0">
<CheckCircle2 className="h-3 w-3 mr-1" />
启用
</Badge>
) : (
<Badge className="bg-slate-500/20 text-slate-400 border-0">
<XCircle className="h-3 w-3 mr-1" />
停用
</Badge>
)}
</div>
<p className="text-sm text-slate-400 mt-0.5">{platform.code}</p>
<p className="text-xs text-slate-500 mt-1">{platform.description}</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<ExternalLink className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-slate-700">
<div>
<p className="text-xs text-slate-400">账号数</p>
<p className="text-lg font-medium text-slate-100">{platform.accountCount}</p>
</div>
<div>
<p className="text-xs text-slate-400">区域数</p>
<p className="text-lg font-medium text-slate-100">{platform.regionCount}</p>
</div>
<div>
<p className="text-xs text-slate-400">服务器数</p>
<p className="text-lg font-medium text-slate-100">{platform.serverCount}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
+222
View File
@@ -0,0 +1,222 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Plus,
Search,
Edit2,
Trash2,
MapPin,
Server,
Cloud,
CheckCircle2,
XCircle
} from 'lucide-react';
import { regions } from '@/mocks/data';
interface Region {
id: string;
name: string;
code: string;
provider: string;
location: string;
status: 'active' | 'inactive';
serverCount: number;
vpcCount: number;
endpoint: string;
}
const providerColors: Record<string, string> = {
aws: 'bg-orange-500',
aliyun: 'bg-orange-600',
tencent: 'bg-blue-500',
huawei: 'bg-red-500',
azure: 'bg-blue-600',
};
const providerNames: Record<string, string> = {
aws: 'AWS',
aliyun: '阿里云',
tencent: '腾讯云',
huawei: '华为云',
azure: 'Azure',
};
export function RegionManager() {
const [searchQuery, setSearchQuery] = useState('');
const [selectedProvider, setSelectedProvider] = useState<string>('all');
const filteredRegions = regions.filter(region => {
const matchesSearch = region.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
region.code.toLowerCase().includes(searchQuery.toLowerCase()) ||
region.location.toLowerCase().includes(searchQuery.toLowerCase());
const matchesProvider = selectedProvider === 'all' || region.provider === selectedProvider;
return matchesSearch && matchesProvider;
});
const stats = {
total: regions.length,
active: regions.filter(r => r.status === 'active').length,
inactive: regions.filter(r => r.status === 'inactive').length,
totalServers: regions.reduce((sum, r) => sum + r.serverCount, 0),
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">区域管理</h1>
<p className="text-slate-400 mt-1">管理云平台可用区和区域信息</p>
</div>
<Button className="bg-blue-600 hover:bg-blue-700">
<Plus className="h-4 w-4 mr-2" />
添加区域
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">区域总数</p>
<p className="text-2xl font-bold text-slate-100 mt-1">{stats.total}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<MapPin className="h-5 w-5 text-blue-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">可用</p>
<p className="text-2xl font-bold text-green-400 mt-1">{stats.active}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-green-500/20 flex items-center justify-center">
<CheckCircle2 className="h-5 w-5 text-green-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">不可用</p>
<p className="text-2xl font-bold text-slate-400 mt-1">{stats.inactive}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-slate-500/20 flex items-center justify-center">
<XCircle className="h-5 w-5 text-slate-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">服务器总数</p>
<p className="text-2xl font-bold text-cyan-400 mt-1">{stats.totalServers}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-cyan-500/20 flex items-center justify-center">
<Server className="h-5 w-5 text-cyan-400" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<Input
placeholder="搜索区域名称、代码或位置..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-slate-800 border-slate-700 text-slate-100"
/>
</div>
<select
value={selectedProvider}
onChange={(e) => setSelectedProvider(e.target.value)}
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded-md text-slate-100 text-sm"
>
<option value="all">全部平台</option>
<option value="aws">AWS</option>
<option value="aliyun">阿里云</option>
<option value="tencent">腾讯云</option>
<option value="huawei">华为云</option>
<option value="azure">Azure</option>
</select>
</div>
{/* Region List */}
<div className="grid grid-cols-2 gap-4">
{filteredRegions.map((region) => (
<Card key={region.id} className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg ${providerColors[region.provider] || 'bg-blue-500'} flex items-center justify-center text-white font-bold text-xs`}>
{providerNames[region.provider]?.slice(0, 2) || region.provider.slice(0, 2).toUpperCase()}
</div>
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium text-slate-100">{region.name}</h3>
{region.status === 'active' ? (
<Badge className="bg-green-500/20 text-green-400 border-0">
<CheckCircle2 className="h-3 w-3 mr-1" />
可用
</Badge>
) : (
<Badge className="bg-slate-500/20 text-slate-400 border-0">
<XCircle className="h-3 w-3 mr-1" />
不可用
</Badge>
)}
</div>
<p className="text-sm text-slate-400 mt-0.5">{region.code}</p>
<div className="flex items-center gap-2 mt-1">
<MapPin className="h-3 w-3 text-slate-500" />
<span className="text-xs text-slate-400">{region.location}</span>
</div>
</div>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-slate-700">
<div>
<p className="text-xs text-slate-400">服务器</p>
<p className="text-lg font-medium text-slate-100">{region.serverCount}</p>
</div>
<div>
<p className="text-xs text-slate-400">VPC</p>
<p className="text-lg font-medium text-slate-100">{region.vpcCount}</p>
</div>
<div>
<p className="text-xs text-slate-400">Endpoint</p>
<p className="text-xs text-slate-300 truncate">{region.endpoint}</p>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}
+881
View File
@@ -0,0 +1,881 @@
import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import {
Server,
Cloud,
Bell,
Rocket,
TrendingUp,
TrendingDown,
AlertTriangle,
CheckCircle,
Clock,
Activity,
Cpu,
HardDrive,
ArrowRight,
DollarSign,
Shield,
MemoryStick,
Database,
} from 'lucide-react';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
BarChart,
Bar,
} from 'recharts';
import { dashboardData, servers, k8sClusters, alertEvents, appReleases, domains, generateMetricsData } from '@/mocks/data';
import type { DashboardData } from '@/types';
// 统计卡片组件
function StatCard({
title,
value,
description,
icon: Icon,
trend,
trendValue,
color,
onClick,
}: {
title: string;
value: string | number;
description?: string;
icon: React.ElementType;
trend?: 'up' | 'down' | 'neutral';
trendValue?: string;
color: string;
onClick?: () => void;
}) {
const colorClasses: Record<string, { bg: string; text: string }> = {
blue: { bg: 'bg-blue-500/10', text: 'text-blue-400' },
emerald: { bg: 'bg-emerald-500/10', text: 'text-emerald-400' },
amber: { bg: 'bg-amber-500/10', text: 'text-amber-400' },
rose: { bg: 'bg-rose-500/10', text: 'text-rose-400' },
purple: { bg: 'bg-purple-500/10', text: 'text-purple-400' },
cyan: { bg: 'bg-cyan-500/10', text: 'text-cyan-400' },
orange: { bg: 'bg-orange-500/10', text: 'text-orange-400' },
};
const colors = colorClasses[color];
return (
<Card
className="bg-slate-900 border-slate-800 hover:border-slate-700 transition-all cursor-pointer"
onClick={onClick}
>
<CardContent className="p-6">
<div className="flex items-start justify-between">
<div className="space-y-2">
<p className="text-sm font-medium text-slate-400">{title}</p>
<div className="flex items-baseline gap-2">
<h3 className="text-2xl font-bold text-slate-100">{value}</h3>
{trend && trendValue && (
<span
className={`flex items-center text-xs font-medium ${
trend === 'up' ? 'text-emerald-400' : trend === 'down' ? 'text-red-400' : 'text-slate-400'
}`}
>
{trend === 'up' ? (
<TrendingUp className="h-3 w-3 mr-0.5" />
) : trend === 'down' ? (
<TrendingDown className="h-3 w-3 mr-0.5" />
) : null}
{trendValue}
</span>
)}
</div>
{description && <p className="text-xs text-slate-500">{description}</p>}
</div>
<div className={`p-3 rounded-lg ${colors.bg} ${colors.text}`}>
<Icon className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
);
}
// 成本费用卡片
function CostOverviewCard() {
const costData = [
{ name: '阿里云', value: 28500, color: '#ff6a00' },
{ name: 'AWS', value: 42000, color: '#ff9900' },
{ name: '腾讯云', value: 15800, color: '#006eff' },
{ name: '华为云', value: 8900, color: '#ff0000' },
{ name: 'Azure', value: 12500, color: '#0078d4' },
];
const monthlyData = [
{ month: '1月', cost: 85000 },
{ month: '2月', cost: 92000 },
{ month: '3月', cost: 88000 },
{ month: '4月', cost: 95000 },
{ month: '5月', cost: 107700 },
];
const totalCost = costData.reduce((sum, item) => sum + item.value, 0);
return (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-slate-100">云成本概览</CardTitle>
<CardDescription className="text-slate-500">本月各云平台费用支出</CardDescription>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-slate-100">¥{totalCost.toLocaleString()}</p>
<p className="text-xs text-emerald-400">+12.5% 较上月</p>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-sm text-slate-400 mb-2">费用分布</p>
<div className="h-[180px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={costData}
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={70}
paddingAngle={2}
dataKey="value"
>
{costData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Pie>
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '6px',
color: '#f8fafc',
}}
formatter={(value: number) => `¥${value.toLocaleString()}`}
/>
</PieChart>
</ResponsiveContainer>
</div>
</div>
<div>
<p className="text-sm text-slate-400 mb-2">月度趋势</p>
<div className="h-[180px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={monthlyData} margin={{ top: 5, right: 5, left: 0, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="month" stroke="#475569" fontSize={10} tickLine={false} axisLine={false} />
<YAxis stroke="#475569" fontSize={10} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '6px',
color: '#f8fafc',
}}
formatter={(value: number) => `¥${value.toLocaleString()}`}
/>
<Bar dataKey="cost" fill="#3b82f6" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
<div className="mt-4 space-y-2">
{costData.map((item) => (
<div key={item.name} className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: item.color }} />
<span className="text-sm text-slate-300">{item.name}</span>
</div>
<span className="text-sm font-medium text-slate-100">¥{item.value.toLocaleString()}</span>
</div>
))}
</div>
</CardContent>
</Card>
);
}
// 告警消息卡片
function AlertMessagesCard() {
const recentAlerts = alertEvents.filter(a => a.status === 'firing').slice(0, 8);
return (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-slate-100">告警消息</CardTitle>
<CardDescription className="text-slate-500">最近触发的告警事件</CardDescription>
</div>
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-slate-100">
查看全部
<ArrowRight className="h-4 w-4 ml-1" />
</Button>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[320px]">
<div className="space-y-2">
{recentAlerts.map((alert) => (
<div
key={alert.id}
className="flex items-start gap-3 p-3 rounded-lg bg-slate-800/50 hover:bg-slate-800 transition-colors"
>
<div className={`w-2 h-2 rounded-full mt-1.5 ${
alert.severity === 'critical' ? 'bg-red-400' :
alert.severity === 'warning' ? 'bg-amber-400' : 'bg-blue-400'
}`} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-200 truncate">{alert.ruleName}</span>
<Badge
variant="outline"
className={`text-xs ${
alert.severity === 'critical'
? 'border-red-500/30 text-red-400 bg-red-500/10'
: alert.severity === 'warning'
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
: 'border-blue-500/30 text-blue-400 bg-blue-500/10'
}`}
>
{alert.severity === 'critical' ? '严重' : alert.severity === 'warning' ? '警告' : '提示'}
</Badge>
</div>
<p className="text-xs text-slate-500 mt-1">{alert.target}</p>
<p className="text-xs text-slate-500">{alert.message}</p>
</div>
<span className="text-xs text-slate-500 whitespace-nowrap">{alert.startedAt}</span>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}
// Top5 资源使用卡片
function TopResourceUsageCard() {
const topCpuServers = [...servers]
.sort((a, b) => b.cpuUsage - a.cpuUsage)
.slice(0, 5);
const topMemoryServers = [...servers]
.sort((a, b) => b.memoryUsage - a.memoryUsage)
.slice(0, 5);
const topDiskServers = [...servers]
.sort((a, b) => b.diskUsage - a.diskUsage)
.slice(0, 5);
return (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-slate-100">Top5 资源使用</CardTitle>
<CardDescription className="text-slate-500">CPU、内存、磁盘使用率最高</CardDescription>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4">
{/* CPU Top5 */}
<div>
<div className="flex items-center gap-2 mb-3">
<Cpu className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-300">CPU</span>
</div>
<div className="space-y-2">
{topCpuServers.map((server, index) => (
<div key={server.id} className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-4">{index + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-slate-300 truncate">{server.hostname}</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
server.cpuUsage > 80 ? 'bg-red-400' : server.cpuUsage > 60 ? 'bg-amber-400' : 'bg-blue-400'
}`}
style={{ width: `${server.cpuUsage}%` }}
/>
</div>
<span className={`text-xs ${
server.cpuUsage > 80 ? 'text-red-400' : server.cpuUsage > 60 ? 'text-amber-400' : 'text-slate-400'
}`}>
{server.cpuUsage.toFixed(1)}%
</span>
</div>
</div>
</div>
))}
</div>
</div>
{/* Memory Top5 */}
<div>
<div className="flex items-center gap-2 mb-3">
<MemoryStick className="h-4 w-4 text-emerald-400" />
<span className="text-sm font-medium text-slate-300">内存</span>
</div>
<div className="space-y-2">
{topMemoryServers.map((server, index) => (
<div key={server.id} className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-4">{index + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-slate-300 truncate">{server.hostname}</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
server.memoryUsage > 80 ? 'bg-red-400' : server.memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
}`}
style={{ width: `${server.memoryUsage}%` }}
/>
</div>
<span className={`text-xs ${
server.memoryUsage > 80 ? 'text-red-400' : server.memoryUsage > 60 ? 'text-amber-400' : 'text-slate-400'
}`}>
{server.memoryUsage.toFixed(1)}%
</span>
</div>
</div>
</div>
))}
</div>
</div>
{/* Disk Top5 */}
<div>
<div className="flex items-center gap-2 mb-3">
<Database className="h-4 w-4 text-purple-400" />
<span className="text-sm font-medium text-slate-300">磁盘</span>
</div>
<div className="space-y-2">
{topDiskServers.map((server, index) => (
<div key={server.id} className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-4">{index + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-slate-300 truncate">{server.hostname}</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
server.diskUsage > 80 ? 'bg-red-400' : server.diskUsage > 60 ? 'bg-amber-400' : 'bg-purple-400'
}`}
style={{ width: `${server.diskUsage}%` }}
/>
</div>
<span className={`text-xs ${
server.diskUsage > 80 ? 'text-red-400' : server.diskUsage > 60 ? 'text-amber-400' : 'text-slate-400'
}`}>
{server.diskUsage.toFixed(1)}%
</span>
</div>
</div>
</div>
))}
</div>
</div>
</div>
</CardContent>
</Card>
);
}
// 证书到期卡片
function CertificateExpiryCard() {
const expiringDomains = domains
.filter(d => d.sslEnabled)
.map(d => ({
...d,
daysUntilExpiry: Math.ceil((new Date(d.sslExpireDate!).getTime() - Date.now()) / (1000 * 60 * 60 * 24))
}))
.sort((a, b) => a.daysUntilExpiry - b.daysUntilExpiry);
const criticalCount = expiringDomains.filter(d => d.daysUntilExpiry < 30).length;
const warningCount = expiringDomains.filter(d => d.daysUntilExpiry >= 30 && d.daysUntilExpiry < 60).length;
return (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-slate-100">证书到期提醒</CardTitle>
<CardDescription className="text-slate-500">SSL证书到期时间监控</CardDescription>
</div>
<div className="flex items-center gap-2">
{criticalCount > 0 && (
<Badge className="bg-red-500/20 text-red-400 border-0">
<AlertTriangle className="h-3 w-3 mr-1" />
{criticalCount} 即将过期
</Badge>
)}
{warningCount > 0 && (
<Badge className="bg-amber-500/20 text-amber-400 border-0">
{warningCount} 30天内过期
</Badge>
)}
</div>
</div>
</CardHeader>
<CardContent>
<ScrollArea className="h-[280px]">
<div className="space-y-2">
{expiringDomains.map((domain) => (
<div
key={domain.id}
className="flex items-center justify-between p-3 rounded-lg bg-slate-800/50 hover:bg-slate-800 transition-colors"
>
<div className="flex items-center gap-3">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center ${
domain.daysUntilExpiry < 30 ? 'bg-red-500/10 text-red-400' :
domain.daysUntilExpiry < 60 ? 'bg-amber-500/10 text-amber-400' :
'bg-emerald-500/10 text-emerald-400'
}`}>
<Shield className="h-4 w-4" />
</div>
<div>
<p className="text-sm font-medium text-slate-200">{domain.name}</p>
<p className="text-xs text-slate-500">到期: {domain.sslExpireDate}</p>
</div>
</div>
<Badge
variant="outline"
className={`text-xs ${
domain.daysUntilExpiry < 30
? 'border-red-500/30 text-red-400 bg-red-500/10'
: domain.daysUntilExpiry < 60
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
}`}
>
{domain.daysUntilExpiry < 0 ? '已过期' : `${domain.daysUntilExpiry} 天`}
</Badge>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
);
}
// 资源使用率趋势卡片
function ResourceUsageCard() {
const data = generateMetricsData(24);
return (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-slate-100">资源使用率趋势</CardTitle>
<CardDescription className="text-slate-500">过去24小时系统资源使用情况</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
<Cpu className="h-3 w-3 mr-1" />
CPU
</Badge>
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<Activity className="h-3 w-3 mr-1" />
内存
</Badge>
<Badge variant="outline" className="border-purple-500/30 text-purple-400 bg-purple-500/10">
<HardDrive className="h-3 w-3 mr-1" />
磁盘
</Badge>
</div>
</div>
</CardHeader>
<CardContent>
<div className="h-[280px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
<defs>
<linearGradient id="colorCpu" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorMemory" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorDisk" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#a855f7" stopOpacity={0.3} />
<stop offset="95%" stopColor="#a855f7" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="time" stroke="#475569" fontSize={12} tickLine={false} axisLine={false} />
<YAxis stroke="#475569" fontSize={12} tickLine={false} axisLine={false} unit="%" />
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '6px',
color: '#f8fafc',
}}
/>
<Area
type="monotone"
dataKey="cpu"
stroke="#3b82f6"
fillOpacity={1}
fill="url(#colorCpu)"
strokeWidth={2}
name="CPU"
/>
<Area
type="monotone"
dataKey="memory"
stroke="#10b981"
fillOpacity={1}
fill="url(#colorMemory)"
strokeWidth={2}
name="内存"
/>
<Area
type="monotone"
dataKey="disk"
stroke="#a855f7"
fillOpacity={1}
fill="url(#colorDisk)"
strokeWidth={2}
name="磁盘"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
);
}
// 发布历史卡片
function ReleaseHistoryCard() {
return (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-slate-100">发布历史</CardTitle>
<CardDescription className="text-slate-500">今日应用发布记录</CardDescription>
</div>
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-slate-100">
查看全部
<ArrowRight className="h-4 w-4 ml-1" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
{appReleases.slice(0, 5).map((release) => (
<div
key={release.id}
className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50 hover:bg-slate-800 transition-colors"
>
<div
className={`w-8 h-8 rounded-lg flex items-center justify-center ${
release.status === 'success'
? 'bg-emerald-500/10 text-emerald-400'
: release.status === 'failed'
? 'bg-red-500/10 text-red-400'
: release.status === 'deploying'
? 'bg-blue-500/10 text-blue-400'
: 'bg-amber-500/10 text-amber-400'
}`}
>
<Rocket className="h-4 w-4" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-200">{release.appName}</span>
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
{release.version}
</Badge>
</div>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-slate-500">{release.environment}</span>
<span className="text-xs text-slate-500">·</span>
<span className="text-xs text-slate-500">{release.commitId}</span>
</div>
</div>
<Badge
variant="outline"
className={`text-xs ${
release.status === 'success'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: release.status === 'failed'
? 'border-red-500/30 text-red-400 bg-red-500/10'
: release.status === 'deploying'
? 'border-blue-500/30 text-blue-400 bg-blue-500/10'
: 'border-amber-500/30 text-amber-400 bg-amber-500/10'
}`}
>
{release.status === 'success'
? '成功'
: release.status === 'failed'
? '失败'
: release.status === 'deploying'
? '发布中'
: release.status === 'rolled_back'
? '已回滚'
: '构建中'}
</Badge>
</div>
))}
</div>
</CardContent>
</Card>
);
}
// 服务器状态卡片
function ServerStatusCard() {
const runningServers = servers.filter((s) => s.status === 'running').length;
const stoppedServers = servers.filter((s) => s.status === 'stopped').length;
const maintenanceServers = servers.filter((s) => s.status === 'maintenance').length;
return (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-slate-100">服务器状态</CardTitle>
<CardDescription className="text-slate-500">服务器运行状态分布</CardDescription>
</div>
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-slate-100">
查看全部
<ArrowRight className="h-4 w-4 ml-1" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-4 mb-4">
<div className="text-center p-3 rounded-lg bg-emerald-500/10">
<div className="text-2xl font-bold text-emerald-400">{runningServers}</div>
<div className="text-xs text-slate-400 mt-1">运行中</div>
</div>
<div className="text-center p-3 rounded-lg bg-amber-500/10">
<div className="text-2xl font-bold text-amber-400">{maintenanceServers}</div>
<div className="text-xs text-slate-400 mt-1">维护中</div>
</div>
<div className="text-center p-3 rounded-lg bg-slate-500/10">
<div className="text-2xl font-bold text-slate-400">{stoppedServers}</div>
<div className="text-xs text-slate-400 mt-1">已停止</div>
</div>
</div>
<div className="space-y-2">
{servers.slice(0, 4).map((server) => (
<div key={server.id} className="flex items-center gap-3 p-2 rounded-lg bg-slate-800/50">
<div
className={`w-2 h-2 rounded-full ${
server.status === 'running'
? 'bg-emerald-400'
: server.status === 'maintenance'
? 'bg-amber-400'
: 'bg-slate-400'
}`}
/>
<div className="flex-1 min-w-0">
<p className="text-sm text-slate-200 truncate">{server.hostname}</p>
<p className="text-xs text-slate-500">{server.ip}</p>
</div>
<div className="text-right">
<p className="text-xs text-slate-400">CPU {server.cpuUsage.toFixed(1)}%</p>
<p className="text-xs text-slate-400">内存 {server.memoryUsage.toFixed(1)}%</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
// K8s集群状态卡片
function K8sClusterCard() {
return (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-slate-100">K8s集群</CardTitle>
<CardDescription className="text-slate-500">Kubernetes集群健康状态</CardDescription>
</div>
<Button variant="ghost" size="sm" className="text-slate-400 hover:text-slate-100">
查看全部
<ArrowRight className="h-4 w-4 ml-1" />
</Button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{k8sClusters.map((cluster) => (
<div key={cluster.id} className="p-3 rounded-lg bg-slate-800/50">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Cloud className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-200">{cluster.name}</span>
</div>
<Badge
variant="outline"
className={`text-xs ${
cluster.status === 'healthy'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: cluster.status === 'warning'
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
: 'border-red-500/30 text-red-400 bg-red-500/10'
}`}
>
{cluster.status === 'healthy' ? '健康' : cluster.status === 'warning' ? '警告' : '异常'}
</Badge>
</div>
<div className="grid grid-cols-4 gap-2 text-center">
<div>
<div className="text-sm font-medium text-slate-200">{cluster.nodes}</div>
<div className="text-xs text-slate-500">节点</div>
</div>
<div>
<div className="text-sm font-medium text-slate-200">{cluster.pods}</div>
<div className="text-xs text-slate-500">Pod</div>
</div>
<div>
<div className="text-sm font-medium text-slate-200">{cluster.cpuUsage.toFixed(1)}%</div>
<div className="text-xs text-slate-500">CPU</div>
</div>
<div>
<div className="text-sm font-medium text-slate-200">{cluster.memoryUsage.toFixed(1)}%</div>
<div className="text-xs text-slate-500">内存</div>
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}
export function Dashboard() {
const [data, setData] = useState<DashboardData>(dashboardData);
// 模拟实时数据更新
useEffect(() => {
const interval = setInterval(() => {
setData((prev) => ({
...prev,
cpuUsage: 60 + Math.random() * 20,
memoryUsage: 70 + Math.random() * 15,
networkUsage: 30 + Math.random() * 30,
}));
}, 5000);
return () => clearInterval(interval);
}, []);
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">仪表盘</h1>
<p className="text-sm text-slate-500 mt-1">实时监控和运维概览</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<Clock className="h-4 w-4 mr-2" />
刷新
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Activity className="h-4 w-4 mr-2" />
实时监控
</Button>
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
title="服务器总数"
value={data.serverCount}
description={`${data.runningServers} 台运行中`}
icon={Server}
trend="up"
trendValue="+3 本月"
color="blue"
/>
<StatCard
title="K8s集群"
value={data.k8sClusters}
description={`${data.healthyClusters} 个健康`}
icon={Cloud}
trend="neutral"
trendValue="稳定"
color="purple"
/>
<StatCard
title="待处理告警"
value={data.alertCount}
description={`${data.criticalAlerts} 个严重`}
icon={Bell}
trend="down"
trendValue="-5 本周"
color="rose"
/>
<StatCard
title="本月云成本"
value="¥107,700"
description="较上月 +12.5%"
icon={DollarSign}
trend="up"
trendValue="+12.5%"
color="orange"
/>
</div>
{/* Resource Usage Chart */}
<ResourceUsageCard />
{/* Cost & Top Resource Usage */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<CostOverviewCard />
<TopResourceUsageCard />
</div>
{/* Alert Messages & Certificate Expiry */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<AlertMessagesCard />
<CertificateExpiryCard />
</div>
{/* Bottom Row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<ReleaseHistoryCard />
<ServerStatusCard />
<K8sClusterCard />
</div>
</div>
);
}
+450
View File
@@ -0,0 +1,450 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Rocket,
Plus,
Search,
Play,
RotateCcw,
CheckCircle,
XCircle,
Clock,
GitBranch,
GitCommit,
User,
MoreHorizontal,
Eye,
Settings,
History,
Pause,
Loader2,
ArrowLeft,
ArrowRight,
Terminal,
} from 'lucide-react';
import { appReleases } from '@/mocks/data';
import type { AppRelease } from '@/types';
export function AppReleaseManager() {
const [searchQuery, setSearchQuery] = useState('');
const [envFilter, setEnvFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [selectedRelease, setSelectedRelease] = useState<AppRelease | null>(null);
const filteredReleases = appReleases.filter((release) => {
const matchesSearch =
release.appName.toLowerCase().includes(searchQuery.toLowerCase()) ||
release.version.includes(searchQuery);
const matchesEnv = envFilter === 'all' || release.environment === envFilter;
const matchesStatus = statusFilter === 'all' || release.status === statusFilter;
return matchesSearch && matchesEnv && matchesStatus;
});
const getStatusBadge = (status: AppRelease['status']) => {
switch (status) {
case 'success':
return (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<CheckCircle className="h-3 w-3 mr-1" />
成功
</Badge>
);
case 'failed':
return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
<XCircle className="h-3 w-3 mr-1" />
失败
</Badge>
);
case 'deploying':
return (
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10 animate-pulse">
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
发布中
</Badge>
);
case 'building':
return (
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
构建中
</Badge>
);
case 'rolled_back':
return (
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
<RotateCcw className="h-3 w-3 mr-1" />
已回滚
</Badge>
);
case 'pending':
return (
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
<Clock className="h-3 w-3 mr-1" />
待发布
</Badge>
);
default:
return null;
}
};
const getEnvBadge = (env: AppRelease['environment']) => {
const colors = {
dev: 'border-slate-500/30 text-slate-400 bg-slate-500/10',
test: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
staging: 'border-amber-500/30 text-amber-400 bg-amber-500/10',
prod: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10',
};
const labels = {
dev: '开发环境',
test: '测试环境',
staging: '预发布',
prod: '生产环境',
};
return (
<Badge variant="outline" className={colors[env]}>
{labels[env]}
</Badge>
);
};
// 模拟流水线步骤
const pipelineSteps = [
{ name: '代码检出', status: 'success', duration: '15s' },
{ name: '依赖安装', status: 'success', duration: '45s' },
{ name: '代码检查', status: 'success', duration: '30s' },
{ name: '单元测试', status: 'success', duration: '2m 15s' },
{ name: '构建镜像', status: 'success', duration: '3m 30s' },
{ name: '推送镜像', status: 'success', duration: '1m 20s' },
{ name: '部署到K8s', status: 'running', duration: '--' },
{ name: '健康检查', status: 'pending', duration: '--' },
];
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">应用发布</h1>
<p className="text-sm text-slate-500 mt-1">CI/CD流水线管理与发布历史</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<Settings className="h-4 w-4 mr-2" />
流水线配置
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
新建发布
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">今日发布</p>
<p className="text-2xl font-bold text-slate-100">12</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Rocket className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">发布成功</p>
<p className="text-2xl font-bold text-emerald-400">10</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<CheckCircle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">发布失败</p>
<p className="text-2xl font-bold text-red-400">1</p>
</div>
<div className="p-3 rounded-lg bg-red-500/10 text-red-400">
<XCircle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">成功率</p>
<p className="text-2xl font-bold text-blue-400">83.3%</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<History className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索应用名称或版本..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<Select value={envFilter} onValueChange={setEnvFilter}>
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectValue placeholder="环境" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all">全部环境</SelectItem>
<SelectItem value="dev">开发环境</SelectItem>
<SelectItem value="test">测试环境</SelectItem>
<SelectItem value="staging">预发布</SelectItem>
<SelectItem value="prod">生产环境</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="success">成功</SelectItem>
<SelectItem value="failed">失败</SelectItem>
<SelectItem value="deploying">发布中</SelectItem>
<SelectItem value="rolled_back">已回滚</SelectItem>
</SelectContent>
</Select>
</div>
{/* Releases Table */}
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">应用名称</TableHead>
<TableHead className="text-slate-400">版本</TableHead>
<TableHead className="text-slate-400">环境</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">代码</TableHead>
<TableHead className="text-slate-400">发布人</TableHead>
<TableHead className="text-slate-400">开始时间</TableHead>
<TableHead className="text-slate-400">耗时</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredReleases.map((release) => (
<TableRow key={release.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<div className="flex items-center gap-2">
<Rocket className="h-4 w-4 text-slate-500" />
<span className="text-sm font-medium text-slate-200">{release.appName}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="border-slate-700 text-slate-400">
{release.version}
</Badge>
</TableCell>
<TableCell>{getEnvBadge(release.environment)}</TableCell>
<TableCell>{getStatusBadge(release.status)}</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-xs text-slate-400">
<GitBranch className="h-3 w-3" />
<span>{release.branch}</span>
<span className="mx-1">·</span>
<GitCommit className="h-3 w-3" />
<span>{release.commitId}</span>
</div>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{release.deployer}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{new Date(release.startedAt).toLocaleString()}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{release.duration ? `${Math.floor(release.duration / 60)}m ${release.duration % 60}s` : '--'}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
className="h-8 text-slate-400 hover:text-slate-100"
onClick={() => setSelectedRelease(release)}
>
<Eye className="h-4 w-4 mr-1" />
详情
</Button>
{release.status === 'failed' && (
<Button variant="ghost" size="sm" className="h-8 text-slate-400 hover:text-blue-400">
<RotateCcw className="h-4 w-4 mr-1" />
重试
</Button>
)}
{release.status === 'success' && (
<Button variant="ghost" size="sm" className="h-8 text-slate-400 hover:text-amber-400">
<RotateCcw className="h-4 w-4 mr-1" />
回滚
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Release Detail Dialog */}
<Dialog open={!!selectedRelease} onOpenChange={() => setSelectedRelease(null)}>
<DialogContent className="max-w-4xl bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<Rocket className="h-5 w-5 text-blue-400" />
{selectedRelease?.appName}
<Badge variant="outline" className="border-slate-700 text-slate-400">
{selectedRelease?.version}
</Badge>
</DialogTitle>
<DialogDescription className="text-slate-500">
流水线: {selectedRelease?.pipeline}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Pipeline Steps */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-slate-300">发布进度</h4>
<div className="space-y-2">
{pipelineSteps.map((step, index) => (
<div
key={index}
className={cn(
'flex items-center gap-3 p-3 rounded-lg',
step.status === 'success' && 'bg-emerald-500/5',
step.status === 'running' && 'bg-blue-500/5',
step.status === 'pending' && 'bg-slate-800/50'
)}
>
<div
className={cn(
'w-6 h-6 rounded-full flex items-center justify-center',
step.status === 'success' && 'bg-emerald-500/20 text-emerald-400',
step.status === 'running' && 'bg-blue-500/20 text-blue-400',
step.status === 'pending' && 'bg-slate-700 text-slate-500'
)}
>
{step.status === 'success' ? (
<CheckCircle className="h-4 w-4" />
) : step.status === 'running' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<span className="text-xs">{index + 1}</span>
)}
</div>
<span className="flex-1 text-sm text-slate-200">{step.name}</span>
<span className="text-sm text-slate-500">{step.duration}</span>
</div>
))}
</div>
</div>
{/* Build Logs */}
<div className="space-y-2">
<h4 className="text-sm font-medium text-slate-300">构建日志</h4>
<div className="bg-slate-950 rounded-lg p-4 font-mono text-xs text-slate-400 h-48 overflow-auto">
<div className="space-y-1">
<p>[10:00:00] Starting pipeline for {selectedRelease?.appName}...</p>
<p>[10:00:15] Checkout code from branch {selectedRelease?.branch}</p>
<p>[10:00:30] Installing dependencies...</p>
<p className="text-emerald-400">[10:01:15] Dependencies installed successfully</p>
<p>[10:01:15] Running code analysis...</p>
<p className="text-emerald-400">[10:01:45] Code analysis passed</p>
<p>[10:01:45] Running unit tests...</p>
<p className="text-emerald-400">[10:04:00] All tests passed (156/156)</p>
<p>[10:04:00] Building Docker image...</p>
<p className="text-emerald-400">[10:07:30] Image built successfully</p>
<p>[10:07:30] Pushing image to registry...</p>
<p className="text-emerald-400">[10:08:50] Image pushed successfully</p>
<p>[10:08:50] Deploying to Kubernetes...</p>
<p className="text-blue-400">[10:10:00] Deployment in progress...</p>
</div>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Terminal className="h-4 w-4 mr-2" />
查看完整日志
</Button>
{selectedRelease?.status === 'failed' && (
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<RotateCcw className="h-4 w-4 mr-2" />
重新发布
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,616 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Progress } from '@/components/ui/progress';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Play,
Plus,
Search,
Settings,
GitBranch,
GitCommit,
Clock,
CheckCircle,
XCircle,
Loader2,
MoreHorizontal,
Edit,
Trash2,
Copy,
Eye,
Terminal,
Pause,
RotateCw,
History,
ChevronRight,
ChevronDown,
FileCode,
Workflow,
} from 'lucide-react';
import { pipelines, pipelineExecutions, releases } from '@/mocks/data';
interface PipelineStage {
id: string;
name: string;
status: 'pending' | 'running' | 'success' | 'failed' | 'skipped';
duration: string;
logs: string[];
}
interface Pipeline {
id: string;
name: string;
description: string;
repository: string;
branch: string;
stages: PipelineStage[];
lastRun: string;
lastStatus: 'success' | 'failed' | 'running' | 'never';
triggerType: 'manual' | 'auto';
createdAt: string;
}
const mockPipelines: Pipeline[] = [
{
id: 'pipe-001',
name: 'frontend-build-and-deploy',
description: '前端项目构建与部署',
repository: 'https://gitlab.company.com/frontend/app.git',
branch: 'main',
stages: [
{ id: 'stage-1', name: '代码检出', status: 'success', duration: '15s', logs: ['检出代码...', '代码检出完成'] },
{ id: 'stage-2', name: '依赖安装', status: 'success', duration: '45s', logs: ['安装依赖...', '依赖安装完成'] },
{ id: 'stage-3', name: '代码检查', status: 'success', duration: '30s', logs: ['执行 ESLint...', '代码检查通过'] },
{ id: 'stage-4', name: '构建打包', status: 'success', duration: '2m 15s', logs: ['开始构建...', '构建完成'] },
{ id: 'stage-5', name: '部署到测试环境', status: 'success', duration: '30s', logs: ['部署中...', '部署完成'] },
],
lastRun: '2025-04-14 10:30:00',
lastStatus: 'success',
triggerType: 'auto',
createdAt: '2025-01-15',
},
{
id: 'pipe-002',
name: 'backend-api-deploy',
description: '后端API服务部署',
repository: 'https://gitlab.company.com/backend/api.git',
branch: 'develop',
stages: [
{ id: 'stage-1', name: '代码检出', status: 'success', duration: '12s', logs: [] },
{ id: 'stage-2', name: '单元测试', status: 'success', duration: '1m 30s', logs: [] },
{ id: 'stage-3', name: '构建镜像', status: 'running', duration: '45s', logs: ['构建Docker镜像...'] },
{ id: 'stage-4', name: '推送镜像', status: 'pending', duration: '-', logs: [] },
{ id: 'stage-5', name: '部署到K8s', status: 'pending', duration: '-', logs: [] },
],
lastRun: '2025-04-14 11:15:00',
lastStatus: 'running',
triggerType: 'manual',
createdAt: '2025-02-01',
},
{
id: 'pipe-003',
name: 'database-migration',
description: '数据库迁移脚本执行',
repository: 'https://gitlab.company.com/database/migrations.git',
branch: 'main',
stages: [
{ id: 'stage-1', name: '代码检出', status: 'success', duration: '10s', logs: [] },
{ id: 'stage-2', name: '数据库备份', status: 'success', duration: '2m 00s', logs: [] },
{ id: 'stage-3', name: '执行迁移', status: 'failed', duration: '15s', logs: ['迁移失败: 表已存在'] },
],
lastRun: '2025-04-14 09:00:00',
lastStatus: 'failed',
triggerType: 'manual',
createdAt: '2025-01-20',
},
];
const pipelineTemplates = [
{ id: 'template-1', name: 'Node.js 项目', description: '适用于 Node.js 前端项目', stages: ['代码检出', '依赖安装', '代码检查', '构建打包', '部署'] },
{ id: 'template-2', name: 'Go 项目', description: '适用于 Go 后端项目', stages: ['代码检出', '单元测试', '构建', '构建镜像', '部署'] },
{ id: 'template-3', name: 'Java 项目', description: '适用于 Java Spring Boot 项目', stages: ['代码检出', 'Maven构建', '单元测试', '打包', '部署'] },
{ id: 'template-4', name: 'Python 项目', description: '适用于 Python 项目', stages: ['代码检出', '依赖安装', '代码检查', '测试', '部署'] },
];
export function PipelineManager() {
const [searchQuery, setSearchQuery] = useState('');
const [selectedPipeline, setSelectedPipeline] = useState<Pipeline | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false);
const [isRunDialogOpen, setIsRunDialogOpen] = useState(false);
const [activeStage, setActiveStage] = useState<string | null>(null);
const [newPipeline, setNewPipeline] = useState({
name: '',
description: '',
repository: '',
branch: 'main',
template: '',
});
const filteredPipelines = mockPipelines.filter(
(p) =>
p.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase())
);
const getStatusBadge = (status: Pipeline['lastStatus']) => {
switch (status) {
case 'success':
return (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<CheckCircle className="h-3 w-3 mr-1" />
成功
</Badge>
);
case 'failed':
return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
<XCircle className="h-3 w-3 mr-1" />
失败
</Badge>
);
case 'running':
return (
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10 animate-pulse">
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
运行中
</Badge>
);
default:
return (
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
从未运行
</Badge>
);
}
};
const getStageStatusIcon = (status: PipelineStage['status']) => {
switch (status) {
case 'success':
return <CheckCircle className="h-4 w-4 text-emerald-400" />;
case 'failed':
return <XCircle className="h-4 w-4 text-red-400" />;
case 'running':
return <Loader2 className="h-4 w-4 text-blue-400 animate-spin" />;
case 'skipped':
return <span className="text-slate-500 text-xs">跳过</span>;
default:
return <span className="text-slate-600 text-xs">待执行</span>;
}
};
const handleCreatePipeline = () => {
// Mock create
setIsCreateDialogOpen(false);
setNewPipeline({ name: '', description: '', repository: '', branch: 'main', template: '' });
};
const handleRunPipeline = () => {
setIsRunDialogOpen(false);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">流水线管理</h1>
<p className="text-sm text-slate-500 mt-1">CI/CD 流水线配置与管理</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300">
<History className="h-4 w-4 mr-2" />
执行历史
</Button>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
新建流水线
</Button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-4 gap-4">
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">流水线总数</p>
<p className="text-2xl font-bold text-slate-100">{mockPipelines.length}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Workflow className="h-5 w-5 text-blue-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">运行中</p>
<p className="text-2xl font-bold text-blue-400">
{mockPipelines.filter((p) => p.lastStatus === 'running').length}
</p>
</div>
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Loader2 className="h-5 w-5 text-blue-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">今日成功</p>
<p className="text-2xl font-bold text-emerald-400">12</p>
</div>
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-emerald-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">今日失败</p>
<p className="text-2xl font-bold text-red-400">2</p>
</div>
<div className="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
<XCircle className="h-5 w-5 text-red-400" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<Input
placeholder="搜索流水线..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-slate-800 border-slate-700 text-slate-100"
/>
</div>
</div>
{/* Pipeline List */}
<div className="space-y-4">
{filteredPipelines.map((pipeline) => (
<Card key={pipeline.id} className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="font-medium text-slate-100">{pipeline.name}</h3>
{getStatusBadge(pipeline.lastStatus)}
<Badge variant="outline" className="border-slate-700 text-slate-400">
{pipeline.triggerType === 'auto' ? '自动触发' : '手动触发'}
</Badge>
</div>
<p className="text-sm text-slate-400 mt-1">{pipeline.description}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span className="flex items-center gap-1">
<GitBranch className="h-3 w-3" />
{pipeline.branch}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
上次运行: {pipeline.lastRun}
</span>
</div>
{/* Pipeline Stages */}
<div className="mt-4">
<div className="flex items-center gap-2">
{pipeline.stages.map((stage, index) => (
<div key={stage.id} className="flex items-center">
<div
className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${
stage.status === 'success'
? 'bg-emerald-500/20 text-emerald-400'
: stage.status === 'failed'
? 'bg-red-500/20 text-red-400'
: stage.status === 'running'
? 'bg-blue-500/20 text-blue-400'
: 'bg-slate-700 text-slate-400'
}`}
>
{getStageStatusIcon(stage.status)}
<span className="ml-1">{stage.name}</span>
</div>
{index < pipeline.stages.length - 1 && (
<ChevronRight className="h-4 w-4 text-slate-600 mx-1" />
)}
</div>
))}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="border-emerald-600 text-emerald-400 hover:bg-emerald-600/20"
onClick={() => {
setSelectedPipeline(pipeline);
setIsRunDialogOpen(true);
}}
>
<Play className="h-4 w-4 mr-1" />
执行
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400"
onClick={() => {
setSelectedPipeline(pipeline);
setIsDetailDialogOpen(true);
}}
>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* Create Pipeline Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-700 text-slate-100">
<DialogHeader>
<DialogTitle>新建流水线</DialogTitle>
<DialogDescription className="text-slate-500">配置新的 CI/CD 流水线</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label className="text-slate-300">流水线名称</Label>
<Input
value={newPipeline.name}
onChange={(e) => setNewPipeline({ ...newPipeline, name: e.target.value })}
placeholder="例如: frontend-build"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
<div>
<Label className="text-slate-300">描述</Label>
<Textarea
value={newPipeline.description}
onChange={(e) => setNewPipeline({ ...newPipeline, description: e.target.value })}
placeholder="流水线描述..."
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
<div>
<Label className="text-slate-300">代码仓库</Label>
<Input
value={newPipeline.repository}
onChange={(e) => setNewPipeline({ ...newPipeline, repository: e.target.value })}
placeholder="https://gitlab.company.com/project/repo.git"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
<div>
<Label className="text-slate-300">默认分支</Label>
<Input
value={newPipeline.branch}
onChange={(e) => setNewPipeline({ ...newPipeline, branch: e.target.value })}
placeholder="main"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
<div>
<Label className="text-slate-300">选择模板</Label>
<div className="grid grid-cols-2 gap-3 mt-1">
{pipelineTemplates.map((template) => (
<Card
key={template.id}
className={`cursor-pointer transition-all ${
newPipeline.template === template.id
? 'border-blue-500 ring-1 ring-blue-500/30 bg-slate-800'
: 'border-slate-700 hover:border-slate-600 bg-slate-800/50'
}`}
onClick={() => setNewPipeline({ ...newPipeline, template: template.id })}
>
<CardContent className="p-3">
<p className="font-medium text-slate-200">{template.name}</p>
<p className="text-xs text-slate-500">{template.description}</p>
<div className="flex flex-wrap gap-1 mt-2">
{template.stages.slice(0, 3).map((stage) => (
<Badge key={stage} variant="secondary" className="bg-slate-700 text-slate-300 text-xs">
{stage}
</Badge>
))}
{template.stages.length > 3 && (
<Badge variant="secondary" className="bg-slate-700 text-slate-300 text-xs">
+{template.stages.length - 3}
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)} className="border-slate-700 text-slate-300">
取消
</Button>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={handleCreatePipeline}>
创建流水线
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Pipeline Detail Dialog */}
<Dialog open={isDetailDialogOpen} onOpenChange={setIsDetailDialogOpen}>
<DialogContent className="max-w-4xl bg-slate-900 border-slate-700 text-slate-100">
<DialogHeader>
<DialogTitle>{selectedPipeline?.name}</DialogTitle>
<DialogDescription className="text-slate-500">{selectedPipeline?.description}</DialogDescription>
</DialogHeader>
{selectedPipeline && (
<div className="space-y-4">
<Tabs defaultValue="stages">
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="stages" className="data-[state=active]:bg-slate-700">
执行阶段
</TabsTrigger>
<TabsTrigger value="config" className="data-[state=active]:bg-slate-700">
配置
</TabsTrigger>
<TabsTrigger value="history" className="data-[state=active]:bg-slate-700">
历史记录
</TabsTrigger>
</TabsList>
<TabsContent value="stages" className="mt-4">
<div className="space-y-2">
{selectedPipeline.stages.map((stage, index) => (
<div
key={stage.id}
className={`p-3 rounded-lg border cursor-pointer transition-all ${
activeStage === stage.id
? 'border-blue-500 bg-blue-500/10'
: 'border-slate-700 hover:border-slate-600 bg-slate-800/50'
}`}
onClick={() => setActiveStage(activeStage === stage.id ? null : stage.id)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-slate-500 w-6">{index + 1}</span>
{getStageStatusIcon(stage.status)}
<span className="text-slate-200">{stage.name}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500">{stage.duration}</span>
<ChevronDown
className={`h-4 w-4 text-slate-500 transition-transform ${
activeStage === stage.id ? 'rotate-180' : ''
}`}
/>
</div>
</div>
{activeStage === stage.id && stage.logs.length > 0 && (
<div className="mt-3 p-3 bg-slate-900 rounded font-mono text-xs text-slate-400">
{stage.logs.map((log, i) => (
<div key={i}>{log}</div>
))}
</div>
)}
</div>
))}
</div>
</TabsContent>
<TabsContent value="config" className="mt-4">
<div className="space-y-4">
<div>
<Label className="text-slate-400">代码仓库</Label>
<p className="text-slate-200">{selectedPipeline.repository}</p>
</div>
<div>
<Label className="text-slate-400">默认分支</Label>
<p className="text-slate-200">{selectedPipeline.branch}</p>
</div>
<div>
<Label className="text-slate-400">触发方式</Label>
<p className="text-slate-200">
{selectedPipeline.triggerType === 'auto' ? '自动触发' : '手动触发'}
</p>
</div>
</div>
</TabsContent>
<TabsContent value="history" className="mt-4">
<div className="text-slate-500 text-center py-8">历史执行记录将在这里显示</div>
</TabsContent>
</Tabs>
</div>
)}
</DialogContent>
</Dialog>
{/* Run Pipeline Dialog */}
<Dialog open={isRunDialogOpen} onOpenChange={setIsRunDialogOpen}>
<DialogContent className="bg-slate-900 border-slate-700 text-slate-100">
<DialogHeader>
<DialogTitle>执行流水线</DialogTitle>
<DialogDescription className="text-slate-500">{selectedPipeline?.name}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label className="text-slate-300">分支</Label>
<Input defaultValue={selectedPipeline?.branch} className="bg-slate-800 border-slate-700 text-slate-100 mt-1" />
</div>
<div>
<Label className="text-slate-300">Commit ID (可选)</Label>
<Input placeholder="留空则使用最新提交" className="bg-slate-800 border-slate-700 text-slate-100 mt-1" />
</div>
<div>
<Label className="text-slate-300">环境变量 (可选)</Label>
<Textarea
placeholder="KEY=value&#10;KEY2=value2"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1 font-mono text-sm"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsRunDialogOpen(false)} className="border-slate-700 text-slate-300">
取消
</Button>
<Button className="bg-emerald-600 hover:bg-emerald-700" onClick={handleRunPipeline}>
<Play className="h-4 w-4 mr-2" />
开始执行
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,564 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Progress } from '@/components/ui/progress';
import { ScrollArea } from '@/components/ui/scroll-area';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Rocket,
Plus,
Search,
CheckCircle,
XCircle,
Clock,
RotateCcw,
GitBranch,
GitCommit,
User,
Eye,
MoreHorizontal,
FileText,
AlertCircle,
History,
ChevronRight,
Loader2,
Play,
Pause,
Check,
X,
} from 'lucide-react';
import { appReleases } from '@/mocks/data';
interface Release {
id: string;
appName: string;
version: string;
environment: 'production' | 'staging' | 'development';
status: 'pending' | 'approved' | 'deploying' | 'success' | 'failed' | 'rolled_back';
commitId: string;
branch: string;
applicant: string;
approver?: string;
description: string;
createdAt: string;
deployedAt?: string;
rollbackVersion?: string;
}
const mockReleases: Release[] = [
{
id: 'rel-001',
appName: '用户中心服务',
version: 'v2.3.1',
environment: 'production',
status: 'success',
commitId: 'a1b2c3d',
branch: 'main',
applicant: '张三',
approver: '李四',
description: '修复用户登录异常问题,优化性能',
createdAt: '2025-04-14 09:00:00',
deployedAt: '2025-04-14 10:30:00',
},
{
id: 'rel-002',
appName: '订单服务',
version: 'v1.5.0',
environment: 'production',
status: 'pending',
commitId: 'e4f5g6h',
branch: 'release/v1.5.0',
applicant: '王五',
description: '新增订单批量导出功能',
createdAt: '2025-04-14 11:00:00',
},
{
id: 'rel-003',
appName: '支付网关',
version: 'v3.0.2',
environment: 'staging',
status: 'deploying',
commitId: 'i7j8k9l',
branch: 'develop',
applicant: '赵六',
approver: '李四',
description: '集成新的支付渠道',
createdAt: '2025-04-14 10:00:00',
deployedAt: '2025-04-14 11:15:00',
},
{
id: 'rel-004',
appName: '消息推送服务',
version: 'v1.2.0',
environment: 'production',
status: 'failed',
commitId: 'm0n1o2p',
branch: 'main',
applicant: '张三',
approver: '李四',
description: '优化推送性能',
createdAt: '2025-04-14 08:00:00',
deployedAt: '2025-04-14 08:30:00',
},
{
id: 'rel-005',
appName: '数据分析平台',
version: 'v2.0.0',
environment: 'production',
status: 'rolled_back',
commitId: 'q3r4s5t',
branch: 'main',
applicant: '王五',
approver: '李四',
description: '新版数据分析引擎',
createdAt: '2025-04-13 14:00:00',
deployedAt: '2025-04-13 15:00:00',
rollbackVersion: 'v1.9.8',
},
];
const applications = [
{ id: 'app-1', name: '用户中心服务', currentVersion: 'v2.3.0' },
{ id: 'app-2', name: '订单服务', currentVersion: 'v1.4.5' },
{ id: 'app-3', name: '支付网关', currentVersion: 'v3.0.1' },
{ id: 'app-4', name: '消息推送服务', currentVersion: 'v1.1.9' },
{ id: 'app-5', name: '数据分析平台', currentVersion: 'v1.9.8' },
];
export function ReleaseManager() {
const [searchQuery, setSearchQuery] = useState('');
const [envFilter, setEnvFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [selectedRelease, setSelectedRelease] = useState<Release | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false);
const [isApproveDialogOpen, setIsApproveDialogOpen] = useState(false);
const [newRelease, setNewRelease] = useState({
appId: '',
version: '',
environment: 'staging',
branch: '',
commitId: '',
description: '',
});
const filteredReleases = mockReleases.filter((release) => {
const matchesSearch =
release.appName.toLowerCase().includes(searchQuery.toLowerCase()) ||
release.version.includes(searchQuery);
const matchesEnv = envFilter === 'all' || release.environment === envFilter;
const matchesStatus = statusFilter === 'all' || release.status === statusFilter;
return matchesSearch && matchesEnv && matchesStatus;
});
const getStatusBadge = (status: Release['status']) => {
switch (status) {
case 'success':
return (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<CheckCircle className="h-3 w-3 mr-1" />
发布成功
</Badge>
);
case 'failed':
return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
<XCircle className="h-3 w-3 mr-1" />
发布失败
</Badge>
);
case 'deploying':
return (
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10 animate-pulse">
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
发布中
</Badge>
);
case 'pending':
return (
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
<Clock className="h-3 w-3 mr-1" />
待审批
</Badge>
);
case 'approved':
return (
<Badge variant="outline" className="border-purple-500/30 text-purple-400 bg-purple-500/10">
<Check className="h-3 w-3 mr-1" />
已审批
</Badge>
);
case 'rolled_back':
return (
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
<RotateCcw className="h-3 w-3 mr-1" />
已回滚
</Badge>
);
}
};
const getEnvironmentBadge = (env: Release['environment']) => {
const colors = {
production: 'border-red-500/30 text-red-400 bg-red-500/10',
staging: 'border-amber-500/30 text-amber-400 bg-amber-500/10',
development: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
};
const labels = {
production: '生产环境',
staging: '预发环境',
development: '开发环境',
};
return (
<Badge variant="outline" className={colors[env]}>
{labels[env]}
</Badge>
);
};
const handleCreateRelease = () => {
setIsCreateDialogOpen(false);
setNewRelease({ appId: '', version: '', environment: 'staging', branch: '', commitId: '', description: '' });
};
const handleApprove = (approved: boolean) => {
setIsApproveDialogOpen(false);
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">应用发布</h1>
<p className="text-sm text-slate-500 mt-1">管理应用版本发布与审批流程</p>
</div>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => setIsCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
新建发布
</Button>
</div>
{/* Stats */}
<div className="grid grid-cols-5 gap-4">
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<p className="text-slate-400 text-sm">待审批</p>
<p className="text-2xl font-bold text-amber-400">
{mockReleases.filter((r) => r.status === 'pending').length}
</p>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<p className="text-slate-400 text-sm">发布中</p>
<p className="text-2xl font-bold text-blue-400">
{mockReleases.filter((r) => r.status === 'deploying').length}
</p>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<p className="text-slate-400 text-sm">今日成功</p>
<p className="text-2xl font-bold text-emerald-400">
{mockReleases.filter((r) => r.status === 'success').length}
</p>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<p className="text-slate-400 text-sm">今日失败</p>
<p className="text-2xl font-bold text-red-400">
{mockReleases.filter((r) => r.status === 'failed').length}
</p>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<p className="text-slate-400 text-sm">已回滚</p>
<p className="text-2xl font-bold text-slate-400">
{mockReleases.filter((r) => r.status === 'rolled_back').length}
</p>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<Input
placeholder="搜索应用或版本..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-slate-800 border-slate-700 text-slate-100"
/>
</div>
<select
value={envFilter}
onChange={(e) => setEnvFilter(e.target.value)}
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded-md text-slate-100 text-sm"
>
<option value="all">全部环境</option>
<option value="production">生产环境</option>
<option value="staging">预发环境</option>
<option value="development">开发环境</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded-md text-slate-100 text-sm"
>
<option value="all">全部状态</option>
<option value="pending">待审批</option>
<option value="approved">已审批</option>
<option value="deploying">发布中</option>
<option value="success">发布成功</option>
<option value="failed">发布失败</option>
<option value="rolled_back">已回滚</option>
</select>
</div>
{/* Release List */}
<div className="space-y-3">
{filteredReleases.map((release) => (
<Card key={release.id} className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-3">
<h3 className="font-medium text-slate-100">{release.appName}</h3>
{getStatusBadge(release.status)}
{getEnvironmentBadge(release.environment)}
</div>
<div className="flex items-center gap-4 mt-2 text-sm text-slate-400">
<span className="flex items-center gap-1">
<Rocket className="h-3 w-3" />
{release.version}
</span>
<span className="flex items-center gap-1">
<GitBranch className="h-3 w-3" />
{release.branch}
</span>
<span className="flex items-center gap-1">
<GitCommit className="h-3 w-3" />
{release.commitId}
</span>
<span className="flex items-center gap-1">
<User className="h-3 w-3" />
申请人: {release.applicant}
</span>
</div>
<p className="text-sm text-slate-500 mt-2">{release.description}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span>创建: {release.createdAt}</span>
{release.deployedAt && <span>部署: {release.deployedAt}</span>}
{release.rollbackVersion && (
<span className="text-red-400">回滚至: {release.rollbackVersion}</span>
)}
</div>
</div>
<div className="flex items-center gap-2">
{release.status === 'pending' && (
<Button
variant="outline"
size="sm"
className="border-emerald-600 text-emerald-400 hover:bg-emerald-600/20"
onClick={() => {
setSelectedRelease(release);
setIsApproveDialogOpen(true);
}}
>
<Check className="h-4 w-4 mr-1" />
审批
</Button>
)}
{release.status === 'approved' && (
<Button variant="outline" size="sm" className="border-blue-600 text-blue-400 hover:bg-blue-600/20">
<Play className="h-4 w-4 mr-1" />
开始发布
</Button>
)}
{(release.status === 'success' || release.status === 'failed') && (
<Button variant="outline" size="sm" className="border-amber-600 text-amber-400 hover:bg-amber-600/20">
<RotateCcw className="h-4 w-4 mr-1" />
回滚
</Button>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400"
onClick={() => {
setSelectedRelease(release);
setIsDetailDialogOpen(true);
}}
>
<Eye className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* Create Release Dialog */}
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-700 text-slate-100">
<DialogHeader>
<DialogTitle>新建发布</DialogTitle>
<DialogDescription className="text-slate-500">创建新的应用版本发布申请</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label className="text-slate-300">选择应用</Label>
<Select
value={newRelease.appId}
onValueChange={(value) => setNewRelease({ ...newRelease, appId: value })}
>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-100 mt-1">
<SelectValue placeholder="选择应用" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{applications.map((app) => (
<SelectItem key={app.id} value={app.id} className="text-slate-100">
{app.name} (当前: {app.currentVersion})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-300">版本号</Label>
<Input
value={newRelease.version}
onChange={(e) => setNewRelease({ ...newRelease, version: e.target.value })}
placeholder="v1.0.0"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
<div>
<Label className="text-slate-300">目标环境</Label>
<Select
value={newRelease.environment}
onValueChange={(value: any) => setNewRelease({ ...newRelease, environment: value })}
>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-100 mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="development" className="text-slate-100">开发环境</SelectItem>
<SelectItem value="staging" className="text-slate-100">预发环境</SelectItem>
<SelectItem value="production" className="text-slate-100">生产环境</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-slate-300">分支</Label>
<Input
value={newRelease.branch}
onChange={(e) => setNewRelease({ ...newRelease, branch: e.target.value })}
placeholder="main"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
<div>
<Label className="text-slate-300">Commit ID</Label>
<Input
value={newRelease.commitId}
onChange={(e) => setNewRelease({ ...newRelease, commitId: e.target.value })}
placeholder="abc1234"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
</div>
<div>
<Label className="text-slate-300">发布说明</Label>
<Textarea
value={newRelease.description}
onChange={(e) => setNewRelease({ ...newRelease, description: e.target.value })}
placeholder="描述本次发布的内容..."
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsCreateDialogOpen(false)} className="border-slate-700 text-slate-300">
取消
</Button>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={handleCreateRelease}>
提交发布申请
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Approve Dialog */}
<Dialog open={isApproveDialogOpen} onOpenChange={setIsApproveDialogOpen}>
<DialogContent className="bg-slate-900 border-slate-700 text-slate-100">
<DialogHeader>
<DialogTitle>审批发布</DialogTitle>
<DialogDescription className="text-slate-500">
{selectedRelease?.appName} - {selectedRelease?.version}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<div className="p-4 bg-slate-800 rounded-lg">
<p className="text-sm text-slate-400">发布说明</p>
<p className="text-slate-200 mt-1">{selectedRelease?.description}</p>
</div>
<div className="mt-4">
<Label className="text-slate-300">审批意见</Label>
<Textarea
placeholder="输入审批意见..."
className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => handleApprove(false)} className="border-red-600 text-red-400 hover:bg-red-600/20">
<X className="h-4 w-4 mr-2" />
驳回
</Button>
<Button className="bg-emerald-600 hover:bg-emerald-700" onClick={() => handleApprove(true)}>
<Check className="h-4 w-4 mr-2" />
通过
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+730
View File
@@ -0,0 +1,730 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Cloud,
Plus,
MoreHorizontal,
Server,
Cpu,
Database,
Activity,
CheckCircle,
AlertTriangle,
XCircle,
RefreshCw,
Settings,
Eye,
Trash2,
Edit,
Terminal,
Search,
Filter,
} from 'lucide-react';
import { k8sClusters, k8sNodes, k8sPods, k8sDeployments, k8sServices, k8sIngresses } from '@/mocks/data';
import type { K8sCluster } from '@/types';
import {
LineChart,
Line,
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
} from 'recharts';
export function K8sClusters() {
const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const filteredClusters = k8sClusters.filter(
(cluster) =>
cluster.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
cluster.version.includes(searchQuery)
);
const getStatusBadge = (status: K8sCluster['status']) => {
switch (status) {
case 'healthy':
return (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<CheckCircle className="h-3 w-3 mr-1" />
健康
</Badge>
);
case 'warning':
return (
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
<AlertTriangle className="h-3 w-3 mr-1" />
警告
</Badge>
);
case 'error':
return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
<XCircle className="h-3 w-3 mr-1" />
异常
</Badge>
);
default:
return null;
}
};
const clusterDetail = selectedCluster
? k8sClusters.find((c) => c.id === selectedCluster)
: null;
const clusterNodes = selectedCluster
? k8sNodes.filter((n) => n.clusterId === selectedCluster)
: [];
const clusterPods = selectedCluster
? k8sPods.filter((p) => p.clusterId === selectedCluster)
: [];
// 模拟资源使用趋势数据
const resourceData = [
{ time: '00:00', cpu: 45, memory: 52 },
{ time: '04:00', cpu: 38, memory: 48 },
{ time: '08:00', cpu: 62, memory: 68 },
{ time: '12:00', cpu: 78, memory: 82 },
{ time: '16:00', cpu: 72, memory: 76 },
{ time: '20:00', cpu: 58, memory: 64 },
{ time: '24:00', cpu: 48, memory: 56 },
];
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">K8s集群管理</h1>
<p className="text-sm text-slate-500 mt-1">管理Kubernetes集群资源</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<RefreshCw className="h-4 w-4 mr-2" />
刷新
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
添加集群
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">集群总数</p>
<p className="text-2xl font-bold text-slate-100">{k8sClusters.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Cloud className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">健康集群</p>
<p className="text-2xl font-bold text-emerald-400">
{k8sClusters.filter((c) => c.status === 'healthy').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<CheckCircle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">总节点数</p>
<p className="text-2xl font-bold text-slate-100">
{k8sClusters.reduce((acc, c) => acc + c.nodes, 0)}
</p>
</div>
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
<Server className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">总Pod数</p>
<p className="text-2xl font-bold text-slate-100">
{k8sClusters.reduce((acc, c) => acc + c.pods, 0)}
</p>
</div>
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
<Database className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Search */}
<div className="flex items-center gap-3">
<div className="relative w-80">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索集群名称或版本..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
</div>
{/* Clusters Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredClusters.map((cluster) => (
<Card
key={cluster.id}
className={cn(
'bg-slate-900 border-slate-800 cursor-pointer transition-all',
selectedCluster === cluster.id
? 'border-blue-500 ring-1 ring-blue-500/30'
: 'hover:border-slate-700'
)}
onClick={() => setSelectedCluster(cluster.id)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
<Cloud className="h-5 w-5 text-blue-400" />
</div>
<div>
<CardTitle className="text-base text-slate-100">{cluster.name}</CardTitle>
<CardDescription className="text-slate-500">{cluster.version}</CardDescription>
</div>
</div>
<div className="flex items-center gap-1">
{getStatusBadge(cluster.status)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={(e) => e.stopPropagation()}
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
<DropdownMenuLabel className="text-slate-300">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Eye className="h-4 w-4 mr-2" />
查看详情
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Terminal className="h-4 w-4 mr-2" />
Kubectl
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Settings className="h-4 w-4 mr-2" />
配置
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400">
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="grid grid-cols-4 gap-2 mb-4">
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.nodes}</div>
<div className="text-xs text-slate-500">节点</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.pods}</div>
<div className="text-xs text-slate-500">Pod</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.services}</div>
<div className="text-xs text-slate-500">Service</div>
</div>
<div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.deployments}</div>
<div className="text-xs text-slate-500">Deployment</div>
</div>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-10">CPU</span>
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full',
cluster.cpuUsage > 80 ? 'bg-red-400' : cluster.cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)}
style={{ width: `${cluster.cpuUsage}%` }}
/>
</div>
<span className="text-xs text-slate-400 w-10 text-right">{cluster.cpuUsage.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-10">内存</span>
<div className="flex-1 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full',
cluster.memoryUsage > 80 ? 'bg-red-400' : cluster.memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)}
style={{ width: `${cluster.memoryUsage}%` }}
/>
</div>
<span className="text-xs text-slate-400 w-10 text-right">{cluster.memoryUsage.toFixed(1)}%</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* Cluster Detail */}
{clusterDetail && (
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Cloud className="h-6 w-6 text-blue-400" />
<div>
<CardTitle className="text-slate-100">{clusterDetail.name}</CardTitle>
<CardDescription className="text-slate-500">
{clusterDetail.version} · {clusterDetail.nodes} 节点 · 创建于{' '}
{new Date(clusterDetail.createdAt).toLocaleDateString()}
</CardDescription>
</div>
</div>
<Button
variant="ghost"
size="sm"
className="text-slate-400 hover:text-slate-100"
onClick={() => setSelectedCluster(null)}
>
<XCircle className="h-4 w-4 mr-2" />
关闭
</Button>
</div>
</CardHeader>
<CardContent>
<Tabs defaultValue="overview" className="w-full">
<TabsList className="bg-slate-800 border border-slate-700 flex-wrap h-auto">
<TabsTrigger value="overview" className="data-[state=active]:bg-slate-700">
概览
</TabsTrigger>
<TabsTrigger value="nodes" className="data-[state=active]:bg-slate-700">
节点 ({clusterNodes.length})
</TabsTrigger>
<TabsTrigger value="pods" className="data-[state=active]:bg-slate-700">
Pod ({clusterPods.length})
</TabsTrigger>
<TabsTrigger value="deployments" className="data-[state=active]:bg-slate-700">
Deployment ({k8sDeployments.length})
</TabsTrigger>
<TabsTrigger value="services" className="data-[state=active]:bg-slate-700">
Service ({k8sServices.length})
</TabsTrigger>
<TabsTrigger value="ingresses" className="data-[state=active]:bg-slate-700">
Ingress ({k8sIngresses.length})
</TabsTrigger>
<TabsTrigger value="events" className="data-[state=active]:bg-slate-700">
事件
</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="mt-4 space-y-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="bg-slate-800/50 border-slate-700">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-slate-400">资源使用趋势</CardTitle>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={resourceData}>
<defs>
<linearGradient id="colorCpu" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.3} />
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorMemory" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.3} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="time" stroke="#475569" fontSize={12} />
<YAxis stroke="#475569" fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '6px',
color: '#f8fafc',
}}
/>
<Area
type="monotone"
dataKey="cpu"
stroke="#3b82f6"
fillOpacity={1}
fill="url(#colorCpu)"
name="CPU"
/>
<Area
type="monotone"
dataKey="memory"
stroke="#10b981"
fillOpacity={1}
fill="url(#colorMemory)"
name="内存"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800/50 border-slate-700">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-slate-400">资源分布</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4">
<div className="text-center p-4 rounded-lg bg-slate-800">
<div className="text-3xl font-bold text-blue-400">{clusterDetail.pods}</div>
<div className="text-sm text-slate-500 mt-1">Pod</div>
</div>
<div className="text-center p-4 rounded-lg bg-slate-800">
<div className="text-3xl font-bold text-emerald-400">{clusterDetail.services}</div>
<div className="text-sm text-slate-500 mt-1">Service</div>
</div>
<div className="text-center p-4 rounded-lg bg-slate-800">
<div className="text-3xl font-bold text-purple-400">{clusterDetail.deployments}</div>
<div className="text-sm text-slate-500 mt-1">Deployment</div>
</div>
<div className="text-center p-4 rounded-lg bg-slate-800">
<div className="text-3xl font-bold text-amber-400">{clusterDetail.nodes}</div>
<div className="text-sm text-slate-500 mt-1">节点</div>
</div>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="nodes" className="mt-4">
<div className="border rounded-lg border-slate-700 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-800">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">名称</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">角色</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">状态</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">CPU</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">内存</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">Pod</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">操作系统</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{clusterNodes.map((node) => (
<tr key={node.id} className="hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-200">{node.name}</td>
<td className="px-4 py-3">
<Badge
variant="outline"
className={
node.role === 'master'
? 'border-purple-500/30 text-purple-400 bg-purple-500/10'
: 'border-blue-500/30 text-blue-400 bg-blue-500/10'
}
>
{node.role === 'master' ? 'Master' : 'Worker'}
</Badge>
</td>
<td className="px-4 py-3">
<Badge
variant="outline"
className={
node.status === 'ready'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-red-500/30 text-red-400 bg-red-500/10'
}
>
{node.status === 'ready' ? 'Ready' : 'NotReady'}
</Badge>
</td>
<td className="px-4 py-3 text-sm text-slate-300">
{node.cpuUsage.toFixed(1)}% / {node.cpu}核
</td>
<td className="px-4 py-3 text-sm text-slate-300">
{node.memoryUsage.toFixed(1)}% / {node.memory}GB
</td>
<td className="px-4 py-3 text-sm text-slate-300">{node.pods}</td>
<td className="px-4 py-3 text-sm text-slate-400">{node.os}</td>
</tr>
))}
</tbody>
</table>
</div>
</TabsContent>
<TabsContent value="pods" className="mt-4">
<div className="border rounded-lg border-slate-700 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-800">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">名称</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">命名空间</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">状态</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">重启次数</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">CPU</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">内存</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">节点</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{clusterPods.map((pod) => (
<tr key={pod.id} className="hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-200">{pod.name}</td>
<td className="px-4 py-3">
<Badge variant="outline" className="border-slate-700 text-slate-400">
{pod.namespace}
</Badge>
</td>
<td className="px-4 py-3">
<Badge
variant="outline"
className={
pod.status === 'running'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: pod.status === 'pending'
? 'border-amber-500/30 text-amber-400 bg-amber-500/10'
: 'border-red-500/30 text-red-400 bg-red-500/10'
}
>
{pod.status}
</Badge>
</td>
<td className="px-4 py-3 text-sm text-slate-300">{pod.restarts}</td>
<td className="px-4 py-3 text-sm text-slate-300">{pod.cpu}</td>
<td className="px-4 py-3 text-sm text-slate-300">{pod.memory}</td>
<td className="px-4 py-3 text-sm text-slate-400">{pod.node}</td>
</tr>
))}
</tbody>
</table>
</div>
</TabsContent>
<TabsContent value="deployments" className="mt-4">
<div className="border rounded-lg border-slate-700 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-800">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">名称</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">命名空间</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">副本数</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">可用副本</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">镜像</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">创建时间</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{k8sDeployments.map((deployment) => (
<tr key={deployment.id} className="hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-200">{deployment.name}</td>
<td className="px-4 py-3">
<Badge variant="outline" className="border-slate-700 text-slate-400">
{deployment.namespace}
</Badge>
</td>
<td className="px-4 py-3 text-sm text-slate-300">{deployment.replicas}</td>
<td className="px-4 py-3">
<span className={`text-sm ${deployment.availableReplicas === deployment.replicas ? 'text-emerald-400' : 'text-amber-400'}`}>
{deployment.availableReplicas} / {deployment.replicas}
</span>
</td>
<td className="px-4 py-3 text-sm text-slate-400 truncate max-w-[200px]">{deployment.image}</td>
<td className="px-4 py-3 text-sm text-slate-400">{deployment.createdAt}</td>
</tr>
))}
</tbody>
</table>
</div>
</TabsContent>
<TabsContent value="services" className="mt-4">
<div className="border rounded-lg border-slate-700 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-800">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">名称</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">命名空间</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">类型</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">ClusterIP</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">端口</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">Selector</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{k8sServices.map((service) => (
<tr key={service.id} className="hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-200">{service.name}</td>
<td className="px-4 py-3">
<Badge variant="outline" className="border-slate-700 text-slate-400">
{service.namespace}
</Badge>
</td>
<td className="px-4 py-3">
<Badge
variant="outline"
className={
service.type === 'ClusterIP'
? 'border-blue-500/30 text-blue-400 bg-blue-500/10'
: service.type === 'NodePort'
? 'border-purple-500/30 text-purple-400 bg-purple-500/10'
: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
}
>
{service.type}
</Badge>
</td>
<td className="px-4 py-3 text-sm text-slate-300">{service.clusterIP}</td>
<td className="px-4 py-3 text-sm text-slate-300">{service.ports}</td>
<td className="px-4 py-3 text-sm text-slate-400">{service.selector}</td>
</tr>
))}
</tbody>
</table>
</div>
</TabsContent>
<TabsContent value="ingresses" className="mt-4">
<div className="border rounded-lg border-slate-700 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-800">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">名称</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">命名空间</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">Hosts</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">后端服务</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">TLS</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{k8sIngresses.map((ingress) => (
<tr key={ingress.id} className="hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-200">{ingress.name}</td>
<td className="px-4 py-3">
<Badge variant="outline" className="border-slate-700 text-slate-400">
{ingress.namespace}
</Badge>
</td>
<td className="px-4 py-3 text-sm text-slate-300">{ingress.hosts}</td>
<td className="px-4 py-3 text-sm text-slate-300">{ingress.backend}</td>
<td className="px-4 py-3">
<Badge
variant="outline"
className={
ingress.tls
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-slate-700 text-slate-400'
}
>
{ingress.tls ? '已启用' : '未启用'}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</TabsContent>
<TabsContent value="events" className="mt-4">
<div className="space-y-2">
{[
{ type: 'Normal', reason: 'Started', message: 'Started container nginx', time: '2分钟前' },
{ type: 'Normal', reason: 'Created', message: 'Created pod nginx-deployment-xxx', time: '5分钟前' },
{ type: 'Warning', reason: 'FailedScheduling', message: '0/3 nodes are available', time: '10分钟前' },
{ type: 'Normal', reason: 'Pulling', message: 'Pulling image "nginx:latest"', time: '15分钟前' },
{ type: 'Normal', reason: 'Pulled', message: 'Successfully pulled image', time: '12分钟前' },
].map((event, index) => (
<div
key={index}
className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50"
>
<Badge
variant="outline"
className={
event.type === 'Normal'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-amber-500/30 text-amber-400 bg-amber-500/10'
}
>
{event.type}
</Badge>
<span className="text-sm font-medium text-slate-300 min-w-[120px]">{event.reason}</span>
<span className="text-sm text-slate-400 flex-1">{event.message}</span>
<span className="text-xs text-slate-500">{event.time}</span>
</div>
))}
</div>
</TabsContent>
</Tabs>
</CardContent>
</Card>
)}
</div>
);
}
+453
View File
@@ -0,0 +1,453 @@
import { useState, useRef, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Network,
Plus,
Search,
Server,
Database,
Layers,
Share2,
Shield,
HardDrive,
Cloud,
RefreshCw,
Download,
ZoomIn,
ZoomOut,
Move,
Edit,
Trash2,
Save,
} from 'lucide-react';
import { topologyNodes, topologyEdges } from '@/mocks/data';
import type { TopologyNode, TopologyEdge } from '@/types';
export function TopologyView() {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [scale, setScale] = useState(1);
const [offset, setOffset] = useState({ x: 0, y: 0 });
const [isDragging, setIsDragging] = useState(false);
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const [selectedNode, setSelectedNode] = useState<TopologyNode | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [viewType, setViewType] = useState<string>('all');
const filteredNodes = topologyNodes.filter(
(node) =>
node.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
(viewType === 'all' || node.type === viewType)
);
const getNodeIcon = (type: TopologyNode['type']) => {
const icons = {
server: <Server className="h-5 w-5" />,
database: <Database className="h-5 w-5" />,
cache: <Layers className="h-5 w-5" />,
loadbalancer: <Share2 className="h-5 w-5" />,
cluster: <Cloud className="h-5 w-5" />,
app: <HardDrive className="h-5 w-5" />,
service: <Network className="h-5 w-5" />,
};
return icons[type] || <Server className="h-5 w-5" />;
};
const getNodeColor = (type: TopologyNode['type']) => {
const colors = {
server: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
database: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
cache: 'bg-amber-500/20 text-amber-400 border-amber-500/30',
loadbalancer: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
cluster: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
app: 'bg-rose-500/20 text-rose-400 border-rose-500/30',
service: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
};
return colors[type] || 'bg-slate-500/20 text-slate-400 border-slate-500/30';
};
// 简化的SVG拓扑图渲染
const renderTopology = () => {
const width = 800;
const height = 500;
return (
<svg
width="100%"
height="100%"
viewBox={`0 0 ${width} ${height}`}
className="bg-slate-950 rounded-lg"
>
{/* Grid background */}
<defs>
<pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">
<path d="M 20 0 L 0 0 0 20" fill="none" stroke="#1e293b" strokeWidth="0.5" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{/* Edges */}
{topologyEdges.map((edge) => {
const source = topologyNodes.find((n) => n.id === edge.source);
const target = topologyNodes.find((n) => n.id === edge.target);
if (!source || !target) return null;
return (
<g key={edge.id}>
<line
x1={source.x}
y1={source.y}
x2={target.x}
y2={target.y}
stroke="#475569"
strokeWidth="2"
markerEnd="url(#arrowhead)"
/>
<text
x={(source.x + target.x) / 2}
y={(source.y + target.y) / 2 - 5}
fill="#64748b"
fontSize="10"
textAnchor="middle"
>
{edge.label}
</text>
</g>
);
})}
{/* Arrow marker */}
<defs>
<marker
id="arrowhead"
markerWidth="10"
markerHeight="7"
refX="28"
refY="3.5"
orient="auto"
>
<polygon points="0 0, 10 3.5, 0 7" fill="#475569" />
</marker>
</defs>
{/* Nodes */}
{filteredNodes.map((node) => (
<g
key={node.id}
transform={`translate(${node.x}, ${node.y})`}
className="cursor-pointer"
onClick={() => setSelectedNode(node)}
>
<circle
r="28"
fill="#0f172a"
stroke={selectedNode?.id === node.id ? '#3b82f6' : '#334155'}
strokeWidth={selectedNode?.id === node.id ? 3 : 2}
className="transition-all hover:stroke-blue-500"
/>
<foreignObject x="-12" y="-12" width="24" height="24">
<div className={`flex items-center justify-center w-full h-full ${getNodeColor(node.type).split(' ')[1]}`}>
{getNodeIcon(node.type)}
</div>
</foreignObject>
<text
y="45"
fill="#94a3b8"
fontSize="12"
textAnchor="middle"
className="font-medium"
>
{node.name}
</text>
</g>
))}
</svg>
);
};
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">架构拓扑</h1>
<p className="text-sm text-slate-500 mt-1">系统架构可视化与依赖关系管理</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<Download className="h-4 w-4 mr-2" />
导出
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
添加节点
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">节点数</p>
<p className="text-2xl font-bold text-slate-100">{topologyNodes.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Server className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">连接数</p>
<p className="text-2xl font-bold text-slate-100">{topologyEdges.length}</p>
</div>
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
<Network className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">数据库</p>
<p className="text-2xl font-bold text-emerald-400">
{topologyNodes.filter((n) => n.type === 'database').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<Database className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">缓存</p>
<p className="text-2xl font-bold text-amber-400">
{topologyNodes.filter((n) => n.type === 'cache').length}
</p>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
<Layers className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">负载均衡</p>
<p className="text-2xl font-bold text-cyan-400">
{topologyNodes.filter((n) => n.type === 'loadbalancer').length}
</p>
</div>
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
<Share2 className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters & Toolbar */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="relative w-48">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索节点..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<Select value={viewType} onValueChange={setViewType}>
<SelectTrigger className="w-36 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectValue placeholder="节点类型" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all">全部类型</SelectItem>
<SelectItem value="server">服务器</SelectItem>
<SelectItem value="database">数据库</SelectItem>
<SelectItem value="cache">缓存</SelectItem>
<SelectItem value="loadbalancer">负载均衡</SelectItem>
<SelectItem value="cluster">集群</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400">
<ZoomIn className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400">
<ZoomOut className="h-4 w-4" />
</Button>
<Button variant="outline" size="icon" className="h-9 w-9 border-slate-700 text-slate-400">
<RefreshCw className="h-4 w-4" />
</Button>
</div>
</div>
{/* Topology Canvas */}
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
<div className="lg:col-span-3">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="h-[500px]">
{renderTopology()}
</div>
</CardContent>
</Card>
</div>
{/* Node List */}
<div>
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<CardTitle className="text-base text-slate-100">节点列表</CardTitle>
<CardDescription className="text-slate-500">点击节点查看详情</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2 max-h-[420px] overflow-auto">
{filteredNodes.map((node) => (
<div
key={node.id}
className={`flex items-center gap-3 p-2 rounded-lg cursor-pointer transition-colors ${
selectedNode?.id === node.id ? 'bg-blue-500/10 border border-blue-500/30' : 'hover:bg-slate-800/50'
}`}
onClick={() => setSelectedNode(node)}
>
<div className={`p-2 rounded-lg ${getNodeColor(node.type)}`}>
{getNodeIcon(node.type)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-200 truncate">{node.name}</p>
<p className="text-xs text-slate-500">{node.type}</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
{/* Node Detail Dialog */}
<Dialog open={!!selectedNode} onOpenChange={() => setSelectedNode(null)}>
<DialogContent className="bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<div className={`p-2 rounded-lg ${getNodeColor(selectedNode?.type || 'server')}`}>
{selectedNode && getNodeIcon(selectedNode.type)}
</div>
{selectedNode?.name}
</DialogTitle>
<DialogDescription className="text-slate-500">
类型: {selectedNode?.type}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-slate-800/50">
<div>
<p className="text-xs text-slate-500 mb-1">节点ID</p>
<p className="text-sm text-slate-200">{selectedNode?.id}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">节点名称</p>
<p className="text-sm text-slate-200">{selectedNode?.name}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">坐标位置</p>
<p className="text-sm text-slate-200">X: {selectedNode?.x}, Y: {selectedNode?.y}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">关联连接</p>
<p className="text-sm text-slate-200">
{topologyEdges.filter((e) => e.source === selectedNode?.id || e.target === selectedNode?.id).length} 条
</p>
</div>
</div>
{selectedNode?.data && (
<div>
<h4 className="text-sm font-medium text-slate-300 mb-2">扩展数据</h4>
<div className="p-3 rounded-lg bg-slate-800/50">
<pre className="text-xs text-slate-400 overflow-auto">
{JSON.stringify(selectedNode.data, null, 2)}
</pre>
</div>
</div>
)}
<div>
<h4 className="text-sm font-medium text-slate-300 mb-2">关联节点</h4>
<div className="space-y-2">
{topologyEdges
.filter((e) => e.source === selectedNode?.id || e.target === selectedNode?.id)
.map((edge) => {
const isSource = edge.source === selectedNode?.id;
const relatedNodeId = isSource ? edge.target : edge.source;
const relatedNode = topologyNodes.find((n) => n.id === relatedNodeId);
if (!relatedNode) return null;
return (
<div key={edge.id} className="flex items-center gap-2 p-2 rounded-lg bg-slate-800/50">
<span className="text-sm text-slate-400">{isSource ? '→' : '←'}</span>
<div className={`p-1 rounded ${getNodeColor(relatedNode.type)}`}>
{getNodeIcon(relatedNode.type)}
</div>
<span className="text-sm text-slate-300">{relatedNode.name}</span>
<Badge variant="outline" className="ml-auto border-slate-700 text-slate-400 text-xs">
{edge.label}
</Badge>
</div>
);
})}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Edit className="h-4 w-4 mr-2" />
编辑
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Server className="h-4 w-4 mr-2" />
查看资源
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+262
View File
@@ -0,0 +1,262 @@
import { useState } from 'react';
import { NavLink } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { useThemeStore, useTabStore, useNotificationStore } from '@/store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Search,
Bell,
Menu,
X,
Plus,
Settings,
User,
LogOut,
CheckCheck,
AlertTriangle,
Info,
CheckCircle,
Terminal,
Rocket,
Server,
Cloud,
} from 'lucide-react';
import { formatDistanceToNow } from '@/lib/utils';
export function Header() {
const { sidebarCollapsed, toggleSidebar } = useThemeStore();
const { tabs, activeTab, removeTab, setActiveTab } = useTabStore();
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotificationStore();
const [searchValue, setSearchValue] = useState('');
const getNotificationIcon = (type: string) => {
switch (type) {
case 'error':
return <AlertTriangle className="h-4 w-4 text-red-500" />;
case 'warning':
return <AlertTriangle className="h-4 w-4 text-amber-500" />;
case 'success':
return <CheckCircle className="h-4 w-4 text-emerald-500" />;
default:
return <Info className="h-4 w-4 text-blue-500" />;
}
};
return (
<header className="fixed top-0 right-0 left-0 z-30 h-14 bg-slate-900/95 backdrop-blur supports-[backdrop-filter]:bg-slate-900/60 border-b border-slate-800 transition-all duration-300"
style={{ marginLeft: sidebarCollapsed ? '4rem' : '16rem' }}
>
<div className="flex items-center justify-between h-full px-4">
{/* Left: Toggle & Search */}
<div className="flex items-center gap-3 flex-1">
{sidebarCollapsed && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={toggleSidebar}
>
<Menu className="h-4 w-4" />
</Button>
)}
{/* Global Search */}
<div className="relative w-96 max-w-full">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
type="text"
placeholder="搜索服务器、应用、工单、文档..."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500 focus-visible:ring-blue-500/30"
/>
<kbd className="absolute right-3 top-1/2 -translate-y-1/2 px-1.5 py-0.5 text-xs font-mono text-slate-500 bg-slate-800 rounded">
⌘K
</kbd>
</div>
{/* Quick Actions */}
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-slate-400 hover:text-slate-100">
<Terminal className="h-4 w-4" />
<span className="hidden lg:inline">WebShell</span>
</Button>
<Button variant="ghost" size="sm" className="h-8 gap-1.5 text-slate-400 hover:text-slate-100">
<Rocket className="h-4 w-4" />
<span className="hidden lg:inline">发布</span>
</Button>
</div>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-2">
{/* Create New */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Plus className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
<DropdownMenuLabel className="text-slate-300">快速创建</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Server className="h-4 w-4 mr-2" />
添加服务器
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Cloud className="h-4 w-4 mr-2" />
添加K8s集群
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Rocket className="h-4 w-4 mr-2" />
创建发布
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Terminal className="h-4 w-4 mr-2" />
创建工单
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Notifications */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100 relative">
<Bell className="h-4 w-4" />
{unreadCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 h-4 w-4 rounded-full bg-red-500 text-[10px] font-medium text-white flex items-center justify-center">
{unreadCount > 99 ? '99+' : unreadCount}
</span>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80 bg-slate-800 border-slate-700">
<DropdownMenuLabel className="flex items-center justify-between text-slate-300">
<span>通知</span>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="h-6 text-xs text-slate-400 hover:text-slate-100"
onClick={markAllAsRead}
>
<CheckCheck className="h-3 w-3 mr-1" />
全部已读
</Button>
)}
</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-slate-700" />
<div className="max-h-80 overflow-y-auto">
{notifications.length === 0 ? (
<div className="py-8 text-center text-slate-500">
<Bell className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">暂无通知</p>
</div>
) : (
notifications.slice(0, 10).map((notification) => (
<DropdownMenuItem
key={notification.id}
className={cn(
'flex items-start gap-3 p-3 cursor-pointer focus:bg-slate-700',
!notification.read && 'bg-slate-700/30'
)}
onClick={() => markAsRead(notification.id)}
>
{getNotificationIcon(notification.type)}
<div className="flex-1 min-w-0">
<p className={cn('text-sm font-medium', !notification.read ? 'text-slate-200' : 'text-slate-400')}>
{notification.title}
</p>
<p className="text-xs text-slate-500 mt-0.5 line-clamp-2">{notification.message}</p>
<p className="text-xs text-slate-600 mt-1">
{formatDistanceToNow(notification.timestamp)}
</p>
</div>
{!notification.read && (
<span className="w-2 h-2 rounded-full bg-blue-500 mt-1" />
)}
</DropdownMenuItem>
))
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
<Separator orientation="vertical" className="h-6 bg-slate-800" />
{/* User Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 gap-2 px-2 text-slate-300 hover:text-slate-100">
<div className="w-6 h-6 rounded-full bg-blue-600 flex items-center justify-center text-white text-xs font-medium">
管
</div>
<span className="hidden md:inline text-sm">管理员</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
<DropdownMenuLabel className="text-slate-300">我的账号</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<User className="h-4 w-4 mr-2" />
个人中心
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Settings className="h-4 w-4 mr-2" />
账号设置
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400">
<LogOut className="h-4 w-4 mr-2" />
退出登录
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
{/* Tabs */}
{tabs.length > 0 && (
<div className="flex items-center h-9 px-2 bg-slate-950 border-b border-slate-800 overflow-x-auto">
{tabs.map((tab) => (
<div
key={tab.id}
className={cn(
'group flex items-center gap-2 px-3 py-1.5 text-sm border-r border-slate-800 cursor-pointer transition-colors whitespace-nowrap',
activeTab === tab.id
? 'bg-slate-900 text-slate-100'
: 'text-slate-500 hover:text-slate-300 hover:bg-slate-900/50'
)}
onClick={() => setActiveTab(tab.id)}
>
<span>{tab.title}</span>
{tab.closable && (
<button
className="opacity-0 group-hover:opacity-100 p-0.5 rounded hover:bg-slate-700 transition-all"
onClick={(e) => {
e.stopPropagation();
removeTab(tab.id);
}}
>
<X className="h-3 w-3" />
</button>
)}
</div>
))}
</div>
)}
</header>
);
}
+31
View File
@@ -0,0 +1,31 @@
import { Outlet } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { useThemeStore } from '@/store';
import { Sidebar } from './Sidebar';
import { Header } from './Header';
export function MainLayout() {
const { sidebarCollapsed } = useThemeStore();
return (
<div className="min-h-screen bg-slate-950">
<Sidebar />
<div
className="transition-all duration-300"
style={{ marginLeft: sidebarCollapsed ? '4rem' : '16rem' }}
>
<Header />
<main
className={cn(
'pt-14 transition-all duration-300',
'min-h-[calc(100vh-3.5rem)]'
)}
>
<div className="p-6">
<Outlet />
</div>
</main>
</div>
</div>
);
}
+344
View File
@@ -0,0 +1,344 @@
import { useState } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { cn } from '@/lib/utils';
import { useThemeStore, useMenuStore } from '@/store';
import {
LayoutDashboard,
Server,
Cloud,
Rocket,
Bell,
CheckSquare,
PlayCircle,
Settings,
ChevronDown,
ChevronRight,
Terminal,
Cpu,
Database,
FileCode,
Shield,
Users,
LogOut,
Menu,
X,
Box,
Network,
CloudCog,
Globe,
MapPin,
Key,
Compass,
Brain,
ScrollText,
FileTerminal,
Activity,
BarChart3,
GitBranch,
Monitor,
Bot,
AlertTriangle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
interface MenuItem {
id: string;
name: string;
icon: React.ElementType;
path?: string;
children?: MenuItem[];
}
const menuItems: MenuItem[] = [
{ id: 'dashboard', name: '仪表盘', icon: LayoutDashboard, path: '/' },
{
id: 'assets',
name: '资产管理',
icon: Server,
children: [
{ id: 'servers', name: '服务器管理', icon: Database, path: '/servers' },
{ id: 'cloud-accounts', name: '云账号管理', icon: Key, path: '/cloud-accounts' },
{ id: 'domains', name: '域名管理', icon: Globe, path: '/domains' },
{ id: 'regions', name: '区域管理', icon: MapPin, path: '/regions' },
{ id: 'platforms', name: '云平台', icon: CloudCog, path: '/platforms' },
{ id: 'resource-models', name: '资源模型', icon: Box, path: '/resource-models' },
{ id: 'webshell', name: 'WebShell', icon: Terminal, path: '/webshell' },
{ id: 'batch-import', name: '批量导入', icon: FileCode, path: '/batch-import' },
],
},
{
id: 'k8s',
name: 'K8s集群管理',
icon: Cloud,
children: [
{ id: 'clusters', name: '集群列表', icon: Cloud, path: '/clusters' },
{ id: 'nodes', name: '节点管理', icon: Cpu, path: '/nodes' },
{ id: 'workloads', name: '工作负载', icon: Database, path: '/workloads' },
{ id: 'topology', name: '架构拓扑', icon: Network, path: '/topology' },
],
},
{
id: 'deploy',
name: '应用发布',
icon: Rocket,
children: [
{ id: 'pipelines', name: '流水线管理', icon: GitBranch, path: '/pipelines' },
{ id: 'releases', name: '发布管理', icon: Rocket, path: '/releases' },
],
},
{
id: 'monitor',
name: '监控告警',
icon: Bell,
children: [
{ id: 'metrics', name: '指标监控', icon: Cpu, path: '/metrics' },
{ id: 'alert-center', name: '告警中心', icon: Bell, path: '/alert-center' },
{ id: 'logs-monitor', name: '日志能力', icon: FileCode, path: '/logs-monitor' },
{ id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace' },
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/grafana' },
],
},
{ id: 'approval', name: '审批流程', icon: CheckSquare, path: '/approval' },
{ id: 'navigation', name: '站内导航', icon: Compass, path: '/navigation' },
{
id: 'automation',
name: '自动化运维',
icon: PlayCircle,
children: [
{ id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs' },
{ id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts' },
{ id: 'ansible', name: 'Ansible', icon: FileTerminal, path: '/ansible' },
],
},
{
id: 'aiops',
name: 'AIOps智能',
icon: Brain,
children: [
{ id: 'ai-hub', name: '智能运维中心', icon: Brain, path: '/ai-hub' },
{ id: 'ai-assistant', name: 'AI智能助手', icon: Bot, path: '/ai-assistant' },
{ id: 'ai-alert-analyzer', name: '告警智能分析', icon: AlertTriangle, path: '/ai-alert-analyzer' },
{ id: 'ai-script-generator', name: 'AI脚本生成', icon: FileCode, path: '/ai-script-generator' },
{ id: 'ai-fault-diagnosis', name: '故障智能诊断', icon: Activity, path: '/ai-fault-diagnosis' },
{ id: 'ai-predictive', name: 'AI预测分析', icon: BarChart3, path: '/ai-predictive' },
{ id: 'sql-audit', name: 'SQL审计', icon: ScrollText, path: '/sql-audit' },
],
},
{
id: 'system',
name: '系统管理',
icon: Settings,
children: [
{ id: 'users', name: '用户管理', icon: Users, path: '/users' },
{ id: 'roles', name: '角色权限', icon: Shield, path: '/roles' },
{ id: 'logs-audit', name: '操作审计', icon: FileCode, path: '/logs-audit' },
{ id: 'settings', name: '系统设置', icon: Settings, path: '/settings' },
],
},
];
function MenuItemComponent({ item, depth = 0 }: { item: MenuItem; depth?: number }) {
const location = useLocation();
const { openMenus, toggleMenu, activeMenu, setActiveMenu } = useMenuStore();
const { sidebarCollapsed } = useThemeStore();
const hasChildren = item.children && item.children.length > 0;
const isOpen = openMenus.includes(item.id);
const isActive = item.path === location.pathname || activeMenu === item.id;
const handleClick = () => {
if (hasChildren) {
toggleMenu(item.id);
} else if (item.path) {
setActiveMenu(item.id);
}
};
if (sidebarCollapsed && depth === 0) {
return (
<div className="relative group">
<Button
variant={isActive ? 'secondary' : 'ghost'}
size="icon"
className={cn(
'w-10 h-10 mx-auto mb-1',
isActive && 'bg-blue-600/20 text-blue-400'
)}
asChild={!hasChildren}
>
{hasChildren ? (
<span onClick={handleClick}>
<item.icon className="h-5 w-5" />
</span>
) : (
<NavLink to={item.path || '#'} onClick={handleClick}>
<item.icon className="h-5 w-5" />
</NavLink>
)}
</Button>
{/* Tooltip */}
<div className="absolute left-full top-1/2 -translate-y-1/2 ml-2 px-3 py-1.5 bg-slate-800 text-slate-100 text-sm rounded-md opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all whitespace-nowrap z-50 border border-slate-700">
{item.name}
{hasChildren && (
<div className="absolute left-0 top-0 mt-2 ml-2 w-48 bg-slate-800 rounded-md border border-slate-700 shadow-lg py-1">
{item.children?.map((child) => (
<NavLink
key={child.id}
to={child.path || '#'}
className={({ isActive }) =>
cn(
'block px-4 py-2 text-sm hover:bg-slate-700 transition-colors',
isActive ? 'text-blue-400 bg-blue-600/10' : 'text-slate-300'
)
}
>
{child.name}
</NavLink>
))}
</div>
)}
</div>
</div>
);
}
return (
<div className={cn('mb-0.5', depth > 0 && 'ml-4')}>
{hasChildren ? (
<>
<Button
variant={isActive ? 'secondary' : 'ghost'}
className={cn(
'w-full justify-between h-9 px-3 text-sm font-normal',
isActive ? 'bg-blue-600/20 text-blue-400 hover:bg-blue-600/30' : 'text-slate-300 hover:text-slate-100 hover:bg-slate-800'
)}
onClick={handleClick}
>
<span className="flex items-center gap-2">
<item.icon className="h-4 w-4" />
<span>{item.name}</span>
</span>
{isOpen ? (
<ChevronDown className="h-4 w-4 text-slate-500" />
) : (
<ChevronRight className="h-4 w-4 text-slate-500" />
)}
</Button>
{isOpen && (
<div className="mt-0.5 space-y-0.5">
{item.children?.map((child) => (
<NavLink
key={child.id}
to={child.path || '#'}
onClick={() => setActiveMenu(child.id)}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-3 py-2 text-sm rounded-md transition-colors',
isActive
? 'bg-blue-600/20 text-blue-400'
: 'text-slate-400 hover:text-slate-100 hover:bg-slate-800'
)
}
>
<child.icon className="h-3.5 w-3.5" />
<span>{child.name}</span>
</NavLink>
))}
</div>
)}
</>
) : (
<NavLink
to={item.path || '#'}
onClick={handleClick}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-3 py-2 text-sm rounded-md transition-colors',
isActive
? 'bg-blue-600/20 text-blue-400'
: 'text-slate-300 hover:text-slate-100 hover:bg-slate-800'
)
}
>
<item.icon className="h-4 w-4" />
<span>{item.name}</span>
</NavLink>
)}
</div>
);
}
export function Sidebar() {
const { sidebarCollapsed, toggleSidebar } = useThemeStore();
return (
<aside
className={cn(
'fixed left-0 top-0 z-40 h-screen bg-slate-900 border-r border-slate-800 transition-all duration-300',
sidebarCollapsed ? 'w-16' : 'w-64'
)}
>
{/* Logo */}
<div className="flex items-center justify-between h-14 px-4 border-b border-slate-800">
{!sidebarCollapsed && (
<div className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center">
<Cloud className="h-5 w-5 text-white" />
</div>
<span className="font-semibold text-slate-100">AIOps平台</span>
</div>
)}
{sidebarCollapsed && (
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center mx-auto">
<Cloud className="h-5 w-5 text-white" />
</div>
)}
{!sidebarCollapsed && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={toggleSidebar}
>
<Menu className="h-4 w-4" />
</Button>
)}
</div>
{/* Menu */}
<ScrollArea className="h-[calc(100vh-3.5rem-4rem)]">
<div className={cn('p-2', sidebarCollapsed && 'px-1')}>
{menuItems.map((item) => (
<MenuItemComponent key={item.id} item={item} />
))}
</div>
</ScrollArea>
{/* User */}
{!sidebarCollapsed && (
<div className="absolute bottom-0 left-0 right-0 p-3 border-t border-slate-800 bg-slate-900">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium">
管
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-slate-200 truncate">管理员</p>
<p className="text-xs text-slate-500 truncate">admin@company.com</p>
</div>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<LogOut className="h-4 w-4" />
</Button>
</div>
</div>
)}
{sidebarCollapsed && (
<div className="absolute bottom-0 left-0 right-0 p-2 border-t border-slate-800 bg-slate-900">
<Button variant="ghost" size="icon" className="w-10 h-10 mx-auto text-slate-400 hover:text-slate-100">
<LogOut className="h-5 w-5" />
</Button>
</div>
)}
</aside>
);
}
+469
View File
@@ -0,0 +1,469 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Bell,
Plus,
Search,
CheckCircle,
AlertTriangle,
XCircle,
Clock,
Settings,
Mail,
MessageSquare,
Phone,
VolumeX,
} from 'lucide-react';
import { alertRules, alertEvents } from '@/mocks/data';
import type { AlertEvent } from '@/types';
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from 'recharts';
export function AlertManager() {
const [activeTab, setActiveTab] = useState('events');
const [searchQuery, setSearchQuery] = useState('');
const [severityFilter, setSeverityFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const filteredEvents = alertEvents.filter((event) => {
const matchesSearch =
event.ruleName.toLowerCase().includes(searchQuery.toLowerCase()) ||
event.target.toLowerCase().includes(searchQuery.toLowerCase());
const matchesSeverity = severityFilter === 'all' || event.severity === severityFilter;
const matchesStatus = statusFilter === 'all' || event.status === statusFilter;
return matchesSearch && matchesSeverity && matchesStatus;
});
const getSeverityBadge = (severity: AlertEvent['severity']) => {
switch (severity) {
case 'critical':
return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
<AlertTriangle className="h-3 w-3 mr-1" />
严重
</Badge>
);
case 'warning':
return (
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
<AlertTriangle className="h-3 w-3 mr-1" />
警告
</Badge>
);
case 'info':
return (
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
<Bell className="h-3 w-3 mr-1" />
提示
</Badge>
);
default:
return null;
}
};
const getStatusBadge = (status: AlertEvent['status']) => {
switch (status) {
case 'firing':
return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10 animate-pulse">
触发中
</Badge>
);
case 'resolved':
return (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
已恢复
</Badge>
);
default:
return null;
}
};
// 告警趋势数据
const alertTrendData = [
{ time: '00:00', critical: 2, warning: 5, info: 8 },
{ time: '04:00', critical: 1, warning: 3, info: 6 },
{ time: '08:00', critical: 3, warning: 7, info: 12 },
{ time: '12:00', critical: 5, warning: 10, info: 15 },
{ time: '16:00', critical: 4, warning: 8, info: 10 },
{ time: '20:00', critical: 3, warning: 6, info: 9 },
{ time: '24:00', critical: 2, warning: 4, info: 7 },
];
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">监控告警</h1>
<p className="text-sm text-slate-500 mt-1">告警规则管理与告警事件处理</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<Settings className="h-4 w-4 mr-2" />
通知配置
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
新建规则
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">告警规则</p>
<p className="text-2xl font-bold text-slate-100">{alertRules.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Bell className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">触发中</p>
<p className="text-2xl font-bold text-red-400">
{alertEvents.filter((e) => e.status === 'firing').length}
</p>
</div>
<div className="p-3 rounded-lg bg-red-500/10 text-red-400">
<AlertTriangle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">今日告警</p>
<p className="text-2xl font-bold text-amber-400">{alertEvents.length}</p>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
<Clock className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">已恢复</p>
<p className="text-2xl font-bold text-emerald-400">
{alertEvents.filter((e) => e.status === 'resolved').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<CheckCircle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Alert Trend Chart */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<CardTitle className="text-slate-100">告警趋势</CardTitle>
<CardDescription className="text-slate-500">过去24小时告警统计</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={alertTrendData}>
<defs>
<linearGradient id="colorCritical" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.3} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorWarning" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#f59e0b" stopOpacity={0.3} />
<stop offset="95%" stopColor="#f59e0b" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" />
<XAxis dataKey="time" stroke="#475569" fontSize={12} />
<YAxis stroke="#475569" fontSize={12} />
<Tooltip
contentStyle={{
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '6px',
color: '#f8fafc',
}}
/>
<Area
type="monotone"
dataKey="critical"
stroke="#ef4444"
fillOpacity={1}
fill="url(#colorCritical)"
name="严重"
/>
<Area
type="monotone"
dataKey="warning"
stroke="#f59e0b"
fillOpacity={1}
fill="url(#colorWarning)"
name="警告"
/>
</AreaChart>
</ResponsiveContainer>
</div>
</CardContent>
</Card>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="events" className="data-[state=active]:bg-slate-700">
告警事件
</TabsTrigger>
<TabsTrigger value="rules" className="data-[state=active]:bg-slate-700">
告警规则
</TabsTrigger>
<TabsTrigger value="silences" className="data-[state=active]:bg-slate-700">
静默规则
</TabsTrigger>
</TabsList>
<TabsContent value="events" className="mt-4 space-y-4">
{/* Filters */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索告警名称或目标..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<select
value={severityFilter}
onChange={(e) => setSeverityFilter(e.target.value)}
className="h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
>
<option value="all">全部级别</option>
<option value="critical">严重</option>
<option value="warning">警告</option>
<option value="info">提示</option>
</select>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="h-9 px-3 rounded-md bg-slate-800/50 border border-slate-700 text-slate-200 text-sm"
>
<option value="all">全部状态</option>
<option value="firing">触发中</option>
<option value="resolved">已恢复</option>
</select>
</div>
{/* Events Table */}
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">级别</TableHead>
<TableHead className="text-slate-400">告警名称</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">目标</TableHead>
<TableHead className="text-slate-400">当前值</TableHead>
<TableHead className="text-slate-400">阈值</TableHead>
<TableHead className="text-slate-400">开始时间</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredEvents.map((event) => (
<TableRow key={event.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>{getSeverityBadge(event.severity)}</TableCell>
<TableCell>
<span className="text-sm font-medium text-slate-200">{event.ruleName}</span>
</TableCell>
<TableCell>{getStatusBadge(event.status)}</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{event.target}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{event.value}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-500">{event.threshold}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{new Date(event.startedAt).toLocaleString()}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
{event.status === 'firing' && (
<Button
variant="ghost"
size="sm"
className="h-8 text-slate-400 hover:text-emerald-400"
>
<CheckCircle className="h-4 w-4 mr-1" />
确认
</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="rules" className="mt-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">规则名称</TableHead>
<TableHead className="text-slate-400">监控指标</TableHead>
<TableHead className="text-slate-400">触发条件</TableHead>
<TableHead className="text-slate-400">级别</TableHead>
<TableHead className="text-slate-400">持续时间</TableHead>
<TableHead className="text-slate-400">通知渠道</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{alertRules.map((rule) => (
<TableRow key={rule.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<div>
<span className="text-sm font-medium text-slate-200">{rule.name}</span>
<p className="text-xs text-slate-500">{rule.description}</p>
</div>
</TableCell>
<TableCell>
<code className="text-xs text-slate-300 bg-slate-800 px-2 py-0.5 rounded">
{rule.metric}
</code>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">
{rule.operator} {rule.threshold}
</span>
</TableCell>
<TableCell>
{rule.severity === 'critical' ? (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
严重
</Badge>
) : rule.severity === 'warning' ? (
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
警告
</Badge>
) : (
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
提示
</Badge>
)}
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{rule.duration / 60}分钟</span>
</TableCell>
<TableCell>
<div className="flex gap-1">
{rule.notifyChannels.includes('email') && (
<Mail className="h-4 w-4 text-slate-400" />
)}
{rule.notifyChannels.includes('sms') && (
<Phone className="h-4 w-4 text-slate-400" />
)}
{rule.notifyChannels.includes('dingtalk') && (
<MessageSquare className="h-4 w-4 text-slate-400" />
)}
</div>
</TableCell>
<TableCell>
<Switch checked={rule.enabled} className="data-[state=checked]:bg-blue-600" />
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Settings className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="silences" className="mt-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-8">
<div className="text-center">
<VolumeX className="h-12 w-12 mx-auto text-slate-500 mb-4" />
<h3 className="text-lg font-medium text-slate-300 mb-2">暂无静默规则</h3>
<p className="text-sm text-slate-500 mb-4">创建静默规则以临时抑制告警通知</p>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
创建静默规则
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,631 @@
import React, { useState, useEffect } from 'react';
import {
Bell, AlertTriangle, AlertCircle, CheckCircle, XCircle, Clock, Filter,
Search, RefreshCw, Settings, Eye, Check, X, Volume2, VolumeX, Mail,
MessageSquare, Phone, User, Server, TrendingUp, TrendingDown, BarChart3,
Calendar, Download, MoreHorizontal, ChevronDown, ChevronUp, Zap, Shield,
Activity, Layers, Info, Plus, ChevronRight
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { toast } from 'sonner';
import { alertCenterApi } from '@/services/api';
interface Alert {
id: string;
title: string;
description: string;
severity: 'critical' | 'warning' | 'info';
status: 'firing' | 'resolved' | 'acknowledged' | 'suppressed';
source: string;
service: string;
metric: string;
value: number;
threshold: number;
startedAt: string;
resolvedAt?: string;
acknowledgedBy?: string;
acknowledgedAt?: string;
runbookUrl?: string;
labels: Record<string, string>;
annotations: Record<string, string>;
}
interface AlertRule {
id: string;
name: string;
description: string;
enabled: boolean;
severity: 'critical' | 'warning' | 'info';
query: string;
duration: string;
threshold: number;
labels: string[];
notificationChannels: string[];
lastEvaluated: string;
}
interface NotificationChannel {
id: string;
name: string;
type: 'email' | 'slack' | 'webhook' | 'sms' | 'pagerduty';
enabled: boolean;
config: Record<string, string>;
}
const AlertCenter: React.FC = () => {
const [alerts, setAlerts] = useState<Alert[]>([]);
const [rules, setRules] = useState<AlertRule[]>([]);
const [channels, setChannels] = useState<NotificationChannel[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [severityFilter, setSeverityFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [activeTab, setActiveTab] = useState('alerts');
const [selectedAlert, setSelectedAlert] = useState<Alert | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
const [silenceOpen, setSilenceOpen] = useState(false);
const [stats, setStats] = useState({
firing: 0,
critical: 0,
warning: 0,
resolved: 0
});
useEffect(() => {
fetchData();
const interval = setInterval(fetchData, 30000);
return () => clearInterval(interval);
}, []);
const fetchData = async () => {
await Promise.all([fetchAlerts(), fetchRules(), fetchChannels()]);
};
const fetchAlerts = async () => {
setLoading(true);
try {
const res = await alertCenterApi.getAlerts();
const data = res.data || [];
setAlerts(data);
setStats({
firing: data.filter((a: Alert) => a.status === 'firing').length,
critical: data.filter((a: Alert) => a.severity === 'critical' && a.status === 'firing').length,
warning: data.filter((a: Alert) => a.severity === 'warning' && a.status === 'firing').length,
resolved: data.filter((a: Alert) => a.status === 'resolved').length
});
} catch (error) {
toast.error('获取告警失败');
} finally {
setLoading(false);
}
};
const fetchRules = async () => {
try {
const res = await alertCenterApi.getRules();
setRules(res.data || []);
} catch (error) {
toast.error('获取告警规则失败');
}
};
const fetchChannels = async () => {
try {
const res = await alertCenterApi.getChannels();
setChannels(res.data || []);
} catch (error) {
toast.error('获取通知渠道失败');
}
};
const handleAcknowledge = async (id: string) => {
try {
await alertCenterApi.acknowledge(id);
toast.success('告警已确认');
fetchAlerts();
} catch (error) {
toast.error('操作失败');
}
};
const handleResolve = async (id: string) => {
try {
await alertCenterApi.resolve(id);
toast.success('告警已解决');
fetchAlerts();
} catch (error) {
toast.error('操作失败');
}
};
const handleSilence = async (id: string, duration: string) => {
try {
await alertCenterApi.silence(id, duration);
toast.success('告警已静默');
setSilenceOpen(false);
fetchAlerts();
} catch (error) {
toast.error('操作失败');
}
};
const toggleRule = async (ruleId: string, enabled: boolean) => {
try {
await alertCenterApi.toggleRule(ruleId, enabled);
setRules(rules.map(r => r.id === ruleId ? { ...r, enabled } : r));
toast.success('规则状态已更新');
} catch (error) {
toast.error('更新失败');
}
};
const getSeverityBadge = (severity: string) => {
switch (severity) {
case 'critical':
return <Badge variant="destructive" className="gap-1"><AlertCircle className="w-3 h-3" />严重</Badge>;
case 'warning':
return <Badge variant="default" className="bg-orange-500 gap-1"><AlertTriangle className="w-3 h-3" />警告</Badge>;
case 'info':
return <Badge variant="default" className="bg-blue-500 gap-1"><Info className="w-3 h-3" />信息</Badge>;
default:
return <Badge variant="secondary">未知</Badge>;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'firing':
return <Badge variant="destructive" className="gap-1 animate-pulse"><Zap className="w-3 h-3" />触发中</Badge>;
case 'acknowledged':
return <Badge variant="default" className="bg-yellow-500 gap-1"><User className="w-3 h-3" />已确认</Badge>;
case 'resolved':
return <Badge variant="default" className="bg-green-500 gap-1"><CheckCircle className="w-3 h-3" />已解决</Badge>;
case 'suppressed':
return <Badge variant="secondary" className="gap-1"><VolumeX className="w-3 h-3" />已静默</Badge>;
default:
return <Badge variant="secondary">未知</Badge>;
}
};
const getChannelIcon = (type: string) => {
switch (type) {
case 'email': return <Mail className="w-4 h-4" />;
case 'slack': return <MessageSquare className="w-4 h-4" />;
case 'sms': return <Phone className="w-4 h-4" />;
case 'webhook': return <Zap className="w-4 h-4" />;
default: return <Bell className="w-4 h-4" />;
}
};
const filteredAlerts = alerts.filter(alert => {
const matchSearch = alert.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
alert.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
alert.source.toLowerCase().includes(searchQuery.toLowerCase());
const matchSeverity = severityFilter === 'all' || alert.severity === severityFilter;
const matchStatus = statusFilter === 'all' || alert.status === statusFilter;
return matchSearch && matchSeverity && matchStatus;
});
return (
<div className="space-y-6">
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-4">
<Card className={stats.firing > 0 ? 'border-red-500 border-2' : ''}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">触发中</p>
<p className={`text-2xl font-bold ${stats.firing > 0 ? 'text-red-600' : ''}`}>{stats.firing}</p>
</div>
<div className={`p-3 rounded-lg ${stats.firing > 0 ? 'bg-red-100' : 'bg-gray-100'}`}>
<Zap className={`w-5 h-5 ${stats.firing > 0 ? 'text-red-600' : 'text-gray-600'}`} />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">严重告警</p>
<p className="text-2xl font-bold text-red-600">{stats.critical}</p>
</div>
<div className="p-3 bg-red-100 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">警告告警</p>
<p className="text-2xl font-bold text-orange-600">{stats.warning}</p>
</div>
<div className="p-3 bg-orange-100 rounded-lg">
<AlertTriangle className="w-5 h-5 text-orange-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">今日已解决</p>
<p className="text-2xl font-bold text-green-600">{stats.resolved}</p>
</div>
<div className="p-3 bg-green-100 rounded-lg">
<CheckCircle className="w-5 h-5 text-green-600" />
</div>
</div>
</CardContent>
</Card>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3 lg:w-[400px]">
<TabsTrigger value="alerts">告警列表</TabsTrigger>
<TabsTrigger value="rules">告警规则</TabsTrigger>
<TabsTrigger value="channels">通知渠道</TabsTrigger>
</TabsList>
<TabsContent value="alerts" className="space-y-4">
{/* 过滤栏 */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2 flex-1 min-w-[300px]">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索告警标题、描述、来源..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Select value={severityFilter} onValueChange={setSeverityFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="级别" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部级别</SelectItem>
<SelectItem value="critical">严重</SelectItem>
<SelectItem value="warning">警告</SelectItem>
<SelectItem value="info">信息</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="firing">触发中</SelectItem>
<SelectItem value="acknowledged">已确认</SelectItem>
<SelectItem value="resolved">已解决</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchAlerts}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
</Button>
<Button variant="outline" onClick={() => toast.info('批量操作功能开发中')}>
<Check className="w-4 h-4 mr-2" />
批量确认
</Button>
</div>
</div>
{/* 告警列表 */}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">告警信息</th>
<th className="px-4 py-3 text-left text-sm font-medium">级别</th>
<th className="px-4 py-3 text-left text-sm font-medium">状态</th>
<th className="px-4 py-3 text-left text-sm font-medium">来源</th>
<th className="px-4 py-3 text-left text-sm font-medium">持续时间</th>
<th className="px-4 py-3 text-left text-sm font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">加载中...</td>
</tr>
) : filteredAlerts.length === 0 ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">暂无告警</td>
</tr>
) : (
filteredAlerts.map((alert) => (
<tr key={alert.id} className="hover:bg-muted/50">
<td className="px-4 py-3">
<div className="font-medium">{alert.title}</div>
<div className="text-sm text-muted-foreground line-clamp-1">{alert.description}</div>
<div className="flex gap-1 mt-1">
{Object.entries(alert.labels).slice(0, 3).map(([k, v]) => (
<Badge key={k} variant="outline" className="text-xs">{k}={v}</Badge>
))}
</div>
</td>
<td className="px-4 py-3">{getSeverityBadge(alert.severity)}</td>
<td className="px-4 py-3">{getStatusBadge(alert.status)}</td>
<td className="px-4 py-3 text-sm">
<div>{alert.source}</div>
<div className="text-muted-foreground">{alert.service}</div>
</td>
<td className="px-4 py-3 text-sm text-muted-foreground">
{alert.status === 'resolved' && alert.resolvedAt
? `${Math.floor((new Date(alert.resolvedAt).getTime() - new Date(alert.startedAt).getTime()) / 60000)}分钟`
: `${Math.floor((Date.now() - new Date(alert.startedAt).getTime()) / 60000)}分钟`
}
</td>
<td className="px-4 py-3">
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => { setSelectedAlert(alert); setDetailOpen(true); }}
>
<Eye className="w-4 h-4" />
</Button>
{alert.status === 'firing' && (
<Button
variant="ghost"
size="sm"
onClick={() => handleAcknowledge(alert.id)}
>
<Check className="w-4 h-4 text-blue-600" />
</Button>
)}
{(alert.status === 'firing' || alert.status === 'acknowledged') && (
<Button
variant="ghost"
size="sm"
onClick={() => handleResolve(alert.id)}
>
<CheckCircle className="w-4 h-4 text-green-600" />
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => { setSelectedAlert(alert); setSilenceOpen(true); }}
>
<VolumeX className="w-4 h-4 text-orange-600" />
</Button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="rules" className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchRules}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
</Button>
</div>
<Button onClick={() => toast.info('创建规则功能开发中')}>
<Plus className="w-4 h-4 mr-2" />
新建规则
</Button>
</div>
<div className="grid grid-cols-1 gap-4">
{rules.map((rule) => (
<Card key={rule.id}>
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className="font-medium">{rule.name}</span>
{getSeverityBadge(rule.severity)}
<Badge variant={rule.enabled ? 'default' : 'secondary'}>
{rule.enabled ? '已启用' : '已禁用'}
</Badge>
</div>
<p className="text-sm text-muted-foreground mb-2">{rule.description}</p>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-mono bg-muted px-2 py-1 rounded">{rule.query}</span>
<span>持续 {rule.duration}</span>
<span>阈值: {rule.threshold}</span>
</div>
<div className="flex gap-1 mt-2">
{rule.notificationChannels.map(ch => (
<Badge key={ch} variant="outline" className="text-xs">{ch}</Badge>
))}
</div>
</div>
<div className="flex items-center gap-2">
<Switch
checked={rule.enabled}
onCheckedChange={(checked) => toggleRule(rule.id, checked)}
/>
<Button variant="ghost" size="sm">
<Settings className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="channels" className="space-y-4">
<div className="flex justify-between items-center">
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchChannels}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
</Button>
</div>
<Button onClick={() => toast.info('添加渠道功能开发中')}>
<Plus className="w-4 h-4 mr-2" />
添加渠道
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{channels.map((channel) => (
<Card key={channel.id}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center gap-2">
{getChannelIcon(channel.type)}
<CardTitle className="text-base">{channel.name}</CardTitle>
</div>
<Badge variant={channel.enabled ? 'default' : 'secondary'}>
{channel.enabled ? '已启用' : '已禁用'}
</Badge>
</div>
</CardHeader>
<CardContent className="pt-0">
<div className="text-sm text-muted-foreground capitalize mb-4">
类型: {channel.type}
</div>
<div className="flex gap-2">
<Button variant="outline" size="sm" className="flex-1">
<Settings className="w-4 h-4 mr-2" />
配置
</Button>
<Button variant="outline" size="sm" className="flex-1">
<Zap className="w-4 h-4 mr-2" />
测试
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
</Tabs>
{/* 告警详情对话框 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
{selectedAlert?.title}
{selectedAlert && getSeverityBadge(selectedAlert.severity)}
</DialogTitle>
</DialogHeader>
{selectedAlert && (
<div className="space-y-4">
<div>
<Label className="text-muted-foreground">描述</Label>
<p className="mt-1">{selectedAlert.description}</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-muted-foreground">来源</Label>
<p className="font-medium">{selectedAlert.source}</p>
</div>
<div>
<Label className="text-muted-foreground">服务</Label>
<p className="font-medium">{selectedAlert.service}</p>
</div>
<div>
<Label className="text-muted-foreground">指标</Label>
<p className="font-medium">{selectedAlert.metric}</p>
</div>
<div>
<Label className="text-muted-foreground">当前值 / 阈值</Label>
<p className="font-medium">{selectedAlert.value} / {selectedAlert.threshold}</p>
</div>
<div>
<Label className="text-muted-foreground">开始时间</Label>
<p className="font-medium">{selectedAlert.startedAt}</p>
</div>
{selectedAlert.resolvedAt && (
<div>
<Label className="text-muted-foreground">解决时间</Label>
<p className="font-medium">{selectedAlert.resolvedAt}</p>
</div>
)}
</div>
<div>
<Label className="text-muted-foreground">标签</Label>
<div className="flex flex-wrap gap-2 mt-1">
{Object.entries(selectedAlert.labels).map(([k, v]) => (
<Badge key={k} variant="outline">{k}={v}</Badge>
))}
</div>
</div>
{selectedAlert.runbookUrl && (
<div>
<Label className="text-muted-foreground">处理手册</Label>
<a href={selectedAlert.runbookUrl} target="_blank" rel="noopener noreferrer" className="text-blue-500 hover:underline block mt-1">
查看处理手册 →
</a>
</div>
)}
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setDetailOpen(false)}>关闭</Button>
{selectedAlert?.status === 'firing' && (
<Button onClick={() => { handleAcknowledge(selectedAlert.id); setDetailOpen(false); }}>
<Check className="w-4 h-4 mr-2" />
确认告警
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* 静默对话框 */}
<Dialog open={silenceOpen} onOpenChange={setSilenceOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>静默告警</DialogTitle>
<DialogDescription>选择静默持续时间</DialogDescription>
</DialogHeader>
<div className="space-y-2">
{['1h', '4h', '8h', '24h', '7d'].map((duration) => (
<Button
key={duration}
variant="outline"
className="w-full justify-between"
onClick={() => selectedAlert && handleSilence(selectedAlert.id, duration)}
>
<span>静默 {duration}</span>
<ChevronRight className="w-4 h-4" />
</Button>
))}
</div>
</DialogContent>
</Dialog>
</div>
);
};
export default AlertCenter;
@@ -0,0 +1,715 @@
import React, { useState, useEffect } from 'react';
import {
Monitor, Settings, Plus, Edit, Trash2, Copy, Eye, Download, RefreshCw,
Maximize2, Minimize2, Save, X, ChevronDown, BarChart3, LineChart, PieChart,
Activity, Cpu, Database, Server, Globe, Zap, AlertTriangle, TrendingUp,
TrendingDown, Clock, Calendar, Filter, Search, GripVertical, Move
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Separator } from '@/components/ui/separator';
import { toast } from 'sonner';
import { dashboardApi } from '@/services/api';
interface Dashboard {
id: string;
name: string;
description: string;
datasource: 'prometheus' | 'zabbix' | 'skywalking' | 'custom';
datasourceUrl: string;
panels: Panel[];
createdAt: string;
updatedAt: string;
createdBy: string;
isDefault: boolean;
}
interface Panel {
id: string;
title: string;
type: 'graph' | 'stat' | 'gauge' | 'table' | 'pie' | 'heatmap' | 'text';
query: string;
datasource: string;
width: number;
height: number;
x: number;
y: number;
options: PanelOptions;
}
interface PanelOptions {
unit?: string;
min?: number;
max?: number;
thresholds?: Threshold[];
legend?: boolean;
tooltip?: boolean;
refreshInterval?: number;
}
interface Threshold {
value: number;
color: string;
name: string;
}
interface Datasource {
id: string;
name: string;
type: 'prometheus' | 'zabbix' | 'skywalking' | 'influxdb' | 'elasticsearch';
url: string;
status: 'connected' | 'disconnected' | 'error';
}
const GrafanaDashboard: React.FC = () => {
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [datasources, setDatasources] = useState<Datasource[]>([]);
const [activeDashboard, setActiveDashboard] = useState<Dashboard | null>(null);
const [loading, setLoading] = useState(false);
const [editMode, setEditMode] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [addPanelOpen, setAddPanelOpen] = useState(false);
const [datasourceOpen, setDatasourceOpen] = useState(false);
const [newDashboard, setNewDashboard] = useState({
name: '',
description: '',
datasource: 'prometheus' as const,
datasourceUrl: ''
});
const [newPanel, setNewPanel] = useState({
title: '',
type: 'graph' as const,
query: '',
width: 6,
height: 4
});
const [newDatasource, setNewDatasource] = useState({
name: '',
type: 'prometheus' as const,
url: ''
});
useEffect(() => {
fetchDashboards();
fetchDatasources();
}, []);
const fetchDashboards = async () => {
setLoading(true);
try {
const res = await dashboardApi.getDashboards();
setDashboards(res.data || []);
if (res.data?.length > 0 && !activeDashboard) {
setActiveDashboard(res.data[0]);
}
} catch (error) {
toast.error('获取仪表盘失败');
} finally {
setLoading(false);
}
};
const fetchDatasources = async () => {
try {
const res = await dashboardApi.getDatasources();
setDatasources(res.data || []);
} catch (error) {
toast.error('获取数据源失败');
}
};
const handleCreateDashboard = async () => {
if (!newDashboard.name) {
toast.error('请输入仪表盘名称');
return;
}
try {
await dashboardApi.createDashboard({
...newDashboard,
panels: []
});
toast.success('仪表盘创建成功');
setCreateOpen(false);
setNewDashboard({ name: '', description: '', datasource: 'prometheus', datasourceUrl: '' });
fetchDashboards();
} catch (error) {
toast.error('创建失败');
}
};
const handleAddPanel = async () => {
if (!newPanel.title || !newPanel.query || !activeDashboard) {
toast.error('请填写完整信息');
return;
}
try {
const panel: Panel = {
id: Date.now().toString(),
...newPanel,
datasource: activeDashboard.datasource,
x: 0,
y: 0,
options: {
unit: '',
legend: true,
tooltip: true,
refreshInterval: 30
}
};
await dashboardApi.updateDashboard(activeDashboard.id, {
...activeDashboard,
panels: [...activeDashboard.panels, panel]
});
toast.success('面板添加成功');
setAddPanelOpen(false);
setNewPanel({ title: '', type: 'graph', query: '', width: 6, height: 4 });
fetchDashboards();
} catch (error) {
toast.error('添加失败');
}
};
const handleAddDatasource = async () => {
if (!newDatasource.name || !newDatasource.url) {
toast.error('请填写完整信息');
return;
}
try {
await dashboardApi.createDatasource(newDatasource);
toast.success('数据源添加成功');
setDatasourceOpen(false);
setNewDatasource({ name: '', type: 'prometheus', url: '' });
fetchDatasources();
} catch (error) {
toast.error('添加失败');
}
};
const handleDeleteDashboard = async (id: string) => {
if (!confirm('确定删除此仪表盘吗?')) return;
try {
await dashboardApi.deleteDashboard(id);
toast.success('删除成功');
if (activeDashboard?.id === id) {
setActiveDashboard(null);
}
fetchDashboards();
} catch (error) {
toast.error('删除失败');
}
};
const getDatasourceIcon = (type: string) => {
switch (type) {
case 'prometheus': return <Activity className="w-4 h-4" />;
case 'zabbix': return <Server className="w-4 h-4" />;
case 'skywalking': return <Globe className="w-4 h-4" />;
case 'influxdb': return <Database className="w-4 h-4" />;
default: return <Database className="w-4 h-4" />;
}
};
const getPanelIcon = (type: string) => {
switch (type) {
case 'graph': return <LineChart className="w-4 h-4" />;
case 'stat': return <Activity className="w-4 h-4" />;
case 'gauge': return <Cpu className="w-4 h-4" />;
case 'pie': return <PieChart className="w-4 h-4" />;
case 'table': return <BarChart3 className="w-4 h-4" />;
default: return <BarChart3 className="w-4 h-4" />;
}
};
const getDatasourceStatusBadge = (status: string) => {
switch (status) {
case 'connected':
return <Badge variant="default" className="bg-green-500">已连接</Badge>;
case 'disconnected':
return <Badge variant="secondary">已断开</Badge>;
case 'error':
return <Badge variant="destructive">错误</Badge>;
default:
return <Badge variant="outline">未知</Badge>;
}
};
// 模拟图表数据
const generateChartData = (panel: Panel) => {
const dataPoints = 24;
const data = [];
for (let i = 0; i < dataPoints; i++) {
data.push({
time: new Date(Date.now() - (dataPoints - i) * 3600000).toLocaleTimeString(),
value: Math.random() * 100
});
}
return data;
};
// 渲染面板内容
const renderPanelContent = (panel: Panel) => {
const data = generateChartData(panel);
switch (panel.type) {
case 'stat':
return (
<div className="flex flex-col items-center justify-center h-full">
<span className="text-4xl font-bold text-blue-500">{Math.floor(Math.random() * 1000)}</span>
<span className="text-sm text-muted-foreground mt-2">{panel.options?.unit || 'requests/s'}</span>
<div className="flex items-center gap-1 mt-2 text-green-500 text-sm">
<TrendingUp className="w-4 h-4" />
<span>+12.5%</span>
</div>
</div>
);
case 'gauge':
return (
<div className="flex flex-col items-center justify-center h-full">
<div className="relative w-24 h-24">
<svg className="w-full h-full" viewBox="0 0 100 100">
<circle cx="50" cy="50" r="40" fill="none" stroke="#e5e7eb" strokeWidth="10" />
<circle
cx="50"
cy="50"
r="40"
fill="none"
stroke="#3b82f6"
strokeWidth="10"
strokeDasharray={`${Math.random() * 251} 251`}
transform="rotate(-90 50 50)"
/>
</svg>
<div className="absolute inset-0 flex items-center justify-center">
<span className="text-xl font-bold">{Math.floor(Math.random() * 100)}%</span>
</div>
</div>
<span className="text-sm text-muted-foreground mt-2">CPU使用率</span>
</div>
);
case 'graph':
default:
return (
<div className="h-full flex flex-col">
<div className="flex-1 flex items-end gap-1 px-2">
{data.map((d, i) => (
<div
key={i}
className="flex-1 bg-blue-500 rounded-t"
style={{ height: `${d.value}%`, opacity: 0.3 + (i / data.length) * 0.7 }}
/>
))}
</div>
<div className="flex justify-between text-xs text-muted-foreground px-2 pt-2">
<span>{data[0].time}</span>
<span>{data[data.length - 1].time}</span>
</div>
</div>
);
}
};
return (
<div className={`space-y-4 ${fullscreen ? 'fixed inset-0 z-50 bg-background p-4' : ''}`}>
{/* 顶部工具栏 */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Select
value={activeDashboard?.id}
onValueChange={(id) => {
const dashboard = dashboards.find(d => d.id === id);
if (dashboard) setActiveDashboard(dashboard);
}}
>
<SelectTrigger className="w-[250px]">
<SelectValue placeholder="选择仪表盘" />
</SelectTrigger>
<SelectContent>
{dashboards.map(d => (
<SelectItem key={d.id} value={d.id}>
<div className="flex items-center gap-2">
{getDatasourceIcon(d.datasource)}
{d.name}
{d.isDefault && <Badge variant="outline" className="text-xs">默认</Badge>}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{activeDashboard && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span>更新于 {activeDashboard.updatedAt}</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchDashboards}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
</Button>
{activeDashboard && (
<>
<Button
variant={editMode ? 'default' : 'outline'}
size="sm"
onClick={() => setEditMode(!editMode)}
>
<Edit className="w-4 h-4 mr-2" />
{editMode ? '完成' : '编辑'}
</Button>
<Button variant="outline" size="sm" onClick={() => setAddPanelOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
添加面板
</Button>
</>
)}
<Button variant="outline" size="sm" onClick={() => setFullscreen(!fullscreen)}>
{fullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</Button>
<Button variant="outline" size="sm" onClick={() => setDatasourceOpen(true)}>
<Database className="w-4 h-4 mr-2" />
数据源
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
新建仪表盘
</Button>
</div>
</div>
{/* 仪表盘内容 */}
{activeDashboard ? (
<div className="space-y-4">
{/* 仪表盘信息 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{activeDashboard.name}</h2>
<p className="text-sm text-muted-foreground">{activeDashboard.description}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1">
{getDatasourceIcon(activeDashboard.datasource)}
{activeDashboard.datasource}
</Badge>
</div>
</div>
{/* 面板网格 */}
<div className="grid grid-cols-12 gap-4">
{activeDashboard.panels.map((panel) => (
<Card
key={panel.id}
className={`col-span-${panel.width} ${editMode ? 'ring-2 ring-blue-500 ring-offset-2' : ''}`}
style={{ gridColumn: `span ${panel.width}`, minHeight: `${panel.height * 80}px` }}
>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
{getPanelIcon(panel.type)}
{panel.title}
</CardTitle>
{editMode && (
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<GripVertical className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0">
<Edit className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 text-red-500">
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
{renderPanelContent(panel)}
</CardContent>
</Card>
))}
{activeDashboard.panels.length === 0 && (
<div className="col-span-12 py-16 text-center text-muted-foreground">
<BarChart3 className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>暂无面板,点击"添加面板"开始创建</p>
</div>
)}
</div>
</div>
) : (
<div className="py-16 text-center text-muted-foreground">
<Monitor className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>请选择一个仪表盘或创建新的仪表盘</p>
<Button className="mt-4" onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
新建仪表盘
</Button>
</div>
)}
{/* 新建仪表盘对话框 */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>新建仪表盘</DialogTitle>
<DialogDescription>创建一个新的监控仪表盘</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>名称</Label>
<Input
value={newDashboard.name}
onChange={(e) => setNewDashboard({ ...newDashboard, name: e.target.value })}
placeholder="仪表盘名称"
/>
</div>
<div>
<Label>描述</Label>
<Input
value={newDashboard.description}
onChange={(e) => setNewDashboard({ ...newDashboard, description: e.target.value })}
placeholder="仪表盘描述"
/>
</div>
<div>
<Label>数据源类型</Label>
<Select
value={newDashboard.datasource}
onValueChange={(v) => setNewDashboard({ ...newDashboard, datasource: v as any })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="prometheus">Prometheus</SelectItem>
<SelectItem value="zabbix">Zabbix</SelectItem>
<SelectItem value="skywalking">SkyWalking</SelectItem>
<SelectItem value="custom">自定义</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>数据源地址</Label>
<Input
value={newDashboard.datasourceUrl}
onChange={(e) => setNewDashboard({ ...newDashboard, datasourceUrl: e.target.value })}
placeholder="http://localhost:9090"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
<Button onClick={handleCreateDashboard}>
<Save className="w-4 h-4 mr-2" />
创建
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 添加面板对话框 */}
<Dialog open={addPanelOpen} onOpenChange={setAddPanelOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>添加面板</DialogTitle>
<DialogDescription>添加一个新的监控面板</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>标题</Label>
<Input
value={newPanel.title}
onChange={(e) => setNewPanel({ ...newPanel, title: e.target.value })}
placeholder="面板标题"
/>
</div>
<div>
<Label>类型</Label>
<Select
value={newPanel.type}
onValueChange={(v) => setNewPanel({ ...newPanel, type: v as any })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="graph">折线图</SelectItem>
<SelectItem value="stat">统计值</SelectItem>
<SelectItem value="gauge">仪表盘</SelectItem>
<SelectItem value="pie">饼图</SelectItem>
<SelectItem value="table">表格</SelectItem>
<SelectItem value="heatmap">热力图</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>查询语句</Label>
<textarea
value={newPanel.query}
onChange={(e) => setNewPanel({ ...newPanel, query: e.target.value })}
placeholder={newPanel.type === 'graph' ? 'sum(rate(http_requests_total[5m]))' : 'up'}
className="w-full min-h-[100px] p-2 border rounded-md font-mono text-sm"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>宽度 (列)</Label>
<Select
value={newPanel.width.toString()}
onValueChange={(v) => setNewPanel({ ...newPanel, width: parseInt(v) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3列 (25%)</SelectItem>
<SelectItem value="4">4列 (33%)</SelectItem>
<SelectItem value="6">6列 (50%)</SelectItem>
<SelectItem value="8">8列 (66%)</SelectItem>
<SelectItem value="12">12列 (100%)</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>高度 (行)</Label>
<Select
value={newPanel.height.toString()}
onValueChange={(v) => setNewPanel({ ...newPanel, height: parseInt(v) })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="3">3行 (短)</SelectItem>
<SelectItem value="4">4行 (中)</SelectItem>
<SelectItem value="6">6行 (长)</SelectItem>
<SelectItem value="8">8行 (超长)</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddPanelOpen(false)}>取消</Button>
<Button onClick={handleAddPanel}>
<Plus className="w-4 h-4 mr-2" />
添加
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 数据源管理对话框 */}
<Dialog open={datasourceOpen} onOpenChange={setDatasourceOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>数据源管理</DialogTitle>
<DialogDescription>管理监控数据源配置</DialogDescription>
</DialogHeader>
<Tabs defaultValue="list">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="list">数据源列表</TabsTrigger>
<TabsTrigger value="add">添加数据源</TabsTrigger>
</TabsList>
<TabsContent value="list" className="space-y-4">
<div className="grid grid-cols-1 gap-4">
{datasources.map((ds) => (
<Card key={ds.id}>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{getDatasourceIcon(ds.type)}
<div>
<p className="font-medium">{ds.name}</p>
<p className="text-sm text-muted-foreground">{ds.url}</p>
</div>
</div>
<div className="flex items-center gap-2">
{getDatasourceStatusBadge(ds.status)}
<Button variant="ghost" size="sm">
<Settings className="w-4 h-4" />
</Button>
<Button variant="ghost" size="sm" className="text-red-500">
<Trash2 className="w-4 h-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="add" className="space-y-4">
<div>
<Label>名称</Label>
<Input
value={newDatasource.name}
onChange={(e) => setNewDatasource({ ...newDatasource, name: e.target.value })}
placeholder="数据源名称"
/>
</div>
<div>
<Label>类型</Label>
<Select
value={newDatasource.type}
onValueChange={(v) => setNewDatasource({ ...newDatasource, type: v as any })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="prometheus">Prometheus</SelectItem>
<SelectItem value="zabbix">Zabbix</SelectItem>
<SelectItem value="skywalking">SkyWalking</SelectItem>
<SelectItem value="influxdb">InfluxDB</SelectItem>
<SelectItem value="elasticsearch">Elasticsearch</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>地址</Label>
<Input
value={newDatasource.url}
onChange={(e) => setNewDatasource({ ...newDatasource, url: e.target.value })}
placeholder="http://localhost:9090"
/>
</div>
<Button onClick={handleAddDatasource} className="w-full">
<Plus className="w-4 h-4 mr-2" />
添加数据源
</Button>
</TabsContent>
</Tabs>
</DialogContent>
</Dialog>
</div>
);
};
export default GrafanaDashboard;
@@ -0,0 +1,618 @@
import React, { useState, useEffect } from 'react';
import {
Search, Filter, Clock, Server, ArrowRight, ChevronRight, ChevronDown, Activity,
Zap, AlertCircle, CheckCircle, XCircle, BarChart3, PieChart, TrendingUp,
Download, RefreshCw, Eye, Maximize2, Minimize2, Target, Layers, GitBranch,
Cpu, Database, Globe, Code, Box, Container, Settings, Info
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { ScrollArea } from '@/components/ui/scroll-area';
import { toast } from 'sonner';
import { traceApi } from '@/services/api';
interface Trace {
id: string;
traceId: string;
serviceName: string;
operationName: string;
startTime: string;
duration: number;
status: 'success' | 'error' | 'pending';
spanCount: number;
tags: Record<string, string>;
spans?: Span[];
}
interface Span {
id: string;
spanId: string;
parentSpanId?: string;
serviceName: string;
operationName: string;
startTime: string;
duration: number;
status: 'success' | 'error' | 'pending';
kind: 'client' | 'server' | 'producer' | 'consumer' | 'internal';
tags: Record<string, string>;
logs: SpanLog[];
children: Span[];
}
interface SpanLog {
timestamp: string;
fields: Record<string, string>;
}
interface ServiceMetrics {
serviceName: string;
requestCount: number;
errorRate: number;
avgDuration: number;
p95Duration: number;
p99Duration: number;
}
const TraceManager: React.FC = () => {
const [traces, setTraces] = useState<Trace[]>([]);
const [services, setServices] = useState<ServiceMetrics[]>([]);
const [loading, setLoading] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [serviceFilter, setServiceFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [timeRange, setTimeRange] = useState<string>('1h');
const [activeTab, setActiveTab] = useState('traces');
const [selectedTrace, setSelectedTrace] = useState<Trace | null>(null);
const [selectedSpan, setSelectedSpan] = useState<Span | null>(null);
const [traceDetailOpen, setTraceDetailOpen] = useState(false);
const [spanDetailOpen, setSpanDetailOpen] = useState(false);
useEffect(() => {
fetchData();
}, [timeRange]);
const fetchData = async () => {
await Promise.all([fetchTraces(), fetchServices()]);
};
const fetchTraces = async () => {
setLoading(true);
try {
const res = await traceApi.getTraces({ timeRange, service: serviceFilter, status: statusFilter });
setTraces(res.data || []);
} catch (error) {
toast.error('获取链路数据失败');
} finally {
setLoading(false);
}
};
const fetchServices = async () => {
try {
const res = await traceApi.getServices(timeRange);
setServices(res.data || []);
} catch (error) {
toast.error('获取服务数据失败');
}
};
const fetchTraceDetail = async (traceId: string) => {
try {
const res = await traceApi.getTraceDetail(traceId);
return res.data;
} catch (error) {
toast.error('获取链路详情失败');
return null;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'success':
return <Badge variant="default" className="bg-green-500 gap-1"><CheckCircle className="w-3 h-3" />成功</Badge>;
case 'error':
return <Badge variant="destructive" className="gap-1"><XCircle className="w-3 h-3" />错误</Badge>;
case 'pending':
return <Badge variant="outline" className="gap-1"><Clock className="w-3 h-3" />进行中</Badge>;
default:
return <Badge variant="secondary">未知</Badge>;
}
};
const getKindIcon = (kind: string) => {
switch (kind) {
case 'client': return <ArrowRight className="w-4 h-4" />;
case 'server': return <Server className="w-4 h-4" />;
case 'producer': return <Zap className="w-4 h-4" />;
case 'consumer': return <Box className="w-4 h-4" />;
default: return <Code className="w-4 h-4" />;
}
};
const getStatusColor = (status: string) => {
switch (status) {
case 'success': return 'bg-green-500';
case 'error': return 'bg-red-500';
case 'pending': return 'bg-yellow-500';
default: return 'bg-gray-500';
}
};
const formatDuration = (ms: number) => {
if (ms < 1) return `${(ms * 1000).toFixed(2)}μs`;
if (ms < 1000) return `${ms.toFixed(2)}ms`;
return `${(ms / 1000).toFixed(2)}s`;
};
const filteredTraces = traces.filter(trace => {
const matchSearch = trace.traceId.toLowerCase().includes(searchQuery.toLowerCase()) ||
trace.operationName.toLowerCase().includes(searchQuery.toLowerCase());
const matchService = serviceFilter === 'all' || trace.serviceName === serviceFilter;
const matchStatus = statusFilter === 'all' || trace.status === statusFilter;
return matchSearch && matchService && matchStatus;
});
const serviceNames = Array.from(new Set(traces.map(t => t.serviceName)));
// 构建Span树
const buildSpanTree = (spans: Span[]): Span[] => {
const spanMap = new Map<string, Span>();
const roots: Span[] = [];
spans.forEach(span => {
spanMap.set(span.spanId, { ...span, children: [] });
});
spans.forEach(span => {
const node = spanMap.get(span.spanId)!;
if (span.parentSpanId && spanMap.has(span.parentSpanId)) {
const parent = spanMap.get(span.parentSpanId)!;
parent.children = parent.children || [];
parent.children.push(node);
} else {
roots.push(node);
}
});
return roots;
};
// 渲染Span树
const SpanTreeNode: React.FC<{ span: Span; level: number; totalDuration: number }> = ({ span, level, totalDuration }) => {
const [expanded, setExpanded] = useState(true);
const widthPercent = totalDuration > 0 ? (span.duration / totalDuration) * 100 : 0;
const offsetPercent = 0; // Simplified
return (
<div className="select-none">
<div
className="flex items-center py-2 hover:bg-muted/50 cursor-pointer border-l-2 border-transparent hover:border-blue-500"
style={{ paddingLeft: `${level * 24 + 12}px` }}
onClick={() => { setSelectedSpan(span); setSpanDetailOpen(true); }}
>
{span.children?.length > 0 && (
<Button
variant="ghost"
size="sm"
className="h-4 w-4 p-0 mr-1"
onClick={(e) => { e.stopPropagation(); setExpanded(!expanded); }}
>
{expanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</Button>
)}
{!span.children?.length && <span className="w-5" />}
<div className="flex items-center gap-2 flex-1 min-w-0">
{getKindIcon(span.kind)}
<span className="font-medium truncate">{span.operationName}</span>
<span className="text-xs text-muted-foreground">{span.serviceName}</span>
</div>
<div className="flex items-center gap-4">
<div className="w-32 h-4 bg-muted rounded overflow-hidden">
<div
className={`h-full ${getStatusColor(span.status)}`}
style={{ width: `${Math.max(widthPercent, 1)}%` }}
/>
</div>
<span className="text-sm text-muted-foreground w-20 text-right">
{formatDuration(span.duration)}
</span>
{span.status === 'error' && <AlertCircle className="w-4 h-4 text-red-500" />}
</div>
</div>
{expanded && span.children?.map(child => (
<SpanTreeNode key={child.spanId} span={child} level={level + 1} totalDuration={totalDuration} />
))}
</div>
);
};
return (
<div className="space-y-6">
{/* 统计卡片 */}
<div className="grid grid-cols-4 gap-4">
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">总请求数</p>
<p className="text-2xl font-bold">{services.reduce((sum, s) => sum + s.requestCount, 0).toLocaleString()}</p>
</div>
<div className="p-3 bg-blue-100 rounded-lg">
<Activity className="w-5 h-5 text-blue-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">平均错误率</p>
<p className="text-2xl font-bold">
{(services.reduce((sum, s) => sum + s.errorRate, 0) / (services.length || 1)).toFixed(2)}%
</p>
</div>
<div className="p-3 bg-red-100 rounded-lg">
<AlertCircle className="w-5 h-5 text-red-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">平均延迟</p>
<p className="text-2xl font-bold">
{formatDuration(services.reduce((sum, s) => sum + s.avgDuration, 0) / (services.length || 1))}
</p>
</div>
<div className="p-3 bg-green-100 rounded-lg">
<Clock className="w-5 h-5 text-green-600" />
</div>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">服务数量</p>
<p className="text-2xl font-bold">{services.length}</p>
</div>
<div className="p-3 bg-purple-100 rounded-lg">
<Layers className="w-5 h-5 text-purple-600" />
</div>
</div>
</CardContent>
</Card>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-3 lg:w-[400px]">
<TabsTrigger value="traces">链路追踪</TabsTrigger>
<TabsTrigger value="services">服务拓扑</TabsTrigger>
<TabsTrigger value="metrics">性能指标</TabsTrigger>
</TabsList>
<TabsContent value="traces" className="space-y-4">
{/* 查询工具栏 */}
<div className="flex flex-wrap items-center justify-between gap-4">
<div className="flex items-center gap-2 flex-1 min-w-[400px]">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
<Input
placeholder="搜索Trace ID、操作名..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
<Select value={serviceFilter} onValueChange={setServiceFilter}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="服务" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部服务</SelectItem>
{serviceNames.map(s => (
<SelectItem key={s} value={s}>{s}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="success">成功</SelectItem>
<SelectItem value="error">错误</SelectItem>
<SelectItem value="pending">进行中</SelectItem>
</SelectContent>
</Select>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="15m">15分钟</SelectItem>
<SelectItem value="1h">1小时</SelectItem>
<SelectItem value="6h">6小时</SelectItem>
<SelectItem value="24h">24小时</SelectItem>
<SelectItem value="7d">7天</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchTraces}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
</Button>
</div>
</div>
{/* Trace列表 */}
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">Trace ID</th>
<th className="px-4 py-3 text-left text-sm font-medium">服务 / 操作</th>
<th className="px-4 py-3 text-left text-sm font-medium">状态</th>
<th className="px-4 py-3 text-left text-sm font-medium">耗时</th>
<th className="px-4 py-3 text-left text-sm font-medium">Span数</th>
<th className="px-4 py-3 text-left text-sm font-medium">时间</th>
<th className="px-4 py-3 text-left text-sm font-medium">操作</th>
</tr>
</thead>
<tbody className="divide-y">
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">加载中...</td>
</tr>
) : filteredTraces.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-muted-foreground">暂无链路数据</td>
</tr>
) : (
filteredTraces.map((trace) => (
<tr key={trace.id} className="hover:bg-muted/50">
<td className="px-4 py-3">
<span className="font-mono text-sm text-blue-500">{trace.traceId.slice(0, 16)}...</span>
</td>
<td className="px-4 py-3">
<div className="font-medium">{trace.operationName}</div>
<div className="text-sm text-muted-foreground">{trace.serviceName}</div>
</td>
<td className="px-4 py-3">{getStatusBadge(trace.status)}</td>
<td className="px-4 py-3 text-sm">{formatDuration(trace.duration)}</td>
<td className="px-4 py-3 text-sm">{trace.spanCount}</td>
<td className="px-4 py-3 text-sm text-muted-foreground">{trace.startTime}</td>
<td className="px-4 py-3">
<Button
variant="ghost"
size="sm"
onClick={async () => {
const detail = await fetchTraceDetail(trace.traceId);
if (detail) {
setSelectedTrace({ ...trace, spans: detail.spans });
setTraceDetailOpen(true);
}
}}
>
<Eye className="w-4 h-4" />
</Button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="services" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>服务性能概览</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className="px-4 py-3 text-left text-sm font-medium">服务名称</th>
<th className="px-4 py-3 text-left text-sm font-medium">请求数</th>
<th className="px-4 py-3 text-left text-sm font-medium">错误率</th>
<th className="px-4 py-3 text-left text-sm font-medium">平均延迟</th>
<th className="px-4 py-3 text-left text-sm font-medium">P95延迟</th>
<th className="px-4 py-3 text-left text-sm font-medium">P99延迟</th>
</tr>
</thead>
<tbody className="divide-y">
{services.map((service) => (
<tr key={service.serviceName} className="hover:bg-muted/50">
<td className="px-4 py-3 font-medium">{service.serviceName}</td>
<td className="px-4 py-3">{service.requestCount.toLocaleString()}</td>
<td className="px-4 py-3">
<span className={service.errorRate > 1 ? 'text-red-500' : 'text-green-500'}>
{service.errorRate.toFixed(2)}%
</span>
</td>
<td className="px-4 py-3">{formatDuration(service.avgDuration)}</td>
<td className="px-4 py-3">{formatDuration(service.p95Duration)}</td>
<td className="px-4 py-3">{formatDuration(service.p99Duration)}</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="metrics" className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<Card>
<CardHeader>
<CardTitle>延迟分布</CardTitle>
</CardHeader>
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
<BarChart3 className="w-16 h-16 mr-4" />
延迟分布图表(集成图表库后显示)
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>错误率趋势</CardTitle>
</CardHeader>
<CardContent className="h-[300px] flex items-center justify-center text-muted-foreground">
<TrendingUp className="w-16 h-16 mr-4" />
错误率趋势图表(集成图表库后显示)
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
{/* Trace详情对话框 */}
<Dialog open={traceDetailOpen} onOpenChange={setTraceDetailOpen}>
<DialogContent className="max-w-6xl max-h-[90vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitBranch className="w-5 h-5" />
Trace详情
<span className="font-mono text-sm text-muted-foreground">{selectedTrace?.traceId}</span>
</DialogTitle>
</DialogHeader>
{selectedTrace && (
<div className="space-y-4">
<div className="flex items-center gap-4 p-3 bg-muted rounded-lg">
<div>
<Label className="text-xs text-muted-foreground">服务</Label>
<p className="font-medium">{selectedTrace.serviceName}</p>
</div>
<Separator orientation="vertical" className="h-8" />
<div>
<Label className="text-xs text-muted-foreground">操作</Label>
<p className="font-medium">{selectedTrace.operationName}</p>
</div>
<Separator orientation="vertical" className="h-8" />
<div>
<Label className="text-xs text-muted-foreground">总耗时</Label>
<p className="font-medium">{formatDuration(selectedTrace.duration)}</p>
</div>
<Separator orientation="vertical" className="h-8" />
<div>
<Label className="text-xs text-muted-foreground">状态</Label>
<p>{getStatusBadge(selectedTrace.status)}</p>
</div>
</div>
<ScrollArea className="h-[500px]">
<div className="border rounded-lg">
<div className="flex items-center px-4 py-2 bg-muted/50 border-b text-sm font-medium">
<span className="flex-1">Span名称</span>
<span className="w-32">时间线</span>
<span className="w-20 text-right">耗时</span>
</div>
{/* Span树渲染 */}
{(selectedTrace as any).spans && buildSpanTree((selectedTrace as any).spans).map(span => (
<SpanTreeNode key={span.spanId} span={span} level={0} totalDuration={selectedTrace.duration} />
))}
</div>
</ScrollArea>
</div>
)}
</DialogContent>
</Dialog>
{/* Span详情对话框 */}
<Dialog open={spanDetailOpen} onOpenChange={setSpanDetailOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Span详情</DialogTitle>
</DialogHeader>
{selectedSpan && (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<Label className="text-muted-foreground">Span ID</Label>
<p className="font-mono text-sm">{selectedSpan.spanId}</p>
</div>
<div>
<Label className="text-muted-foreground">类型</Label>
<p className="capitalize">{selectedSpan.kind}</p>
</div>
<div>
<Label className="text-muted-foreground">服务</Label>
<p className="font-medium">{selectedSpan.serviceName}</p>
</div>
<div>
<Label className="text-muted-foreground">操作</Label>
<p className="font-medium">{selectedSpan.operationName}</p>
</div>
<div>
<Label className="text-muted-foreground">状态</Label>
<p>{getStatusBadge(selectedSpan.status)}</p>
</div>
<div>
<Label className="text-muted-foreground">耗时</Label>
<p className="font-medium">{formatDuration(selectedSpan.duration)}</p>
</div>
</div>
{Object.keys(selectedSpan.tags).length > 0 && (
<div>
<Label className="text-muted-foreground">标签</Label>
<div className="mt-1 p-3 bg-muted rounded-lg">
{Object.entries(selectedSpan.tags).map(([k, v]) => (
<div key={k} className="flex justify-between text-sm py-1">
<span className="text-muted-foreground">{k}</span>
<span className="font-mono">{v}</span>
</div>
))}
</div>
</div>
)}
{selectedSpan.logs?.length > 0 && (
<div>
<Label className="text-muted-foreground">日志</Label>
<div className="mt-1 space-y-2">
{selectedSpan.logs.map((log, idx) => (
<div key={idx} className="p-2 bg-muted rounded text-sm">
<span className="text-muted-foreground">{log.timestamp}</span>
<pre className="mt-1 text-xs">{JSON.stringify(log.fields, null, 2)}</pre>
</div>
))}
</div>
</div>
)}
</div>
)}
</DialogContent>
</Dialog>
</div>
);
};
export default TraceManager;
@@ -0,0 +1,314 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Search,
ExternalLink,
Plus,
Edit2,
Trash2,
Folder,
LayoutGrid,
List,
Star,
Settings
} from 'lucide-react';
import { navigationGroups, navigationLinks } from '@/mocks/data';
interface NavLink {
id: string;
name: string;
url: string;
icon?: string;
description?: string;
groupId: string;
isFavorite?: boolean;
tags?: string[];
}
interface NavGroup {
id: string;
name: string;
icon?: string;
description?: string;
linkCount?: number;
}
export function NavigationPage() {
const [searchQuery, setSearchQuery] = useState('');
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [selectedGroup, setSelectedGroup] = useState<string>('all');
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
const groups = navigationGroups;
const links = navigationLinks;
const filteredLinks = links.filter(link => {
const matchesSearch = link.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
link.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
link.tags?.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesGroup = selectedGroup === 'all' || link.groupId === selectedGroup;
const matchesFavorite = !showFavoritesOnly || link.isFavorite;
return matchesSearch && matchesGroup && matchesFavorite;
});
const groupedLinks = groups.map(group => ({
...group,
links: filteredLinks.filter(link => link.groupId === group.id)
})).filter(group => group.links.length > 0);
const stats = {
totalLinks: links.length,
totalGroups: groups.length,
favorites: links.filter(l => l.isFavorite).length,
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">站内导航</h1>
<p className="text-slate-400 mt-1">管理内部系统和常用链接</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:bg-slate-800">
<Settings className="h-4 w-4 mr-2" />
管理分组
</Button>
<Button className="bg-blue-600 hover:bg-blue-700">
<Plus className="h-4 w-4 mr-2" />
添加链接
</Button>
</div>
</div>
{/* Stats */}
<div className="grid grid-cols-3 gap-4">
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">链接总数</p>
<p className="text-2xl font-bold text-slate-100 mt-1">{stats.totalLinks}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<ExternalLink className="h-5 w-5 text-blue-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">分组数</p>
<p className="text-2xl font-bold text-slate-100 mt-1">{stats.totalGroups}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-purple-500/20 flex items-center justify-center">
<Folder className="h-5 w-5 text-purple-400" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-slate-400 text-sm">收藏</p>
<p className="text-2xl font-bold text-yellow-400 mt-1">{stats.favorites}</p>
</div>
<div className="w-10 h-10 rounded-lg bg-yellow-500/20 flex items-center justify-center">
<Star className="h-5 w-5 text-yellow-400" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-4">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-400" />
<Input
placeholder="搜索链接名称、描述或标签..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-slate-800 border-slate-700 text-slate-100"
/>
</div>
<select
value={selectedGroup}
onChange={(e) => setSelectedGroup(e.target.value)}
className="px-3 py-2 bg-slate-800 border border-slate-700 rounded-md text-slate-100 text-sm"
>
<option value="all">全部分组</option>
{groups.map(group => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
<Button
variant={showFavoritesOnly ? 'default' : 'outline'}
size="sm"
onClick={() => setShowFavoritesOnly(!showFavoritesOnly)}
className={showFavoritesOnly ? 'bg-yellow-600 hover:bg-yellow-700' : 'border-slate-700 text-slate-300'}
>
<Star className="h-4 w-4 mr-1" />
收藏
</Button>
<div className="flex items-center gap-1 border border-slate-700 rounded-md p-1">
<Button
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
size="icon"
className="h-8 w-8"
onClick={() => setViewMode('grid')}
>
<LayoutGrid className="h-4 w-4" />
</Button>
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="icon"
className="h-8 w-8"
onClick={() => setViewMode('list')}
>
<List className="h-4 w-4" />
</Button>
</div>
</div>
{/* Navigation Content */}
<div className="space-y-6">
{selectedGroup === 'all' && !searchQuery && !showFavoritesOnly ? (
// Grouped view
groupedLinks.map(group => (
<div key={group.id}>
<div className="flex items-center gap-2 mb-3">
<Folder className="h-5 w-5 text-blue-400" />
<h2 className="text-lg font-semibold text-slate-100">{group.name}</h2>
<Badge variant="secondary" className="bg-slate-700 text-slate-300">
{group.links.length}
</Badge>
</div>
{viewMode === 'grid' ? (
<div className="grid grid-cols-4 gap-4">
{group.links.map(link => (
<NavLinkCard key={link.id} link={link} />
))}
</div>
) : (
<div className="space-y-2">
{group.links.map(link => (
<NavLinkRow key={link.id} link={link} />
))}
</div>
)}
</div>
))
) : (
// Flat view
viewMode === 'grid' ? (
<div className="grid grid-cols-4 gap-4">
{filteredLinks.map(link => (
<NavLinkCard key={link.id} link={link} />
))}
</div>
) : (
<div className="space-y-2">
{filteredLinks.map(link => (
<NavLinkRow key={link.id} link={link} />
))}
</div>
)
)}
</div>
</div>
);
}
function NavLinkCard({ link }: { link: NavLink }) {
return (
<Card className="bg-slate-800 border-slate-700 hover:border-blue-500/50 transition-colors group">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center text-white font-bold text-lg">
{link.name.slice(0, 2)}
</div>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-slate-100 truncate">{link.name}</h3>
<p className="text-xs text-slate-400 mt-0.5 line-clamp-2">{link.description}</p>
</div>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-yellow-400">
<Star className={`h-4 w-4 ${link.isFavorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400">
<Edit2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-700">
<div className="flex gap-1">
{link.tags?.map(tag => (
<Badge key={tag} variant="secondary" className="bg-slate-700 text-slate-300 text-xs">
{tag}
</Badge>
))}
</div>
<Button variant="ghost" size="sm" className="h-7 px-2 text-blue-400 hover:text-blue-300" asChild>
<a href={link.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3 w-3 mr-1" />
访问
</a>
</Button>
</div>
</CardContent>
</Card>
);
}
function NavLinkRow({ link }: { link: NavLink }) {
return (
<Card className="bg-slate-800 border-slate-700 hover:border-blue-500/50 transition-colors">
<CardContent className="p-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center text-white font-bold text-sm">
{link.name.slice(0, 2)}
</div>
<div>
<h3 className="font-medium text-slate-100">{link.name}</h3>
<p className="text-xs text-slate-400">{link.description}</p>
</div>
</div>
<div className="flex items-center gap-4">
<div className="flex gap-1">
{link.tags?.map(tag => (
<Badge key={tag} variant="secondary" className="bg-slate-700 text-slate-300 text-xs">
{tag}
</Badge>
))}
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-yellow-400">
<Star className={`h-4 w-4 ${link.isFavorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400">
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-blue-400 hover:text-blue-300" asChild>
<a href={link.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-3 w-3 mr-1" />
访问
</a>
</Button>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
+491
View File
@@ -0,0 +1,491 @@
import { useState, useCallback } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Alert, AlertDescription } from '@/components/ui/alert';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Upload,
Download,
FileSpreadsheet,
CheckCircle,
AlertTriangle,
X,
Server,
FileJson,
Copy,
Trash2,
Play,
RotateCw,
} from 'lucide-react';
interface ImportRecord {
id: string;
hostname: string;
ip: string;
os: string;
cpu: string;
memory: string;
disk: string;
region: string;
tags: string;
status: 'pending' | 'valid' | 'invalid' | 'importing' | 'success' | 'error';
message?: string;
}
export function BatchImport() {
const [records, setRecords] = useState<ImportRecord[]>([]);
const [isDragging, setIsDragging] = useState(false);
const [importProgress, setImportProgress] = useState(0);
const [isImporting, setIsImporting] = useState(false);
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
// 模拟文件解析
generateMockRecords();
}, []);
const generateMockRecords = () => {
const mockRecords: ImportRecord[] = [
{
id: '1',
hostname: 'web-server-03',
ip: '192.168.1.111',
os: 'CentOS 7.9',
cpu: '16',
memory: '64',
disk: '500',
region: '华北-北京',
tags: 'web,production',
status: 'valid',
},
{
id: '2',
hostname: 'app-server-03',
ip: '192.168.1.112',
os: 'Ubuntu 22.04 LTS',
cpu: '32',
memory: '128',
disk: '1000',
region: '华东-上海',
tags: 'app,production',
status: 'valid',
},
{
id: '3',
hostname: 'cache-server-02',
ip: '192.168.1.113',
os: 'Ubuntu 20.04 LTS',
cpu: '16',
memory: '64',
disk: '200',
region: '华南-广州',
tags: 'cache,redis',
status: 'valid',
},
{
id: '4',
hostname: '',
ip: '192.168.1.114',
os: 'CentOS 8.5',
cpu: '8',
memory: '32',
disk: '500',
region: '华北-北京',
tags: 'test',
status: 'invalid',
message: '主机名不能为空',
},
{
id: '5',
hostname: 'invalid-server',
ip: 'invalid-ip',
os: 'Ubuntu 22.04 LTS',
cpu: '16',
memory: '64',
disk: '500',
region: '华东-上海',
tags: 'dev',
status: 'invalid',
message: 'IP地址格式不正确',
},
];
setRecords(mockRecords);
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
generateMockRecords();
}
};
const removeRecord = (id: string) => {
setRecords(records.filter((r) => r.id !== id));
};
const clearAll = () => {
setRecords([]);
setImportProgress(0);
};
const startImport = () => {
const validRecords = records.filter((r) => r.status === 'valid');
if (validRecords.length === 0) return;
setIsImporting(true);
setImportProgress(0);
// 模拟导入进度
let progress = 0;
const interval = setInterval(() => {
progress += 10;
setImportProgress(progress);
// 更新记录状态
if (progress % 20 === 0) {
const index = Math.floor(progress / 20) - 1;
setRecords((prev) =>
prev.map((r, i) =>
i === index && r.status === 'valid' ? { ...r, status: 'importing' } : r
)
);
}
if (progress >= 100) {
clearInterval(interval);
setRecords((prev) =>
prev.map((r) => (r.status === 'importing' ? { ...r, status: 'success' } : r))
);
setIsImporting(false);
}
}, 300);
};
const validCount = records.filter((r) => r.status === 'valid').length;
const invalidCount = records.filter((r) => r.status === 'invalid').length;
const successCount = records.filter((r) => r.status === 'success').length;
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">批量导入</h1>
<p className="text-sm text-slate-500 mt-1">批量导入服务器资产</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<Download className="h-4 w-4 mr-2" />
下载模板
</Button>
</div>
</div>
{/* Upload Area */}
<Card
className={cn(
'border-2 border-dashed bg-slate-900/50',
isDragging ? 'border-blue-500 bg-blue-500/5' : 'border-slate-700'
)}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<CardContent className="p-8">
<div className="flex flex-col items-center justify-center text-center">
<div className="w-16 h-16 rounded-full bg-slate-800 flex items-center justify-center mb-4">
<Upload className="h-8 w-8 text-slate-400" />
</div>
<h3 className="text-lg font-medium text-slate-200 mb-2">拖拽文件到此处上传</h3>
<p className="text-sm text-slate-500 mb-4">支持 Excel (.xlsx, .xls) 和 CSV 格式文件</p>
<div className="flex items-center gap-2">
<Input
type="file"
accept=".xlsx,.xls,.csv"
onChange={handleFileUpload}
className="hidden"
id="file-upload"
/>
<label htmlFor="file-upload">
<Button variant="outline" className="border-slate-700 text-slate-300" asChild>
<span>选择文件</span>
</Button>
</label>
</div>
</div>
</CardContent>
</Card>
{/* Import Stats */}
{records.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">总记录数</p>
<p className="text-2xl font-bold text-slate-100">{records.length}</p>
</div>
<div className="p-3 rounded-lg bg-slate-800 text-slate-400">
<FileSpreadsheet className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">有效记录</p>
<p className="text-2xl font-bold text-emerald-400">{validCount}</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<CheckCircle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">无效记录</p>
<p className="text-2xl font-bold text-red-400">{invalidCount}</p>
</div>
<div className="p-3 rounded-lg bg-red-500/10 text-red-400">
<AlertTriangle className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">导入成功</p>
<p className="text-2xl font-bold text-blue-400">{successCount}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Server className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
)}
{/* Records Table */}
{records.length > 0 && (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-4">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-slate-100">数据预览</CardTitle>
<CardDescription className="text-slate-500">
请检查数据是否正确,修复无效记录后导入
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="border-slate-700 text-slate-300 hover:text-slate-100"
onClick={clearAll}
>
<Trash2 className="h-4 w-4 mr-2" />
清空
</Button>
<Button
size="sm"
className="bg-blue-600 hover:bg-blue-700 text-white"
onClick={startImport}
disabled={isImporting || validCount === 0}
>
{isImporting ? (
<RotateCw className="h-4 w-4 mr-2 animate-spin" />
) : (
<Play className="h-4 w-4 mr-2" />
)}
{isImporting ? '导入中...' : '开始导入'}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{isImporting && (
<div className="px-6 py-4 border-b border-slate-800">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-slate-400">导入进度</span>
<span className="text-sm text-slate-300">{importProgress}%</span>
</div>
<Progress value={importProgress} className="h-2 bg-slate-800" />
</div>
)}
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">主机名</TableHead>
<TableHead className="text-slate-400">IP地址</TableHead>
<TableHead className="text-slate-400">操作系统</TableHead>
<TableHead className="text-slate-400">配置</TableHead>
<TableHead className="text-slate-400">区域</TableHead>
<TableHead className="text-slate-400">标签</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{records.map((record) => (
<TableRow
key={record.id}
className={cn(
'border-slate-800',
record.status === 'invalid' && 'bg-red-500/5',
record.status === 'success' && 'bg-emerald-500/5'
)}
>
<TableCell>
{record.status === 'valid' && (
<Badge
variant="outline"
className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10"
>
<CheckCircle className="h-3 w-3 mr-1" />
有效
</Badge>
)}
{record.status === 'invalid' && (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
<AlertTriangle className="h-3 w-3 mr-1" />
无效
</Badge>
)}
{record.status === 'importing' && (
<Badge variant="outline" className="border-blue-500/30 text-blue-400 bg-blue-500/10">
<RotateCw className="h-3 w-3 mr-1 animate-spin" />
导入中
</Badge>
)}
{record.status === 'success' && (
<Badge
variant="outline"
className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10"
>
<CheckCircle className="h-3 w-3 mr-1" />
成功
</Badge>
)}
</TableCell>
<TableCell>
<Input
value={record.hostname}
className={cn(
'h-8 text-sm bg-transparent border-0 border-b rounded-none px-0 focus-visible:ring-0',
record.status === 'invalid' && !record.hostname
? 'border-red-500 text-red-400'
: 'border-slate-700 text-slate-200'
)}
placeholder="主机名"
/>
</TableCell>
<TableCell>
<Input
value={record.ip}
className={cn(
'h-8 text-sm bg-transparent border-0 border-b rounded-none px-0 focus-visible:ring-0',
record.status === 'invalid' && record.message?.includes('IP')
? 'border-red-500 text-red-400'
: 'border-slate-700 text-slate-200'
)}
placeholder="IP地址"
/>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{record.os}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">
{record.cpu}核 / {record.memory}GB / {record.disk}GB
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{record.region}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{record.tags}</span>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400"
onClick={() => removeRecord(record.id)}
>
<X className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
)}
{/* Import Tips */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-slate-400">导入说明</CardTitle>
</CardHeader>
<CardContent>
<ul className="space-y-2 text-sm text-slate-500">
<li className="flex items-start gap-2">
<span className="text-blue-400 mt-0.5">•</span>
<span>支持 Excel (.xlsx, .xls) 和 CSV 格式文件</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-400 mt-0.5">•</span>
<span>文件大小不超过 10MB,单次导入不超过 1000 条记录</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-400 mt-0.5">•</span>
<span>必填字段:主机名、IP地址、操作系统</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-400 mt-0.5">•</span>
<span>IP地址必须是有效的IPv4格式</span>
</li>
<li className="flex items-start gap-2">
<span className="text-blue-400 mt-0.5">•</span>
<span>标签使用逗号分隔,如:web,production,frontend</span>
</li>
</ul>
</CardContent>
</Card>
</div>
);
}
+534
View File
@@ -0,0 +1,534 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Server,
Plus,
Search,
MapPin,
Zap,
Grid3X3,
List,
HardDrive,
Network,
Router,
Shield,
Database,
Monitor,
Edit,
Trash2,
Eye,
ArrowUp,
ArrowDown,
} from 'lucide-react';
import { cabinets, devices } from '@/mocks/data';
import type { Cabinet, Device } from '@/types';
export function CabinetView() {
const [viewMode, setViewMode] = useState<'room' | 'cabinet' | 'list'>('room');
const [selectedRoom, setSelectedRoom] = useState<string>('all');
const [selectedCabinet, setSelectedCabinet] = useState<Cabinet | null>(null);
const [selectedDevice, setSelectedDevice] = useState<Device | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const rooms = Array.from(new Set(cabinets.map((c) => c.room)));
const filteredCabinets = cabinets.filter((cabinet) => {
const matchesRoom = selectedRoom === 'all' || cabinet.room === selectedRoom;
const matchesSearch = cabinet.name.toLowerCase().includes(searchQuery.toLowerCase());
return matchesRoom && matchesSearch;
});
const getCabinetDevices = (cabinetId: string) => {
return devices.filter((d) => d.cabinetId === cabinetId).sort((a, b) => b.startU - a.startU);
};
const getDeviceIcon = (type: Device['type']) => {
const icons = {
server: <Server className="h-4 w-4" />,
switch: <Network className="h-4 w-4" />,
router: <Router className="h-4 w-4" />,
firewall: <Shield className="h-4 w-4" />,
storage: <Database className="h-4 w-4" />,
other: <Monitor className="h-4 w-4" />,
};
return icons[type];
};
const getDeviceColor = (type: Device['type']) => {
const colors = {
server: 'bg-blue-500/20 text-blue-400 border-blue-500/30',
switch: 'bg-purple-500/20 text-purple-400 border-purple-500/30',
router: 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30',
firewall: 'bg-red-500/20 text-red-400 border-red-500/30',
storage: 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30',
other: 'bg-slate-500/20 text-slate-400 border-slate-500/30',
};
return colors[type];
};
const getStatusColor = (status: Cabinet['status']) => {
const colors = {
active: 'border-emerald-500/50 bg-emerald-500/5',
empty: 'border-slate-600 bg-slate-800/30',
disabled: 'border-slate-700 bg-slate-800/50 opacity-50',
};
return colors[status];
};
// 渲染机柜正面视图
const renderCabinetFront = (cabinet: Cabinet) => {
const cabinetDevices = getCabinetDevices(cabinet.id);
const uSlots: (Device | null)[] = new Array(cabinet.totalU).fill(null);
cabinetDevices.forEach((device) => {
for (let i = 0; i < device.sizeU; i++) {
const uIndex = cabinet.totalU - device.startU - i;
if (uIndex >= 0 && uIndex < cabinet.totalU) {
uSlots[uIndex] = device;
}
}
});
return (
<div className="flex gap-4">
{/* U位编号 */}
<div className="flex flex-col text-xs text-slate-500">
{Array.from({ length: cabinet.totalU }, (_, i) => (
<div key={i} className="h-6 flex items-center justify-end pr-2">
{cabinet.totalU - i}U
</div>
))}
</div>
{/* 机柜 */}
<div className="flex-1 border-2 border-slate-700 rounded-lg overflow-hidden bg-slate-900">
{uSlots.map((device, index) => {
const uNumber = cabinet.totalU - index;
if (device) {
const isFirstU = uNumber === device.startU + device.sizeU - 1;
if (!isFirstU) return null;
return (
<div
key={`device-${device.id}`}
className={`border-b border-slate-800 flex items-center px-3 cursor-pointer hover:opacity-80 transition-opacity ${getDeviceColor(device.type)}`}
style={{ height: `${device.sizeU * 24}px` }}
onClick={() => setSelectedDevice(device)}
>
{getDeviceIcon(device.type)}
<span className="ml-2 text-sm font-medium truncate">{device.name}</span>
<span className="ml-auto text-xs opacity-70">{device.ip}</span>
</div>
);
}
return (
<div
key={`empty-${index}`}
className="h-6 border-b border-slate-800/50 hover:bg-slate-800/30 transition-colors"
/>
);
})}
</div>
</div>
);
};
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">机房机柜视图</h1>
<p className="text-sm text-slate-500 mt-1">数据中心机房与机柜设备管理</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<MapPin className="h-4 w-4 mr-2" />
机房管理
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
添加设备
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">机房数</p>
<p className="text-2xl font-bold text-slate-100">{rooms.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<MapPin className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">机柜数</p>
<p className="text-2xl font-bold text-slate-100">{cabinets.length}</p>
</div>
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
<Grid3X3 className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">设备数</p>
<p className="text-2xl font-bold text-emerald-400">{devices.length}</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<Server className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">总功率</p>
<p className="text-2xl font-bold text-amber-400">
{cabinets.reduce((acc, c) => acc + c.powerUsed, 0).toFixed(1)}kW
</p>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
<Zap className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters & View Toggle */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索机柜或设备..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<Select value={selectedRoom} onValueChange={setSelectedRoom}>
<SelectTrigger className="w-40 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectValue placeholder="选择机房" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all">全部机房</SelectItem>
{rooms.map((room) => (
<SelectItem key={room} value={room}>
{room}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-1 bg-slate-800 rounded-lg p-1">
<Button
variant={viewMode === 'room' ? 'secondary' : 'ghost'}
size="sm"
className="h-7"
onClick={() => setViewMode('room')}
>
<MapPin className="h-4 w-4 mr-1" />
机房视图
</Button>
<Button
variant={viewMode === 'cabinet' ? 'secondary' : 'ghost'}
size="sm"
className="h-7"
onClick={() => setViewMode('cabinet')}
>
<Grid3X3 className="h-4 w-4 mr-1" />
机柜视图
</Button>
<Button
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
size="sm"
className="h-7"
onClick={() => setViewMode('list')}
>
<List className="h-4 w-4 mr-1" />
列表视图
</Button>
</div>
</div>
{/* View Content */}
{viewMode === 'room' && (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4">
{filteredCabinets.map((cabinet) => {
const usagePercent = (cabinet.usedU / cabinet.totalU) * 100;
const powerPercent = (cabinet.powerUsed / cabinet.powerCapacity) * 100;
return (
<Card
key={cabinet.id}
className={`bg-slate-900 border-2 cursor-pointer hover:shadow-lg transition-all ${getStatusColor(cabinet.status)}`}
onClick={() => setSelectedCabinet(cabinet)}
>
<CardContent className="p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-slate-200">{cabinet.name}</span>
{cabinet.status === 'active' && (
<div className="w-2 h-2 rounded-full bg-emerald-400 animate-pulse" />
)}
</div>
<div className="space-y-2">
<div>
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span>U位</span>
<span>{cabinet.usedU}/{cabinet.totalU}</span>
</div>
<div className="h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
usagePercent > 80 ? 'bg-red-400' : usagePercent > 60 ? 'bg-amber-400' : 'bg-emerald-400'
}`}
style={{ width: `${usagePercent}%` }}
/>
</div>
</div>
<div>
<div className="flex justify-between text-xs text-slate-500 mb-1">
<span>功率</span>
<span>{cabinet.powerUsed}/{cabinet.powerCapacity}kW</span>
</div>
<div className="h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
powerPercent > 80 ? 'bg-red-400' : powerPercent > 60 ? 'bg-amber-400' : 'bg-emerald-400'
}`}
style={{ width: `${powerPercent}%` }}
/>
</div>
</div>
</div>
<div className="mt-3 flex items-center gap-1">
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
{getCabinetDevices(cabinet.id).length} 设备
</Badge>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{viewMode === 'cabinet' && selectedCabinet && (
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<div>
<CardTitle className="text-slate-100">{selectedCabinet.name}</CardTitle>
<CardDescription className="text-slate-500">
{selectedCabinet.room} · {selectedCabinet.usedU}/{selectedCabinet.totalU}U · {selectedCabinet.powerUsed}/{selectedCabinet.powerCapacity}kW
</CardDescription>
</div>
<Button variant="ghost" size="sm" onClick={() => setSelectedCabinet(null)}>
返回
</Button>
</div>
</CardHeader>
<CardContent>
{renderCabinetFront(selectedCabinet)}
</CardContent>
</Card>
</div>
<div>
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<CardTitle className="text-slate-100 text-base">机柜信息</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<p className="text-xs text-slate-500 mb-1">机柜编号</p>
<p className="text-sm text-slate-200">{selectedCabinet.name}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">所属机房</p>
<p className="text-sm text-slate-200">{selectedCabinet.room}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">位置</p>
<p className="text-sm text-slate-200">第{selectedCabinet.row}排 第{selectedCabinet.column}列</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">状态</p>
<Badge variant="outline" className={
selectedCabinet.status === 'active'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-slate-500/30 text-slate-400 bg-slate-500/10'
}>
{selectedCabinet.status === 'active' ? '使用中' : selectedCabinet.status === 'empty' ? '空闲' : '停用'}
</Badge>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
)}
{viewMode === 'list' && (
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<table className="w-full">
<thead className="bg-slate-800">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">设备名称</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">类型</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">机柜</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">U位</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">IP地址</th>
<th className="px-4 py-3 text-left text-xs font-medium text-slate-400">状态</th>
<th className="px-4 py-3 text-right text-xs font-medium text-slate-400">操作</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{devices.map((device) => (
<tr key={device.id} className="hover:bg-slate-800/50">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
{getDeviceIcon(device.type)}
<span className="text-sm font-medium text-slate-200">{device.name}</span>
</div>
</td>
<td className="px-4 py-3">
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
{device.type}
</Badge>
</td>
<td className="px-4 py-3 text-sm text-slate-300">
{cabinets.find((c) => c.id === device.cabinetId)?.name}
</td>
<td className="px-4 py-3 text-sm text-slate-300">
{device.startU}U - {device.startU + device.sizeU - 1}U ({device.sizeU}U)
</td>
<td className="px-4 py-3 text-sm text-slate-300">{device.ip || '--'}</td>
<td className="px-4 py-3">
<Badge variant="outline" className={
device.status === 'online'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: device.status === 'offline'
? 'border-red-500/30 text-red-400 bg-red-500/10'
: 'border-amber-500/30 text-amber-400 bg-amber-500/10'
}>
{device.status === 'online' ? '在线' : device.status === 'offline' ? '离线' : '维护中'}
</Badge>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Edit className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Device Detail Dialog */}
<Dialog open={!!selectedDevice} onOpenChange={() => setSelectedDevice(null)}>
<DialogContent className="bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
{selectedDevice && getDeviceIcon(selectedDevice.type)}
{selectedDevice?.name}
</DialogTitle>
<DialogDescription className="text-slate-500">
{selectedDevice?.type} · {selectedDevice?.assetNo}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-slate-800/50">
<div>
<p className="text-xs text-slate-500 mb-1">序列号</p>
<p className="text-sm text-slate-200">{selectedDevice?.sn || '--'}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">资产编号</p>
<p className="text-sm text-slate-200">{selectedDevice?.assetNo || '--'}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">IP地址</p>
<p className="text-sm text-slate-200">{selectedDevice?.ip || '--'}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">状态</p>
<Badge variant="outline" className={
selectedDevice?.status === 'online'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: selectedDevice?.status === 'offline'
? 'border-red-500/30 text-red-400 bg-red-500/10'
: 'border-amber-500/30 text-amber-400 bg-amber-500/10'
}>
{selectedDevice?.status === 'online' ? '在线' : selectedDevice?.status === 'offline' ? '离线' : '维护中'}
</Badge>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Edit className="h-4 w-4 mr-2" />
编辑
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Server className="h-4 w-4 mr-2" />
查看详情
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
@@ -0,0 +1,338 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Cloud,
Key,
CheckCircle,
ChevronRight,
ChevronLeft,
Server,
RefreshCw,
Check,
} from 'lucide-react';
import { cloudAccounts } from '@/mocks/data';
interface CloudServerImportProps {
open: boolean;
onClose: () => void;
}
const cloudProviders = [
{ id: 'aws', name: 'AWS', color: 'bg-orange-500', icon: 'AWS' },
{ id: 'aliyun', name: '阿里云', color: 'bg-orange-600', icon: 'AL' },
{ id: 'tencent', name: '腾讯云', color: 'bg-blue-500', icon: 'TX' },
{ id: 'huawei', name: '华为云', color: 'bg-red-500', icon: 'HW' },
{ id: 'azure', name: 'Azure', color: 'bg-blue-600', icon: 'AZ' },
];
// Mock cloud servers data
const mockCloudServers = [
{ id: 'ecs-001', name: 'i-bp1a2b3c4d5e6f7g8', hostname: 'web-server-01', ip: '47.98.123.45', region: 'cn-hangzhou', status: 'running', os: 'CentOS 7.9', spec: '2核4G' },
{ id: 'ecs-002', name: 'i-bp2b3c4d5e6f7g8h9', hostname: 'web-server-02', ip: '47.98.123.46', region: 'cn-hangzhou', status: 'running', os: 'CentOS 7.9', spec: '2核4G' },
{ id: 'ecs-003', name: 'i-bp3c4d5e6f7g8h9i0', hostname: 'app-server-01', ip: '47.98.123.47', region: 'cn-shanghai', status: 'stopped', os: 'Ubuntu 20.04', spec: '4核8G' },
{ id: 'ecs-004', name: 'i-bp4d5e6f7g8h9i0j1', hostname: 'db-server-01', ip: '47.98.123.48', region: 'cn-beijing', status: 'running', os: 'CentOS 8.2', spec: '8核16G' },
{ id: 'ecs-005', name: 'i-bp5e6f7g8h9i0j1k2', hostname: 'cache-server-01', ip: '47.98.123.49', region: 'cn-hangzhou', status: 'running', os: 'CentOS 7.9', spec: '2核8G' },
];
export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
const [step, setStep] = useState(1);
const [selectedProvider, setSelectedProvider] = useState<string>('');
const [selectedAccount, setSelectedAccount] = useState<string>('');
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false);
const filteredAccounts = cloudAccounts.filter(
acc => acc.provider === selectedProvider && acc.status === 'active'
);
const handleNext = () => {
if (step < 3) {
if (step === 2) {
setIsLoading(true);
setTimeout(() => setIsLoading(false), 1000);
}
setStep(step + 1);
}
};
const handleBack = () => {
if (step > 1) {
setStep(step - 1);
}
};
const handleServerToggle = (serverId: string) => {
setSelectedServers(prev =>
prev.includes(serverId)
? prev.filter(id => id !== serverId)
: [...prev, serverId]
);
};
const handleSelectAll = () => {
if (selectedServers.length === mockCloudServers.length) {
setSelectedServers([]);
} else {
setSelectedServers(mockCloudServers.map(s => s.id));
}
};
const handleImport = () => {
// Mock import action
setTimeout(() => {
onClose();
setStep(1);
setSelectedProvider('');
setSelectedAccount('');
setSelectedServers([]);
}, 1500);
};
const handleClose = () => {
onClose();
setStep(1);
setSelectedProvider('');
setSelectedAccount('');
setSelectedServers([]);
};
const steps = [
{ id: 1, title: '公有云', icon: Cloud },
{ id: 2, title: '访问凭据', icon: Key },
{ id: 3, title: '导入确认', icon: CheckCircle },
];
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl bg-slate-900 border-slate-700 text-slate-100">
<DialogHeader>
<DialogTitle className="text-xl">导入云主机</DialogTitle>
</DialogHeader>
{/* Step Indicator */}
<div className="flex items-center justify-center mb-6">
{steps.map((s, index) => (
<div key={s.id} className="flex items-center">
<div className={`flex items-center gap-2 px-4 py-2 rounded-lg ${
step === s.id
? 'bg-blue-600 text-white'
: step > s.id
? 'bg-emerald-600/20 text-emerald-400'
: 'bg-slate-800 text-slate-500'
}`}>
<s.icon className="h-4 w-4" />
<span className="text-sm font-medium">{s.title}</span>
{step > s.id && <Check className="h-4 w-4 ml-1" />}
</div>
{index < steps.length - 1 && (
<ChevronRight className="h-5 w-5 text-slate-600 mx-2" />
)}
</div>
))}
</div>
{/* Step 1: Select Cloud Provider */}
{step === 1 && (
<div className="space-y-4">
<p className="text-slate-400 text-sm">请选择要导入云主机的云平台</p>
<div className="grid grid-cols-3 gap-4">
{cloudProviders.map((provider) => (
<Card
key={provider.id}
className={`cursor-pointer transition-all ${
selectedProvider === provider.id
? 'border-blue-500 ring-2 ring-blue-500/30 bg-slate-800'
: 'border-slate-700 hover:border-slate-600 bg-slate-800/50'
}`}
onClick={() => setSelectedProvider(provider.id)}
>
<CardContent className="p-4 flex items-center gap-3">
<div className={`w-10 h-10 rounded-lg ${provider.color} flex items-center justify-center text-white font-bold text-sm`}>
{provider.icon}
</div>
<span className="font-medium text-slate-200">{provider.name}</span>
</CardContent>
</Card>
))}
</div>
</div>
)}
{/* Step 2: Select Account */}
{step === 2 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-slate-400 text-sm">请选择访问凭据(AccessKey)</p>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300">
<RefreshCw className="h-3 w-3 mr-1" />
刷新
</Button>
</div>
{filteredAccounts.length > 0 ? (
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{filteredAccounts.map((account) => (
<Card
key={account.id}
className={`cursor-pointer transition-all ${
selectedAccount === account.id
? 'border-blue-500 ring-1 ring-blue-500/30 bg-slate-800'
: 'border-slate-700 hover:border-slate-600 bg-slate-800/50'
}`}
onClick={() => setSelectedAccount(account.id)}
>
<CardContent className="p-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<Key className="h-5 w-5 text-blue-400" />
<div>
<p className="font-medium text-slate-200">{account.name}</p>
<p className="text-xs text-slate-500">{account.accessKey.slice(0, 20)}...</p>
</div>
</div>
<div className="text-right">
<Badge className="bg-emerald-500/20 text-emerald-400 border-0">
{account.regionCount} 区域
</Badge>
<p className="text-xs text-slate-500 mt-1">{account.serverCount} 服务器</p>
</div>
</CardContent>
</Card>
))}
</div>
) : (
<div className="text-center py-8 text-slate-500">
<Key className="h-12 w-12 mx-auto mb-3 opacity-50" />
<p>该云平台暂无有效的访问凭据</p>
<Button variant="outline" className="mt-3 border-slate-700 text-slate-300">
添加访问凭据
</Button>
</div>
)}
</div>
)}
{/* Step 3: Import Confirmation */}
{step === 3 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-slate-400 text-sm">请选择要导入的云主机</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300" onClick={handleSelectAll}>
{selectedServers.length === mockCloudServers.length ? '取消全选' : '全选'}
</Button>
<Badge className="bg-blue-500/20 text-blue-400 border-0">
已选 {selectedServers.length} 台
</Badge>
</div>
</div>
{isLoading ? (
<div className="text-center py-12">
<RefreshCw className="h-8 w-8 mx-auto mb-3 animate-spin text-blue-400" />
<p className="text-slate-400">正在获取云主机列表...</p>
</div>
) : (
<div className="border rounded-lg border-slate-700 overflow-hidden max-h-[350px] overflow-y-auto">
<table className="w-full">
<thead className="bg-slate-800 sticky top-0">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400 w-10">
<Checkbox
checked={selectedServers.length === mockCloudServers.length && mockCloudServers.length > 0}
onCheckedChange={handleSelectAll}
/>
</th>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">实例ID</th>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">主机名</th>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">IP地址</th>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">区域</th>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">规格</th>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">状态</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{mockCloudServers.map((server) => (
<tr
key={server.id}
className={`hover:bg-slate-800/50 ${selectedServers.includes(server.id) ? 'bg-blue-500/10' : ''}`}
>
<td className="px-3 py-2">
<Checkbox
checked={selectedServers.includes(server.id)}
onCheckedChange={() => handleServerToggle(server.id)}
/>
</td>
<td className="px-3 py-2 text-sm text-slate-300">{server.name}</td>
<td className="px-3 py-2 text-sm text-slate-200">{server.hostname}</td>
<td className="px-3 py-2 text-sm text-slate-400">{server.ip}</td>
<td className="px-3 py-2 text-sm text-slate-400">{server.region}</td>
<td className="px-3 py-2 text-sm text-slate-400">{server.spec}</td>
<td className="px-3 py-2">
<Badge
variant="outline"
className={server.status === 'running'
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-slate-500/30 text-slate-400 bg-slate-500/10'
}
>
{server.status === 'running' ? '运行中' : '已停止'}
</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
{/* Footer */}
<DialogFooter className="flex justify-between items-center">
<div>
{step > 1 && (
<Button variant="outline" onClick={handleBack} className="border-slate-700 text-slate-300">
<ChevronLeft className="h-4 w-4 mr-1" />
上一步
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleClose} className="border-slate-700 text-slate-300">
取消
</Button>
{step < 3 ? (
<Button
onClick={handleNext}
disabled={(step === 1 && !selectedProvider) || (step === 2 && !selectedAccount)}
className="bg-blue-600 hover:bg-blue-700"
>
下一步
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button
onClick={handleImport}
disabled={selectedServers.length === 0}
className="bg-emerald-600 hover:bg-emerald-700"
>
<Check className="h-4 w-4 mr-1" />
确认导入 ({selectedServers.length})
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,378 @@
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Download,
Upload,
FileSpreadsheet,
Check,
X,
AlertCircle,
ChevronRight,
ChevronLeft,
} from 'lucide-react';
import { regions } from '@/mocks/data';
interface ExcelServerImportProps {
open: boolean;
onClose: () => void;
}
const connectionMethods = [
{ id: 'password', name: '密码认证', description: '使用用户名密码连接' },
{ id: 'key', name: '密钥认证', description: '使用SSH密钥连接' },
];
const mockPreviewData = [
{ row: 2, hostname: 'web-server-01', ip: '192.168.1.101', port: 22, username: 'root', status: 'valid', message: '验证通过' },
{ row: 3, hostname: 'web-server-02', ip: '192.168.1.102', port: 22, username: 'root', status: 'valid', message: '验证通过' },
{ row: 4, hostname: 'db-server-01', ip: '192.168.1.201', port: 22, username: 'admin', status: 'warning', message: 'IP格式正确' },
{ row: 5, hostname: '', ip: '192.168.1.999', port: 22, username: 'root', status: 'error', message: '主机名不能为空' },
{ row: 6, hostname: 'cache-server-01', ip: '192.168.1.103', port: 22, username: 'root', status: 'valid', message: '验证通过' },
];
export function ExcelServerImport({ open, onClose }: ExcelServerImportProps) {
const [step, setStep] = useState(1);
const [selectedRegion, setSelectedRegion] = useState<string>('');
const [connectionMethod, setConnectionMethod] = useState<string>('password');
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const [previewData, setPreviewData] = useState<typeof mockPreviewData>([]);
const handleNext = () => {
if (step === 2 && selectedFile) {
setIsUploading(true);
setTimeout(() => {
setIsUploading(false);
setPreviewData(mockPreviewData);
setStep(step + 1);
}, 1500);
} else {
setStep(step + 1);
}
};
const handleBack = () => {
if (step > 1) {
setStep(step - 1);
}
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files[0]) {
setSelectedFile(e.target.files[0]);
}
};
const handleImport = () => {
setTimeout(() => {
onClose();
setStep(1);
setSelectedRegion('');
setConnectionMethod('password');
setSelectedFile(null);
setPreviewData([]);
}, 1500);
};
const handleClose = () => {
onClose();
setStep(1);
setSelectedRegion('');
setConnectionMethod('password');
setSelectedFile(null);
setPreviewData([]);
};
const validCount = previewData.filter(d => d.status === 'valid').length;
const warningCount = previewData.filter(d => d.status === 'warning').length;
const errorCount = previewData.filter(d => d.status === 'error').length;
const steps = [
{ id: 1, title: '配置' },
{ id: 2, title: '上传' },
{ id: 3, title: '确认' },
];
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl bg-slate-900 border-slate-700 text-slate-100">
<DialogHeader>
<DialogTitle className="text-xl">Excel导入服务器</DialogTitle>
</DialogHeader>
{/* Step Indicator */}
<div className="flex items-center justify-center mb-6">
{steps.map((s, index) => (
<div key={s.id} className="flex items-center">
<div className={`flex items-center justify-center w-8 h-8 rounded-full ${
step === s.id
? 'bg-blue-600 text-white'
: step > s.id
? 'bg-emerald-600 text-white'
: 'bg-slate-800 text-slate-500'
}`}>
{step > s.id ? <Check className="h-4 w-4" /> : s.id}
</div>
<span className={`ml-2 text-sm ${step === s.id ? 'text-slate-200' : 'text-slate-500'}`}>
{s.title}
</span>
{index < steps.length - 1 && (
<div className="w-12 h-0.5 bg-slate-700 mx-3" />
)}
</div>
))}
</div>
{/* Step 1: Configuration */}
{step === 1 && (
<div className="space-y-6">
{/* Download Template */}
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center">
<FileSpreadsheet className="h-5 w-5 text-emerald-400" />
</div>
<div>
<p className="font-medium text-slate-200">主机导入模板</p>
<p className="text-xs text-slate-500">下载Excel模板,填写服务器信息后上传</p>
</div>
</div>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Download className="h-4 w-4 mr-2" />
下载模板
</Button>
</div>
</CardContent>
</Card>
{/* Region Selection */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">选择区域</label>
<select
value={selectedRegion}
onChange={(e) => setSelectedRegion(e.target.value)}
className="w-full px-3 py-2 bg-slate-800 border border-slate-700 rounded-md text-slate-100 text-sm"
>
<option value="">请选择区域</option>
{regions.map((region) => (
<option key={region.id} value={region.id}>
{region.name} ({region.code})
</option>
))}
</select>
</div>
{/* Connection Method */}
<div>
<label className="block text-sm font-medium text-slate-300 mb-2">连接方式</label>
<div className="grid grid-cols-2 gap-4">
{connectionMethods.map((method) => (
<Card
key={method.id}
className={`cursor-pointer transition-all ${
connectionMethod === method.id
? 'border-blue-500 ring-1 ring-blue-500/30 bg-slate-800'
: 'border-slate-700 hover:border-slate-600 bg-slate-800/50'
}`}
onClick={() => setConnectionMethod(method.id)}
>
<CardContent className="p-3">
<p className="font-medium text-slate-200">{method.name}</p>
<p className="text-xs text-slate-500">{method.description}</p>
</CardContent>
</Card>
))}
</div>
</div>
</div>
)}
{/* Step 2: Upload */}
{step === 2 && (
<div className="space-y-6">
<div
className="border-2 border-dashed border-slate-700 rounded-lg p-8 text-center hover:border-blue-500/50 transition-colors"
>
<Upload className="h-12 w-12 mx-auto mb-4 text-slate-500" />
<p className="text-slate-300 mb-2">点击或拖拽文件到此处上传</p>
<p className="text-xs text-slate-500 mb-4">支持 .xlsx, .xls 格式文件</p>
<Input
type="file"
accept=".xlsx,.xls"
onChange={handleFileChange}
className="hidden"
id="excel-upload"
/>
<label htmlFor="excel-upload">
<Button variant="outline" className="border-slate-700 text-slate-300" asChild>
<span>选择文件</span>
</Button>
</label>
</div>
{selectedFile && (
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<FileSpreadsheet className="h-8 w-8 text-emerald-400" />
<div>
<p className="text-sm text-slate-200">{selectedFile.name}</p>
<p className="text-xs text-slate-500">{(selectedFile.size / 1024).toFixed(2)} KB</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="text-slate-400 hover:text-red-400"
onClick={() => setSelectedFile(null)}
>
<X className="h-4 w-4" />
</Button>
</CardContent>
</Card>
)}
{isUploading && (
<div className="text-center py-4">
<div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
<p className="text-slate-400 text-sm">正在解析文件...</p>
</div>
)}
</div>
)}
{/* Step 3: Preview & Confirm */}
{step === 3 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<p className="text-slate-400 text-sm">数据预览</p>
<div className="flex items-center gap-2">
<Badge className="bg-emerald-500/20 text-emerald-400 border-0">
<Check className="h-3 w-3 mr-1" />
{validCount} 有效
</Badge>
{warningCount > 0 && (
<Badge className="bg-amber-500/20 text-amber-400 border-0">
<AlertCircle className="h-3 w-3 mr-1" />
{warningCount} 警告
</Badge>
)}
{errorCount > 0 && (
<Badge className="bg-red-500/20 text-red-400 border-0">
<X className="h-3 w-3 mr-1" />
{errorCount} 错误
</Badge>
)}
</div>
</div>
<div className="border rounded-lg border-slate-700 overflow-hidden max-h-[350px] overflow-y-auto">
<table className="w-full">
<thead className="bg-slate-800 sticky top-0">
<tr>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">行号</th>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">主机名</th>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">IP地址</th>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">端口</th>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">用户名</th>
<th className="px-3 py-2 text-left text-xs font-medium text-slate-400">状态</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{previewData.map((row) => (
<tr key={row.row} className="hover:bg-slate-800/50">
<td className="px-3 py-2 text-sm text-slate-500">{row.row}</td>
<td className="px-3 py-2 text-sm text-slate-200">{row.hostname || '-'}</td>
<td className="px-3 py-2 text-sm text-slate-300">{row.ip}</td>
<td className="px-3 py-2 text-sm text-slate-300">{row.port}</td>
<td className="px-3 py-2 text-sm text-slate-300">{row.username}</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1">
{row.status === 'valid' && (
<Badge className="bg-emerald-500/20 text-emerald-400 border-0 text-xs">
<Check className="h-3 w-3 mr-1" />
{row.message}
</Badge>
)}
{row.status === 'warning' && (
<Badge className="bg-amber-500/20 text-amber-400 border-0 text-xs">
<AlertCircle className="h-3 w-3 mr-1" />
{row.message}
</Badge>
)}
{row.status === 'error' && (
<Badge className="bg-red-500/20 text-red-400 border-0 text-xs">
<X className="h-3 w-3 mr-1" />
{row.message}
</Badge>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{errorCount > 0 && (
<div className="p-3 rounded-lg bg-red-500/10 border border-red-500/30">
<div className="flex items-center gap-2 text-red-400">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">存在 {errorCount} 条错误数据,请修正后重新上传</span>
</div>
</div>
)}
</div>
)}
{/* Footer */}
<DialogFooter className="flex justify-between items-center">
<div>
{step > 1 && (
<Button variant="outline" onClick={handleBack} className="border-slate-700 text-slate-300">
<ChevronLeft className="h-4 w-4 mr-1" />
上一步
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleClose} className="border-slate-700 text-slate-300">
取消
</Button>
{step < 3 ? (
<Button
onClick={handleNext}
disabled={(step === 1 && !selectedRegion) || (step === 2 && !selectedFile)}
className="bg-blue-600 hover:bg-blue-700"
>
下一步
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
) : (
<Button
onClick={handleImport}
disabled={errorCount > 0 || validCount === 0}
className="bg-emerald-600 hover:bg-emerald-700"
>
<Check className="h-4 w-4 mr-1" />
确认导入 ({validCount})
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,465 @@
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Layers,
Plus,
Search,
Database,
Network,
Server,
HardDrive,
Settings,
Edit,
Trash2,
Eye,
Link,
Copy,
ArrowRight,
GitBranch,
} from 'lucide-react';
import { resourceModels, modelInstances } from '@/mocks/data';
import type { ResourceModel, ModelInstance } from '@/types';
export function ResourceModelManager() {
const [activeTab, setActiveTab] = useState('models');
const [searchQuery, setSearchQuery] = useState('');
const [selectedModel, setSelectedModel] = useState<ResourceModel | null>(null);
const [selectedInstance, setSelectedInstance] = useState<ModelInstance | null>(null);
const filteredModels = resourceModels.filter(
(model) =>
model.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
model.code.toLowerCase().includes(searchQuery.toLowerCase())
);
const getModelTypeBadge = (type: ResourceModel['type']) => {
const configs = {
normal: { label: '普通', className: 'border-blue-500/30 text-blue-400 bg-blue-500/10' },
parent: { label: '父模型', className: 'border-purple-500/30 text-purple-400 bg-purple-500/10' },
child: { label: '子模型', className: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10' },
};
const config = configs[type];
return (
<Badge variant="outline" className={config.className}>
{config.label}
</Badge>
);
};
const getAttributeTypeIcon = (type: string) => {
switch (type) {
case 'string':
return <span className="text-xs text-slate-400">ABC</span>;
case 'number':
return <span className="text-xs text-slate-400">123</span>;
case 'boolean':
return <span className="text-xs text-slate-400">T/F</span>;
case 'date':
return <span className="text-xs text-slate-400">📅</span>;
case 'enum':
return <span className="text-xs text-slate-400">▼</span>;
default:
return null;
}
};
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">资源模型管理</h1>
<p className="text-sm text-slate-500 mt-1">CMDB资源模型定义与实例管理</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<Link className="h-4 w-4 mr-2" />
关系管理
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
新建模型
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">模型总数</p>
<p className="text-2xl font-bold text-slate-100">{resourceModels.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Layers className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">实例总数</p>
<p className="text-2xl font-bold text-emerald-400">
{resourceModels.reduce((acc, m) => acc + m.instanceCount, 0)}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<Database className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">父模型</p>
<p className="text-2xl font-bold text-purple-400">
{resourceModels.filter((m) => m.type === 'parent').length}
</p>
</div>
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
<GitBranch className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">属性总数</p>
<p className="text-2xl font-bold text-blue-400">
{resourceModels.reduce((acc, m) => acc + m.attributes.length, 0)}
</p>
</div>
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
<Settings className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters */}
<div className="flex items-center gap-3">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索模型..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="models" className="data-[state=active]:bg-slate-700">
<Layers className="h-4 w-4 mr-2" />
模型列表
</TabsTrigger>
<TabsTrigger value="instances" className="data-[state=active]:bg-slate-700">
<Database className="h-4 w-4 mr-2" />
实例管理
</TabsTrigger>
<TabsTrigger value="relations" className="data-[state=active]:bg-slate-700">
<Link className="h-4 w-4 mr-2" />
关系视图
</TabsTrigger>
</TabsList>
<TabsContent value="models" className="mt-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{filteredModels.map((model) => (
<Card key={model.id} className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-500/10 flex items-center justify-center">
<Layers className="h-5 w-5 text-blue-400" />
</div>
<div>
<CardTitle className="text-base text-slate-100">{model.name}</CardTitle>
<CardDescription className="text-slate-500">{model.code}</CardDescription>
</div>
</div>
<div className="flex items-center gap-2">
{getModelTypeBadge(model.type)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => setSelectedModel(model)}
>
<Eye className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-400 mb-3">{model.description}</p>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">属性数量</span>
<span className="text-slate-300">{model.attributes.length}</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-slate-500">实例数量</span>
<span className="text-slate-300">{model.instanceCount}</span>
</div>
</div>
<div className="mt-4 pt-4 border-t border-slate-800">
<p className="text-xs text-slate-500 mb-2">属性预览</p>
<div className="flex flex-wrap gap-1">
{model.attributes.slice(0, 5).map((attr) => (
<Badge key={attr.id} variant="outline" className="border-slate-700 text-slate-400 text-xs">
{getAttributeTypeIcon(attr.type)}
<span className="ml-1">{attr.name}</span>
{attr.required && <span className="text-red-400 ml-0.5">*</span>}
</Badge>
))}
{model.attributes.length > 5 && (
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
+{model.attributes.length - 5}
</Badge>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="instances" className="mt-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">实例名称</TableHead>
<TableHead className="text-slate-400">所属模型</TableHead>
<TableHead className="text-slate-400">关键属性</TableHead>
<TableHead className="text-slate-400">关系数</TableHead>
<TableHead className="text-slate-400">更新时间</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{modelInstances.map((instance) => (
<TableRow key={instance.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<span className="text-sm font-medium text-slate-200">{instance.name}</span>
</TableCell>
<TableCell>
<Badge variant="outline" className="border-slate-700 text-slate-400">
{resourceModels.find((m) => m.id === instance.modelId)?.name}
</Badge>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{Object.entries(instance.data).slice(0, 3).map(([key, value]) => (
<code key={key} className="text-xs text-slate-400 bg-slate-800 px-1.5 py-0.5 rounded">
{key}: {String(value).slice(0, 15)}
</code>
))}
</div>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{instance.relations.length}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{new Date(instance.updatedAt).toLocaleString()}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => setSelectedInstance(instance)}
>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Edit className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="relations" className="mt-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-8">
<div className="text-center">
<Link className="h-12 w-12 mx-auto text-slate-500 mb-4" />
<h3 className="text-lg font-medium text-slate-300 mb-2">关系视图</h3>
<p className="text-sm text-slate-500 mb-4">可视化展示资源实例之间的关系</p>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
创建关系
</Button>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Model Detail Dialog */}
<Dialog open={!!selectedModel} onOpenChange={() => setSelectedModel(null)}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<Layers className="h-5 w-5 text-blue-400" />
{selectedModel?.name}
</DialogTitle>
<DialogDescription className="text-slate-500">
{selectedModel?.code} · {selectedModel?.description}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-300 mb-2">属性定义</h4>
<div className="border rounded-lg border-slate-700 overflow-hidden">
<table className="w-full">
<thead className="bg-slate-800">
<tr>
<th className="px-4 py-2 text-left text-xs font-medium text-slate-400">属性名</th>
<th className="px-4 py-2 text-left text-xs font-medium text-slate-400">字段名</th>
<th className="px-4 py-2 text-left text-xs font-medium text-slate-400">类型</th>
<th className="px-4 py-2 text-left text-xs font-medium text-slate-400">必填</th>
<th className="px-4 py-2 text-left text-xs font-medium text-slate-400">唯一</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-800">
{selectedModel?.attributes.map((attr) => (
<tr key={attr.id} className="hover:bg-slate-800/50">
<td className="px-4 py-2 text-sm text-slate-200">{attr.name}</td>
<td className="px-4 py-2 text-sm text-slate-400">{attr.code}</td>
<td className="px-4 py-2">
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
{attr.type}
</Badge>
</td>
<td className="px-4 py-2 text-sm text-slate-400">{attr.required ? '是' : '否'}</td>
<td className="px-4 py-2 text-sm text-slate-400">{attr.unique ? '是' : '否'}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Copy className="h-4 w-4 mr-2" />
复制模型
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Edit className="h-4 w-4 mr-2" />
编辑模型
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Instance Detail Dialog */}
<Dialog open={!!selectedInstance} onOpenChange={() => setSelectedInstance(null)}>
<DialogContent className="max-w-2xl bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<Database className="h-5 w-5 text-emerald-400" />
{selectedInstance?.name}
</DialogTitle>
<DialogDescription className="text-slate-500">
模型: {resourceModels.find((m) => m.id === selectedInstance?.modelId)?.name}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<h4 className="text-sm font-medium text-slate-300 mb-2">属性数据</h4>
<div className="grid grid-cols-2 gap-3 p-4 rounded-lg bg-slate-800/50">
{selectedInstance && Object.entries(selectedInstance.data).map(([key, value]) => (
<div key={key}>
<p className="text-xs text-slate-500 mb-1">{key}</p>
<p className="text-sm text-slate-200">{String(value)}</p>
</div>
))}
</div>
</div>
{selectedInstance && selectedInstance.relations.length > 0 && (
<div>
<h4 className="text-sm font-medium text-slate-300 mb-2">关联关系</h4>
<div className="space-y-2">
{selectedInstance.relations.map((rel, index) => (
<div key={index} className="flex items-center gap-2 p-2 rounded-lg bg-slate-800/50">
<span className="text-sm text-slate-300">{rel.relationType}</span>
<ArrowRight className="h-4 w-4 text-slate-500" />
<Badge variant="outline" className="border-slate-700 text-slate-400">
{rel.targetInstanceId}
</Badge>
</div>
))}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Link className="h-4 w-4 mr-2" />
管理关系
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Edit className="h-4 w-4 mr-2" />
编辑实例
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+467
View File
@@ -0,0 +1,467 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Search,
Plus,
MoreHorizontal,
Terminal,
Power,
RotateCw,
Trash2,
Edit,
Eye,
Download,
Upload,
Filter,
Server,
Cpu,
HardDrive,
Activity,
CheckSquare,
Square,
Cloud,
FileSpreadsheet,
} from 'lucide-react';
import { servers } from '@/mocks/data';
import type { Server as ServerType } from '@/types';
import { CloudServerImport } from './CloudServerImport';
import { ExcelServerImport } from './ExcelServerImport';
export function ServerManager() {
const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [regionFilter, setRegionFilter] = useState<string>('all');
const [cloudImportOpen, setCloudImportOpen] = useState(false);
const [excelImportOpen, setExcelImportOpen] = useState(false);
const filteredServers = servers.filter((server) => {
const matchesSearch =
server.hostname.toLowerCase().includes(searchQuery.toLowerCase()) ||
server.ip.includes(searchQuery);
const matchesStatus = statusFilter === 'all' || server.status === statusFilter;
const matchesRegion = regionFilter === 'all' || server.region === regionFilter;
return matchesSearch && matchesStatus && matchesRegion;
});
const toggleSelectAll = () => {
if (selectedServers.length === filteredServers.length) {
setSelectedServers([]);
} else {
setSelectedServers(filteredServers.map((s) => s.id));
}
};
const toggleSelect = (id: string) => {
if (selectedServers.includes(id)) {
setSelectedServers(selectedServers.filter((s) => s !== id));
} else {
setSelectedServers([...selectedServers, id]);
}
};
const getStatusBadge = (status: ServerType['status']) => {
switch (status) {
case 'running':
return (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-400 mr-1.5 animate-pulse" />
运行中
</Badge>
);
case 'stopped':
return (
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
已停止
</Badge>
);
case 'maintenance':
return (
<Badge variant="outline" className="border-amber-500/30 text-amber-400 bg-amber-500/10">
<div className="w-1.5 h-1.5 rounded-full bg-amber-400 mr-1.5" />
维护中
</Badge>
);
case 'offline':
return (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
离线
</Badge>
);
default:
return null;
}
};
const regions = Array.from(new Set(servers.map((s) => s.region)));
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">服务器管理</h1>
<p className="text-sm text-slate-500 mt-1">管理云平台服务器资产</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<Download className="h-4 w-4 mr-2" />
导出
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<Upload className="h-4 w-4 mr-2" />
批量导入
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="bg-slate-800 border-slate-700 text-slate-100">
<DropdownMenuLabel>选择导入方式</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem
className="hover:bg-slate-700 cursor-pointer"
onClick={() => setCloudImportOpen(true)}
>
<Cloud className="h-4 w-4 mr-2 text-blue-400" />
云主机导入
<span className="ml-2 text-xs text-slate-500">从云平台同步</span>
</DropdownMenuItem>
<DropdownMenuItem
className="hover:bg-slate-700 cursor-pointer"
onClick={() => setExcelImportOpen(true)}
>
<FileSpreadsheet className="h-4 w-4 mr-2 text-emerald-400" />
Excel导入
<span className="ml-2 text-xs text-slate-500">批量导入服务器</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
添加服务器
</Button>
</div>
</div>
{/* Stats Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">服务器总数</p>
<p className="text-2xl font-bold text-slate-100">{servers.length}</p>
</div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Server className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">运行中</p>
<p className="text-2xl font-bold text-emerald-400">
{servers.filter((s) => s.status === 'running').length}
</p>
</div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<Activity className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">维护中</p>
<p className="text-2xl font-bold text-amber-400">
{servers.filter((s) => s.status === 'maintenance').length}
</p>
</div>
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
<RotateCw className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">已停止</p>
<p className="text-2xl font-bold text-slate-400">
{servers.filter((s) => s.status === 'stopped').length}
</p>
</div>
<div className="p-3 rounded-lg bg-slate-500/10 text-slate-400">
<Power className="h-5 w-5" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Filters & Table */}
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-4">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-3 flex-wrap">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索主机名或IP..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectValue placeholder="状态" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all">全部状态</SelectItem>
<SelectItem value="running">运行中</SelectItem>
<SelectItem value="stopped">已停止</SelectItem>
<SelectItem value="maintenance">维护中</SelectItem>
<SelectItem value="offline">离线</SelectItem>
</SelectContent>
</Select>
<Select value={regionFilter} onValueChange={setRegionFilter}>
<SelectTrigger className="w-32 h-9 bg-slate-800/50 border-slate-700 text-slate-200">
<SelectValue placeholder="区域" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="all">全部区域</SelectItem>
{regions.map((region) => (
<SelectItem key={region} value={region}>
{region}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedServers.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-sm text-slate-400">已选择 {selectedServers.length} 项</span>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300">
<Terminal className="h-4 w-4 mr-2" />
批量命令
</Button>
<Button variant="outline" size="sm" className="border-slate-700 text-slate-300">
<RotateCw className="h-4 w-4 mr-2" />
重启
</Button>
</div>
)}
</div>
</CardHeader>
<CardContent className="p-0">
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="w-12">
<Checkbox
checked={
selectedServers.length === filteredServers.length && filteredServers.length > 0
}
onCheckedChange={toggleSelectAll}
className="border-slate-600"
/>
</TableHead>
<TableHead className="text-slate-400">主机名</TableHead>
<TableHead className="text-slate-400">IP地址</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">配置</TableHead>
<TableHead className="text-slate-400">使用率</TableHead>
<TableHead className="text-slate-400">区域</TableHead>
<TableHead className="text-slate-400">标签</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredServers.map((server) => (
<TableRow key={server.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<Checkbox
checked={selectedServers.includes(server.id)}
onCheckedChange={() => toggleSelect(server.id)}
className="border-slate-600"
/>
</TableCell>
<TableCell>
<div className="flex items-center gap-2">
<Server className="h-4 w-4 text-slate-500" />
<div>
<p className="font-medium text-slate-200">{server.hostname}</p>
<p className="text-xs text-slate-500">{server.os}</p>
</div>
</div>
</TableCell>
<TableCell>
<code className="text-sm text-slate-300 bg-slate-800 px-2 py-0.5 rounded">
{server.ip}
</code>
</TableCell>
<TableCell>{getStatusBadge(server.status)}</TableCell>
<TableCell>
<div className="text-sm text-slate-300">
<span className="text-slate-500">{server.cpu}核</span>
<span className="mx-1 text-slate-600">/</span>
<span className="text-slate-500">{server.memory}GB</span>
<span className="mx-1 text-slate-600">/</span>
<span className="text-slate-500">{server.disk}GB</span>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-8">CPU</span>
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full',
server.cpuUsage > 80 ? 'bg-red-400' : server.cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)}
style={{ width: `${server.cpuUsage}%` }}
/>
</div>
<span className="text-xs text-slate-400">{server.cpuUsage.toFixed(1)}%</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-8">内存</span>
<div className="w-16 h-1.5 bg-slate-800 rounded-full overflow-hidden">
<div
className={cn(
'h-full rounded-full',
server.memoryUsage > 80 ? 'bg-red-400' : server.memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)}
style={{ width: `${server.memoryUsage}%` }}
/>
</div>
<span className="text-xs text-slate-400">{server.memoryUsage.toFixed(1)}%</span>
</div>
</div>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{server.region}</span>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{server.tags.slice(0, 3).map((tag) => (
<Badge
key={tag}
variant="outline"
className="text-xs border-slate-700 text-slate-400 bg-slate-800/50"
>
{tag}
</Badge>
))}
{server.tags.length > 3 && (
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
+{server.tags.length - 3}
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Terminal className="h-4 w-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
<DropdownMenuLabel className="text-slate-300">操作</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Eye className="h-4 w-4 mr-2" />
查看详情
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Edit className="h-4 w-4 mr-2" />
编辑
</DropdownMenuItem>
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<Terminal className="h-4 w-4 mr-2" />
WebShell
</DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-slate-300 focus:bg-slate-700 focus:text-slate-100">
<RotateCw className="h-4 w-4 mr-2" />
重启
</DropdownMenuItem>
<DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400">
<Trash2 className="h-4 w-4 mr-2" />
删除
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Import Dialogs */}
<CloudServerImport
open={cloudImportOpen}
onClose={() => setCloudImportOpen(false)}
/>
<ExcelServerImport
open={excelImportOpen}
onClose={() => setExcelImportOpen(false)}
/>
</div>
);
}
+365
View File
@@ -0,0 +1,365 @@
import { useState, useRef, useEffect } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from '@/components/ui/dialog';
import {
Terminal,
Plus,
X,
Server,
Play,
Pause,
Square,
Download,
Upload,
Copy,
Settings,
Monitor,
RefreshCw,
Maximize2,
Minimize2,
} from 'lucide-react';
import { servers } from '@/mocks/data';
interface TerminalSession {
id: string;
serverId: string;
serverName: string;
serverIp: string;
connected: boolean;
logs: string[];
}
export function WebShell() {
const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeSession, setActiveSession] = useState<string | null>(null);
const [selectedServer, setSelectedServer] = useState<string>('');
const [command, setCommand] = useState('');
const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRef = useRef<HTMLDivElement>(null);
const runningServers = servers.filter((s) => s.status === 'running');
const createSession = () => {
if (!selectedServer) return;
const server = servers.find((s) => s.id === selectedServer);
if (!server) return;
const newSession: TerminalSession = {
id: `session-${Date.now()}`,
serverId: server.id,
serverName: server.hostname,
serverIp: server.ip,
connected: true,
logs: [
`Connecting to ${server.hostname} (${server.ip})...`,
`Connection established.`,
`Last login: ${new Date().toLocaleString()} from 192.168.1.100`,
`[root@${server.hostname} ~]# `,
],
};
setSessions([...sessions, newSession]);
setActiveSession(newSession.id);
setSelectedServer('');
};
const closeSession = (sessionId: string) => {
const newSessions = sessions.filter((s) => s.id !== sessionId);
setSessions(newSessions);
if (activeSession === sessionId) {
setActiveSession(newSessions.length > 0 ? newSessions[0].id : null);
}
};
const executeCommand = () => {
if (!command.trim() || !activeSession) return;
const session = sessions.find((s) => s.id === activeSession);
if (!session) return;
const newLogs = [...session.logs, command];
// 模拟命令执行结果
const mockResponses: Record<string, string> = {
ls: 'total 32\ndrwxr-xr-x 5 root root 4096 Mar 30 10:00 .\ndrwxr-xr-x 18 root root 4096 Mar 30 09:00 ..\n-rw-r--r-- 1 root root 220 Mar 30 08:00 .bash_logout\n-rw-r--r-- 1 root root 3771 Mar 30 08:00 .bashrc\n-rw-r--r-- 1 root root 807 Mar 30 08:00 .profile\ndrwxr-xr-x 2 root root 4096 Mar 30 09:00 scripts\ndrwxr-xr-x 3 root root 4096 Mar 30 09:00 logs',
pwd: '/root',
'uname -a': 'Linux web-server-01 5.4.210-1.el7.elrepo.x86_64 #1 SMP x86_64 x86_64 x86_64 GNU/Linux',
'df -h': 'Filesystem Size Used Avail Use% Mounted on\n/dev/sda1 500G 180G 320G 36% /\n/dev/sdb1 1.0T 450G 550G 45% /data',
'free -m': ' total used free shared buff/cache available\nMem: 65536 42000 8000 1024 15536 21000\nSwap: 8192 0 8192',
'ps aux': 'USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND\nroot 1 0.0 0.1 18532 3200 ? Ss Mar30 0:03 /sbin/init\nroot 512 0.1 0.5 45200 12800 ? Ss Mar30 2:15 nginx: master process\nroot 1024 0.5 2.0 256000 51200 ? Sl Mar30 15:30 java -jar app.jar',
top: 'top - 10:30:00 up 30 days, 12:00, 1 user, load average: 0.52, 0.58, 0.59\nTasks: 156 total, 1 running, 155 sleeping, 0 stopped, 0 zombie\n%Cpu(s): 25.0 us, 5.0 sy, 0.0 ni, 68.0 id, 0.0 wa, 0.0 hi, 2.0 si, 0.0 st',
};
const cmd = command.trim().split(' ')[0];
const response = mockResponses[command.trim()] || mockResponses[cmd] || `${cmd}: command executed successfully`;
newLogs.push(response);
newLogs.push(`[${session.serverName} ~]# `);
setSessions(
sessions.map((s) => (s.id === activeSession ? { ...s, logs: newLogs } : s))
);
setCommand('');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
executeCommand();
}
};
// 自动滚动到底部
useEffect(() => {
if (terminalRef.current) {
terminalRef.current.scrollTop = terminalRef.current.scrollHeight;
}
}, [sessions, activeSession]);
const activeSessionData = sessions.find((s) => s.id === activeSession);
return (
<div className={cn('space-y-4', isFullscreen && 'fixed inset-0 z-50 bg-slate-950 p-4')}>
{/* Page Header */}
{!isFullscreen && (
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">WebShell</h1>
<p className="text-sm text-slate-500 mt-1">远程服务器终端管理</p>
</div>
<div className="flex items-center gap-2">
<Dialog>
<DialogTrigger asChild>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
新建连接
</Button>
</DialogTrigger>
<DialogContent className="bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100">新建终端连接</DialogTitle>
<DialogDescription className="text-slate-500">
选择要连接的服务器
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300">选择服务器</label>
<Select value={selectedServer} onValueChange={setSelectedServer}>
<SelectTrigger className="bg-slate-800 border-slate-700 text-slate-200">
<SelectValue placeholder="选择服务器" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{runningServers.map((server) => (
<SelectItem key={server.id} value={server.id}>
<div className="flex items-center gap-2">
<Server className="h-4 w-4" />
<span>{server.hostname}</span>
<span className="text-slate-500">({server.ip})</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
onClick={createSession}
disabled={!selectedServer}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<Terminal className="h-4 w-4 mr-2" />
连接
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
)}
{/* Terminal Container */}
<Card className="bg-slate-900 border-slate-800 flex-1">
<CardHeader className="pb-2 border-b border-slate-800">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{sessions.length > 0 ? (
<div className="flex items-center gap-1">
{sessions.map((session) => (
<button
key={session.id}
onClick={() => setActiveSession(session.id)}
className={cn(
'flex items-center gap-2 px-3 py-1.5 text-sm rounded-md transition-colors',
activeSession === session.id
? 'bg-slate-800 text-slate-100'
: 'text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
)}
>
<div
className={cn(
'w-2 h-2 rounded-full',
session.connected ? 'bg-emerald-400' : 'bg-red-400'
)}
/>
<span className="max-w-[120px] truncate">{session.serverName}</span>
<button
onClick={(e) => {
e.stopPropagation();
closeSession(session.id);
}}
className="ml-1 p-0.5 rounded hover:bg-slate-700"
>
<X className="h-3 w-3" />
</button>
</button>
))}
</div>
) : (
<span className="text-sm text-slate-500">暂无连接</span>
)}
</div>
<div className="flex items-center gap-1">
{activeSessionData && (
<>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Copy className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Download className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Upload className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Settings className="h-4 w-4" />
</Button>
</>
)}
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => setIsFullscreen(!isFullscreen)}
>
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</Button>
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{activeSessionData ? (
<div className="flex flex-col" style={{ height: isFullscreen ? 'calc(100vh - 140px)' : '500px' }}>
{/* Terminal Output */}
<div
ref={terminalRef}
className="flex-1 p-4 font-mono text-sm bg-slate-950 overflow-auto"
>
{activeSessionData.logs.map((log, index) => (
<div key={index} className="whitespace-pre-wrap text-slate-300 leading-relaxed">
{log.startsWith('[') && log.endsWith(']# ') ? (
<span>
<span className="text-emerald-400">{log.split('#')[0]}#</span>
<span className="text-slate-100">{log.split('#')[1]}</span>
</span>
) : log.startsWith('total') || log.startsWith('drwx') || log.startsWith('-rw') ? (
<span className="text-slate-400">{log}</span>
) : log.startsWith('Filesystem') || log.startsWith('/dev') ? (
<span className="text-slate-400">{log}</span>
) : log.startsWith('USER') || log.startsWith('root') ? (
<span className="text-slate-400">{log}</span>
) : log.startsWith('Connecting') || log.startsWith('Connection') || log.startsWith('Last login') ? (
<span className="text-slate-500">{log}</span>
) : (
<span className="text-slate-300">{log}</span>
)}
</div>
))}
</div>
{/* Command Input */}
<div className="flex items-center gap-2 p-3 border-t border-slate-800 bg-slate-900">
<span className="text-emerald-400 font-mono text-sm whitespace-nowrap">
[{activeSessionData.serverName} ~]#
</span>
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入命令..."
className="flex-1 bg-transparent border-0 text-slate-200 font-mono text-sm placeholder:text-slate-600 focus-visible:ring-0 focus-visible:ring-offset-0"
autoFocus
/>
<Button
size="sm"
onClick={executeCommand}
className="bg-blue-600 hover:bg-blue-700 text-white"
>
<Play className="h-4 w-4" />
</Button>
</div>
</div>
) : (
<div
className="flex flex-col items-center justify-center text-slate-500"
style={{ height: isFullscreen ? 'calc(100vh - 140px)' : '500px' }}
>
<Terminal className="h-16 w-16 mb-4 opacity-50" />
<p className="text-lg font-medium">暂无活动连接</p>
<p className="text-sm mt-1">点击"新建连接"开始远程会话</p>
</div>
)}
</CardContent>
</Card>
{/* Quick Commands */}
{activeSessionData && (
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<CardTitle className="text-sm text-slate-400">快捷命令</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{['ls -la', 'pwd', 'df -h', 'free -m', 'ps aux', 'top', 'netstat -tlnp', 'systemctl status'].map(
(cmd) => (
<Button
key={cmd}
variant="outline"
size="sm"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={() => {
setCommand(cmd);
setTimeout(executeCommand, 100);
}}
>
{cmd}
</Button>
)
)}
</div>
</CardContent>
</Card>
)}
</div>
);
}
+570
View File
@@ -0,0 +1,570 @@
import { useState } from 'react';
import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Switch } from '@/components/ui/switch';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Settings,
Plus,
Search,
Users,
Shield,
FileCode,
User,
Mail,
Phone,
CheckCircle,
XCircle,
Edit,
Trash2,
MoreHorizontal,
Key,
Lock,
Eye,
History,
LogOut,
RefreshCw,
} from 'lucide-react';
import { users, roles, operationLogs } from '@/mocks/data';
import type { User as UserType, Role, OperationLog } from '@/types';
export function SystemManager() {
const [activeTab, setActiveTab] = useState('users');
const [searchQuery, setSearchQuery] = useState('');
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
const filteredUsers = users.filter(
(u) =>
u.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.nickname.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.email.toLowerCase().includes(searchQuery.toLowerCase())
);
const filteredLogs = operationLogs.filter(
(log) =>
log.username.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.module.toLowerCase().includes(searchQuery.toLowerCase()) ||
log.action.toLowerCase().includes(searchQuery.toLowerCase())
);
const getUserStatusBadge = (status: UserType['status']) => {
return status === 'active' ? (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<CheckCircle className="h-3 w-3 mr-1" />
正常
</Badge>
) : (
<Badge variant="outline" className="border-slate-500/30 text-slate-400 bg-slate-500/10">
<XCircle className="h-3 w-3 mr-1" />
禁用
</Badge>
);
};
const getLogResultBadge = (result: OperationLog['result']) => {
return result === 'success' ? (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
成功
</Badge>
) : (
<Badge variant="outline" className="border-red-500/30 text-red-400 bg-red-500/10">
失败
</Badge>
);
};
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-100">系统管理</h1>
<p className="text-sm text-slate-500 mt-1">用户、权限与系统配置</p>
</div>
</div>
{/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-slate-800 border border-slate-700">
<TabsTrigger value="users" className="data-[state=active]:bg-slate-700">
<Users className="h-4 w-4 mr-2" />
用户管理
</TabsTrigger>
<TabsTrigger value="roles" className="data-[state=active]:bg-slate-700">
<Shield className="h-4 w-4 mr-2" />
角色权限
</TabsTrigger>
<TabsTrigger value="logs" className="data-[state=active]:bg-slate-700">
<FileCode className="h-4 w-4 mr-2" />
操作审计
</TabsTrigger>
<TabsTrigger value="settings" className="data-[state=active]:bg-slate-700">
<Settings className="h-4 w-4 mr-2" />
系统设置
</TabsTrigger>
</TabsList>
<TabsContent value="users" className="mt-4 space-y-4">
{/* Filters */}
<div className="flex items-center justify-between">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索用户..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
新建用户
</Button>
</div>
{/* Users Table */}
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">用户信息</TableHead>
<TableHead className="text-slate-400">联系方式</TableHead>
<TableHead className="text-slate-400">角色</TableHead>
<TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">最后登录</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.map((user) => (
<TableRow key={user.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium">
{user.nickname.charAt(0)}
</div>
<div>
<p className="text-sm font-medium text-slate-200">{user.nickname}</p>
<p className="text-xs text-slate-500">@{user.username}</p>
</div>
</div>
</TableCell>
<TableCell>
<div className="space-y-1">
<div className="flex items-center gap-1 text-sm text-slate-400">
<Mail className="h-3.5 w-3.5" />
{user.email}
</div>
{user.phone && (
<div className="flex items-center gap-1 text-sm text-slate-400">
<Phone className="h-3.5 w-3.5" />
{user.phone}
</div>
)}
</div>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{user.roles.map((role) => (
<Badge key={role} variant="outline" className="border-slate-700 text-slate-400">
{role}
</Badge>
))}
</div>
</TableCell>
<TableCell>{getUserStatusBadge(user.status)}</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => setSelectedUser(user)}
>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Edit className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400">
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="roles" className="mt-4 space-y-4">
<div className="flex items-center justify-end">
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Plus className="h-4 w-4 mr-2" />
新建角色
</Button>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{roles.map((role) => (
<Card key={role.id} className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-purple-500/10 flex items-center justify-center">
<Shield className="h-5 w-5 text-purple-400" />
</div>
<div>
<CardTitle className="text-base text-slate-100">{role.name}</CardTitle>
<CardDescription className="text-slate-500">{role.code}</CardDescription>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => setSelectedRole(role)}
>
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Edit className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-slate-400 mb-3">{role.description}</p>
<div className="flex flex-wrap gap-1">
{role.permissions.slice(0, 5).map((permission) => (
<Badge key={permission} variant="outline" className="border-slate-700 text-slate-400 text-xs">
{permission}
</Badge>
))}
{role.permissions.length > 5 && (
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
+{role.permissions.length - 5}
</Badge>
)}
</div>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="logs" className="mt-4 space-y-4">
{/* Filters */}
<div className="flex items-center gap-3">
<div className="relative w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
<Input
placeholder="搜索操作日志..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/>
</div>
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<RefreshCw className="h-4 w-4 mr-2" />
刷新
</Button>
</div>
{/* Logs Table */}
<Card className="bg-slate-900 border-slate-800">
<CardContent className="p-0">
<div className="border-t border-slate-800">
<Table>
<TableHeader>
<TableRow className="border-slate-800 hover:bg-transparent">
<TableHead className="text-slate-400">时间</TableHead>
<TableHead className="text-slate-400">用户</TableHead>
<TableHead className="text-slate-400">模块</TableHead>
<TableHead className="text-slate-400">操作</TableHead>
<TableHead className="text-slate-400">资源</TableHead>
<TableHead className="text-slate-400">IP地址</TableHead>
<TableHead className="text-slate-400">结果</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredLogs.map((log) => (
<TableRow key={log.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>
<span className="text-sm text-slate-400">
{new Date(log.createdAt).toLocaleString()}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-200">{log.username}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{log.module}</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-300">{log.action}</span>
</TableCell>
<TableCell>
<code className="text-xs text-slate-400 bg-slate-800 px-2 py-0.5 rounded">
{log.resource}
</code>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">{log.ip}</span>
</TableCell>
<TableCell>{getLogResultBadge(log.result)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="settings" className="mt-4">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<Lock className="h-5 w-5 text-blue-400" />
安全设置
</CardTitle>
<CardDescription className="text-slate-500">密码策略与登录安全</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-200">强制密码复杂度</p>
<p className="text-xs text-slate-500">要求密码包含大小写字母、数字和特殊字符</p>
</div>
<Switch defaultChecked className="data-[state=checked]:bg-blue-600" />
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-200">登录失败锁定</p>
<p className="text-xs text-slate-500">连续5次登录失败锁定账户30分钟</p>
</div>
<Switch defaultChecked className="data-[state=checked]:bg-blue-600" />
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-200">双因素认证</p>
<p className="text-xs text-slate-500">启用后需要手机验证码登录</p>
</div>
<Switch className="data-[state=checked]:bg-blue-600" />
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<History className="h-5 w-5 text-emerald-400" />
审计设置
</CardTitle>
<CardDescription className="text-slate-500">操作日志与审计策略</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-200">记录所有操作</p>
<p className="text-xs text-slate-500">记录用户的所有操作行为</p>
</div>
<Switch defaultChecked className="data-[state=checked]:bg-blue-600" />
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-200">日志保留时间</p>
<p className="text-xs text-slate-500">操作日志保留180天</p>
</div>
<Select defaultValue="180">
<SelectTrigger className="w-32 h-8 bg-slate-800 border-slate-700 text-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="30">30天</SelectItem>
<SelectItem value="90">90天</SelectItem>
<SelectItem value="180">180天</SelectItem>
<SelectItem value="365">1年</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<Key className="h-5 w-5 text-amber-400" />
会话设置
</CardTitle>
<CardDescription className="text-slate-500">会话超时与Token配置</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-200">会话超时时间</p>
<p className="text-xs text-slate-500">用户无操作30分钟后自动登出</p>
</div>
<Select defaultValue="30">
<SelectTrigger className="w-32 h-8 bg-slate-800 border-slate-700 text-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="15">15分钟</SelectItem>
<SelectItem value="30">30分钟</SelectItem>
<SelectItem value="60">1小时</SelectItem>
<SelectItem value="120">2小时</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-slate-200">单点登录</p>
<p className="text-xs text-slate-500">同一账号只能在一个设备登录</p>
</div>
<Switch className="data-[state=checked]:bg-blue-600" />
</div>
</CardContent>
</Card>
<Card className="bg-slate-900 border-slate-800">
<CardHeader>
<CardTitle className="text-slate-100 flex items-center gap-2">
<Settings className="h-5 w-5 text-purple-400" />
系统信息
</CardTitle>
<CardDescription className="text-slate-500">版本与系统状态</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-slate-400">系统版本</span>
<span className="text-sm text-slate-200">v2.5.1</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-400">构建时间</span>
<span className="text-sm text-slate-200">2025-03-30</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-400">数据库版本</span>
<span className="text-sm text-slate-200">PostgreSQL 15.2</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-slate-400">运行时间</span>
<span className="text-sm text-slate-200">30天 12小时</span>
</div>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
{/* User Detail Dialog */}
<Dialog open={!!selectedUser} onOpenChange={() => setSelectedUser(null)}>
<DialogContent className="bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<User className="h-5 w-5 text-blue-400" />
{selectedUser?.nickname}
</DialogTitle>
<DialogDescription className="text-slate-500">
@{selectedUser?.username} · {selectedUser?.email}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4 p-4 rounded-lg bg-slate-800/50">
<div>
<p className="text-xs text-slate-500 mb-1">用户名</p>
<p className="text-sm text-slate-200">{selectedUser?.username}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">昵称</p>
<p className="text-sm text-slate-200">{selectedUser?.nickname}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">邮箱</p>
<p className="text-sm text-slate-200">{selectedUser?.email}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">手机号</p>
<p className="text-sm text-slate-200">{selectedUser?.phone || '--'}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">状态</p>
<p className="text-sm text-slate-200">{selectedUser && getUserStatusBadge(selectedUser.status)}</p>
</div>
<div>
<p className="text-xs text-slate-500 mb-1">创建时间</p>
<p className="text-sm text-slate-200">
{selectedUser && new Date(selectedUser.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div>
<h4 className="text-sm font-medium text-slate-300 mb-2">角色</h4>
<div className="flex flex-wrap gap-2">
{selectedUser?.roles.map((role) => (
<Badge key={role} variant="outline" className="border-slate-700 text-slate-400">
{role}
</Badge>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300">
<Key className="h-4 w-4 mr-2" />
重置密码
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white">
<Edit className="h-4 w-4 mr-2" />
编辑
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
+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 }
+11
View File
@@ -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,
}
+83
View File
@@ -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 }
+31
View File
@@ -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,
}
+255
View File
@@ -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,
}
+168
View File
@@ -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 }
+45
View File
@@ -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 }
+58
View File
@@ -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 }
+38
View File
@@ -0,0 +1,38 @@
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
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 }
+81
View File
@@ -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 }
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more