feat: FlowStack 发布系统首个可运行版本 (gin + React)
- Go gin 后端:流水线/节点/连线/运行/日志/GitOps/插件 REST API, SQLite(GORM) 自动建表 + seed,模拟执行引擎 + SSE 实时日志推送, 前端产物 go:embed 嵌入单二进制,一个端口同时服务 API 与页面 - React 19 前端:画布式流水线编辑器(React Flow + Tekton 任务库)、 GitOps 部署、批量发布、发布记录等 14 个模块页面, tRPC 全部替换为 REST(TanStack Query) - 首次启动生成随机密码管理员账号(日志 + data/initial-admin-password.txt) - run.sh 一键构建启动(默认端口 9527,支持 start/stop/restart/build/dev)
This commit is contained in:
commit
04d1342b27
115 files changed
+23268
No files matched your search
+27
@@ -0,0 +1,27 @@
|
||||
# 构建产物与二进制
|
||||
flowstack
|
||||
backend/flowstack
|
||||
backend/static/*
|
||||
!backend/static/index.html
|
||||
|
||||
# 数据与日志
|
||||
data/
|
||||
logs/
|
||||
*.db
|
||||
|
||||
# 前端
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
|
||||
# Go
|
||||
backend/backend
|
||||
|
||||
# 环境与编辑器
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
*.pid
|
||||
.zcode/
|
||||
@@ -0,0 +1,92 @@
|
||||
# FlowStack 发布系统
|
||||
|
||||
参考阿里云云效 / 华为云 CodeArts 风格的画布式发布系统。
|
||||
架构:**Go gin 后端 + React 19 前端**,前端产物嵌入 Go 单二进制,一个端口同时服务 API 与页面。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
./run.sh # 一键构建并启动,访问 http://localhost:9527
|
||||
./run.sh stop # 停止
|
||||
./run.sh dev # 开发模式(gin :9527 + Vite :5173 热更新)
|
||||
./run.sh help # 全部命令
|
||||
```
|
||||
|
||||
首次启动自动创建 SQLite 数据库(`data/flowstack.db`)并写入两条示例流水线
|
||||
(aiops-backend K8s 场景 / aiops-web 主机部署场景),零配置开箱即用。
|
||||
|
||||
## 初始管理员账号
|
||||
|
||||
首次启动自动生成 **随机初始密码** 的管理员账号(用户名 `admin`),并在日志中打印、写入 `data/initial-admin-password.txt`(权限 0600)。
|
||||
|
||||
查看初始密码:
|
||||
|
||||
```bash
|
||||
# 方式一:从启动日志中查看(与 opsxloki 的 docker logs 用法一致)
|
||||
grep -A6 "已生成管理员账号" logs/flowstack.log
|
||||
|
||||
# 方式二:直接查看密码文件(与 opsxloki 的 docker exec 用法一致)
|
||||
cat data/initial-admin-password.txt
|
||||
```
|
||||
|
||||
Docker 容器化部署时对应命令(参考 opsxloki 模式):
|
||||
|
||||
```bash
|
||||
docker logs <容器名> | grep -A6 "已生成管理员账号"
|
||||
docker exec <容器名> cat /app/data/initial-admin-password.txt
|
||||
```
|
||||
|
||||
登录校验接口:`POST /api/auth/login`(body: `{"username": "admin", "password": "..."}`)。
|
||||
密码使用 argon2id 哈希存储,请登录后立即修改。
|
||||
|
||||
## 功能模块
|
||||
|
||||
| 模块 | 说明 | 数据来源 |
|
||||
|------|------|----------|
|
||||
| 流水线 | 列表 + 画布编辑器(Tekton 任务库、拖拽编排、连线、节点属性配置) | gin API(SQLite 持久化) |
|
||||
| 模拟执行 | 触发运行后节点状态实时推进(pending→running→success)+ SSE 日志流 | gin API + 模拟执行引擎 |
|
||||
| GitOps 部署 | ArgoCD 风格应用列表 / 资源树 / Diff | gin API(部分模拟) |
|
||||
| 插件市场 | 已安装 / 可安装 / 启停 / 卸载 | gin API(SQLite 持久化) |
|
||||
| 发布记录 | 运行历史、阶段、日志、Pod 详情 | 前端演示数据(待接入) |
|
||||
| 批量发布 / 环境 / 凭证 / 构建节点 / Jenkins 导入 / Webhook / 系统设置 | UI 演示 | 前端演示数据(待接入) |
|
||||
|
||||
## 架构
|
||||
|
||||
```
|
||||
FlowStack/
|
||||
├── run.sh # 启动脚本(单二进制模式)
|
||||
├── backend/ # Go gin
|
||||
│ ├── main.go # 路由 + 静态托管(go:embed)
|
||||
│ └── internal/
|
||||
│ ├── model/ # GORM 模型(流水线/节点/连线/运行/日志/GitOps/插件)
|
||||
│ ├── database/ # SQLite 初始化 + seed
|
||||
│ └── handler/ # REST API + SSE 日志推送 + 模拟执行引擎
|
||||
├── frontend/ # React 19 + Vite + Tailwind + shadcn/ui + React Flow
|
||||
│ └── src/
|
||||
│ ├── lib/api.ts # REST 客户端
|
||||
│ ├── components/flow/# 画布编辑器(PipelineFlowEditor / TektonNode)
|
||||
│ └── pages/ # 14 个功能页面
|
||||
└── data/ # SQLite 数据库(运行时生成)
|
||||
```
|
||||
|
||||
## REST API 摘要
|
||||
|
||||
```
|
||||
GET/POST /api/pipelines 流水线列表 / 创建
|
||||
GET/PUT/DELETE /api/pipelines/:id 详情(含节点连线)/ 更新 / 删除
|
||||
PUT /api/pipelines/:id/nodes 保存画布节点(全量覆盖)
|
||||
PUT /api/pipelines/:id/edges 保存画布连线
|
||||
POST /api/pipelines/:id/runs 触发运行(模拟执行引擎)
|
||||
GET /api/runs 运行列表
|
||||
GET /api/runs/:runId 运行详情(阶段+日志)
|
||||
GET /api/runs/:runId/logs/stream SSE 实时日志
|
||||
GET /api/gitops/apps GitOps 应用列表
|
||||
GET/POST /api/gitops/argo/* ArgoCD 模拟接口
|
||||
GET/POST/DELETE /api/plugins* 插件管理
|
||||
GET /health 健康检查
|
||||
```
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 后端:Go 1.24+ / gin / GORM / SQLite(glebarez 纯 Go 驱动)
|
||||
- 前端:React 19 / TypeScript / Vite 7 / Tailwind CSS / shadcn-ui / @xyflow/react (React Flow) / TanStack Query / lucide-react / sonner
|
||||
@@ -0,0 +1,54 @@
|
||||
module flowstack
|
||||
|
||||
go 1.24
|
||||
|
||||
require (
|
||||
github.com/gin-contrib/cors v1.7.6
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
golang.org/x/crypto v0.40.0
|
||||
gorm.io/gorm v1.30.0
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/bytedance/sonic v1.14.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||
github.com/google/uuid v1.3.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/quic-go/qpack v0.5.1 // indirect
|
||||
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||
go.uber.org/mock v0.5.0 // indirect
|
||||
golang.org/x/arch v0.20.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/net v0.42.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.35.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.9 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
+118
@@ -0,0 +1,118 @@
|
||||
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY=
|
||||
github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok=
|
||||
github.com/gin-contrib/cors v1.7.6 h1:3gQ8GMzs1Ylpf70y8bMw4fVpycXIeX1ZemuSQIsnQQY=
|
||||
github.com/gin-contrib/cors v1.7.6/go.mod h1:Ulcl+xN4jel9t1Ry8vqph23a60FwH9xVLd+3ykmTjOk=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I=
|
||||
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||
golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM=
|
||||
golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY=
|
||||
golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w=
|
||||
golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
|
||||
golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs=
|
||||
golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI=
|
||||
golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
|
||||
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
|
||||
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
|
||||
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
|
||||
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/gorm v1.30.0 h1:qbT5aPv1UH8gI99OsRlvDToLxW5zR7FzS9acZDOZcgs=
|
||||
gorm.io/gorm v1.30.0/go.mod h1:8Z33v652h4//uMA76KjeDH8mJXPm1QNCYrMeatR0DOE=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
@@ -0,0 +1,51 @@
|
||||
// Package crypto 提供口令哈希原语(argon2id,与 opsx-loki 同参数)。
|
||||
package crypto
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"math/big"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
)
|
||||
|
||||
// HashPassword 生成 argon2id 口令哈希(salt+hash base64 编码)
|
||||
func HashPassword(password string) (string, error) {
|
||||
salt := make([]byte, 16)
|
||||
if _, err := rand.Read(salt); err != nil {
|
||||
return "", err
|
||||
}
|
||||
hash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32)
|
||||
combined := append(salt, hash...)
|
||||
return base64.StdEncoding.EncodeToString(combined), nil
|
||||
}
|
||||
|
||||
// VerifyPassword 常数时间比对口令哈希
|
||||
func VerifyPassword(password, hashStr string) bool {
|
||||
combined, err := base64.StdEncoding.DecodeString(hashStr)
|
||||
if err != nil || len(combined) < 16 {
|
||||
return false
|
||||
}
|
||||
salt := combined[:16]
|
||||
hash := combined[16:]
|
||||
newHash := argon2.IDKey([]byte(password), salt, 3, 64*1024, 4, 32)
|
||||
return subtle.ConstantTimeCompare(hash, newHash) == 1
|
||||
}
|
||||
|
||||
// passwordAlphabet 去掉易混淆字符(0O1lI)的密码字母表
|
||||
const passwordAlphabet = "23456789abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ"
|
||||
|
||||
// RandomPassword 生成 n 位随机密码(crypto/rand,无易混淆字符)
|
||||
func RandomPassword(n int) (string, error) {
|
||||
max := big.NewInt(int64(len(passwordAlphabet)))
|
||||
buf := make([]byte, n)
|
||||
for i := range buf {
|
||||
idx, err := rand.Int(rand.Reader, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
buf[i] = passwordAlphabet[idx.Int64()]
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"flowstack/internal/crypto"
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
var DB *gorm.DB
|
||||
|
||||
// DataDir 数据目录(密码文件等运行时产物存放处)
|
||||
var DataDir string
|
||||
|
||||
// Init 打开 SQLite、自动建表并写入示例数据(仅首次)
|
||||
func Init() error {
|
||||
DataDir = filepath.Join(mustDir(), "data")
|
||||
if err := os.MkdirAll(DataDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
db, err := gorm.Open(sqlite.Open(filepath.Join(DataDir, "flowstack.db")), &gorm.Config{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
DB = db
|
||||
|
||||
if err := DB.AutoMigrate(
|
||||
&model.User{},
|
||||
&model.Pipeline{}, &model.PipelineNode{}, &model.PipelineEdge{},
|
||||
&model.PipelineRun{}, &model.RunStage{}, &model.LogEntry{},
|
||||
&model.Application{}, &model.Plugin{},
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
seedAdmin()
|
||||
seedDemoData()
|
||||
return nil
|
||||
}
|
||||
|
||||
// mustDir 返回二进制所在目录;go run 时回退到当前目录
|
||||
func mustDir() string {
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
return filepath.Dir(exe)
|
||||
}
|
||||
return "."
|
||||
}
|
||||
|
||||
// seedAdmin 首次启动生成随机密码的管理员账号:
|
||||
// 密码打印到日志("已生成管理员账号",grep -A6 可见)并写入 data/initial-admin-password.txt。
|
||||
func seedAdmin() {
|
||||
var count int64
|
||||
DB.Model(&model.User{}).Count(&count)
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
password, err := crypto.RandomPassword(16)
|
||||
if err != nil {
|
||||
log.Printf("[seed] 管理员密码生成失败: %v", err)
|
||||
return
|
||||
}
|
||||
hash, err := crypto.HashPassword(password)
|
||||
if err != nil {
|
||||
log.Printf("[seed] 管理员密码哈希失败: %v", err)
|
||||
return
|
||||
}
|
||||
DB.Create(&model.User{
|
||||
Username: "admin", Password: hash, Nickname: "Administrator", Role: "admin",
|
||||
})
|
||||
|
||||
pwdFile := filepath.Join(DataDir, "initial-admin-password.txt")
|
||||
if err := os.WriteFile(pwdFile, []byte("admin / "+password+"\n"), 0o600); err != nil {
|
||||
log.Printf("[seed] 密码文件写入失败: %v", err)
|
||||
}
|
||||
|
||||
log.Println("========================================================")
|
||||
log.Println("[seed] 已生成管理员账号(首次启动)")
|
||||
log.Printf("[seed] 用户名: admin")
|
||||
log.Printf("[seed] 初始密码: %s", password)
|
||||
log.Printf("[seed] 密码已写入: %s", pwdFile)
|
||||
log.Println("[seed] 请登录后立即修改密码")
|
||||
log.Println("========================================================")
|
||||
}
|
||||
|
||||
func seedDemoData() {
|
||||
var count int64
|
||||
DB.Model(&model.Pipeline{}).Count(&count)
|
||||
if count > 0 {
|
||||
return
|
||||
}
|
||||
|
||||
log.Println("[seed] 首次启动,写入示例数据…")
|
||||
|
||||
// ---- 流水线 1:aiops-backend(Go + K8s) ----
|
||||
backend := model.Pipeline{
|
||||
Name: "aiops-backend", Description: "AIOps 后端服务(Go + K8s 滚动发布)",
|
||||
Type: "native", SourceRepo: "https://gitee.com/attacker/aiops.git", SourceBranch: "master",
|
||||
DeployType: "k8s", Env: "production",
|
||||
}
|
||||
DB.Create(&backend)
|
||||
DB.Create([]model.PipelineNode{
|
||||
{PipelineID: backend.ID, NodeID: "git-clone-1", Type: "source", Name: "git-clone", Category: "source", PositionX: 60, PositionY: 220, Width: 220, Height: 120,
|
||||
Config: map[string]interface{}{"url": "https://gitee.com/attacker/aiops.git", "revision": "master"}},
|
||||
{PipelineID: backend.ID, NodeID: "unit-test-1", Type: "test", Name: "unit-test", Category: "test", PositionX: 400, PositionY: 60, Width: 220, Height: 120,
|
||||
Config: map[string]interface{}{"test-command": "go test -v -race ./..."}},
|
||||
{PipelineID: backend.ID, NodeID: "kaniko-build-1", Type: "build", Name: "kaniko-build", Category: "build", PositionX: 400, PositionY: 380, Width: 220, Height: 120,
|
||||
Config: map[string]interface{}{"dockerfile": "Dockerfile", "image": "registry.example.com/aiops/aiops-backend"}},
|
||||
{PipelineID: backend.ID, NodeID: "trivy-scan-1", Type: "security", Name: "trivy-scan", Category: "security", PositionX: 740, PositionY: 380, Width: 220, Height: 120,
|
||||
Config: map[string]interface{}{"image": "registry.example.com/aiops/aiops-backend", "severity": "HIGH,CRITICAL"}},
|
||||
{PipelineID: backend.ID, NodeID: "kubectl-deploy-1", Type: "deploy", Name: "kubectl-deploy", Category: "deploy", PositionX: 1080, PositionY: 220, Width: 220, Height: 120,
|
||||
Config: map[string]interface{}{"manifest": "k8s/production/", "namespace": "aiops"}},
|
||||
})
|
||||
DB.Create([]model.PipelineEdge{
|
||||
{PipelineID: backend.ID, EdgeID: "e-clone-test", Source: "git-clone-1", Target: "unit-test-1", Animated: true},
|
||||
{PipelineID: backend.ID, EdgeID: "e-clone-build", Source: "git-clone-1", Target: "kaniko-build-1", Animated: true},
|
||||
{PipelineID: backend.ID, EdgeID: "e-build-scan", Source: "kaniko-build-1", Target: "trivy-scan-1", Animated: true},
|
||||
{PipelineID: backend.ID, EdgeID: "e-test-deploy", Source: "unit-test-1", Target: "kubectl-deploy-1", Animated: true},
|
||||
{PipelineID: backend.ID, EdgeID: "e-scan-deploy", Source: "trivy-scan-1", Target: "kubectl-deploy-1", Animated: true},
|
||||
})
|
||||
|
||||
// ---- 流水线 2:aiops-web(Vue + 主机部署) ----
|
||||
web := model.Pipeline{
|
||||
Name: "aiops-web", Description: "AIOps 前端(Vue + 主机 Nginx 发布)",
|
||||
Type: "native", SourceRepo: "https://gitee.com/attacker/aiops-web.git", SourceBranch: "master",
|
||||
DeployType: "host", Env: "production",
|
||||
}
|
||||
DB.Create(&web)
|
||||
DB.Create([]model.PipelineNode{
|
||||
{PipelineID: web.ID, NodeID: "git-clone-1", Type: "source", Name: "git-clone", Category: "source", PositionX: 60, PositionY: 200, Width: 220, Height: 120,
|
||||
Config: map[string]interface{}{"url": "https://gitee.com/attacker/aiops-web.git", "revision": "master"}},
|
||||
{PipelineID: web.ID, NodeID: "npm-build-1", Type: "build", Name: "npm build", Category: "build", PositionX: 400, PositionY: 60, Width: 220, Height: 120,
|
||||
Config: map[string]interface{}{"script": "npm ci && npm run build"}},
|
||||
{PipelineID: web.ID, NodeID: "npm-audit-1", Type: "security", Name: "npm audit", Category: "security", PositionX: 400, PositionY: 340, Width: 220, Height: 120,
|
||||
Config: map[string]interface{}{"severity": "high"}},
|
||||
{PipelineID: web.ID, NodeID: "host-deploy-1", Type: "deploy", Name: "主机部署", Category: "deploy", PositionX: 760, PositionY: 200, Width: 220, Height: 120,
|
||||
Config: map[string]interface{}{"主机组": "production-group", "目标路径": "/usr/share/nginx/html", "分批数量": "2"}},
|
||||
{PipelineID: web.ID, NodeID: "cdn-refresh-1", Type: "custom", Name: "刷新 CDN", Category: "custom", PositionX: 1120, PositionY: 200, Width: 220, Height: 120,
|
||||
Config: map[string]interface{}{"刷新URL": "https://aiops.example.com/*"}},
|
||||
})
|
||||
DB.Create([]model.PipelineEdge{
|
||||
{PipelineID: web.ID, EdgeID: "e-clone-npm", Source: "git-clone-1", Target: "npm-build-1", Animated: true},
|
||||
{PipelineID: web.ID, EdgeID: "e-clone-audit", Source: "git-clone-1", Target: "npm-audit-1", Animated: true},
|
||||
{PipelineID: web.ID, EdgeID: "e-npm-deploy", Source: "npm-build-1", Target: "host-deploy-1", Animated: true},
|
||||
{PipelineID: web.ID, EdgeID: "e-audit-deploy", Source: "npm-audit-1", Target: "host-deploy-1", Animated: true},
|
||||
{PipelineID: web.ID, EdgeID: "e-deploy-cdn", Source: "host-deploy-1", Target: "cdn-refresh-1", Animated: true},
|
||||
})
|
||||
|
||||
// ---- GitOps 应用 ----
|
||||
DB.Create([]model.Application{
|
||||
{Name: "aiops-backend", Repo: "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", Branch: "main", Path: "apps/aiops-backend", Cluster: "ack-production", Namespace: "aiops", ManifestType: "kustomize", SyncPolicy: "automated", AutoPrune: true, SelfHeal: true},
|
||||
{Name: "aiops-web", Repo: "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", Branch: "main", Path: "apps/aiops-web", Cluster: "ack-production", Namespace: "aiops", ManifestType: "kustomize", SyncPolicy: "manual"},
|
||||
})
|
||||
|
||||
// ---- 已安装插件 ----
|
||||
DB.Create([]model.Plugin{
|
||||
{PluginID: "jenkins-import", Name: "Jenkins 导入", Description: "一键导入既有 Jenkins Job 为 FlowStack 流水线", Version: "1.2.0", Author: "FlowStack Team", Category: "部署", Enabled: true},
|
||||
{PluginID: "webhook-notify", Name: "Webhook 通知", Description: "发布成功/失败后回调企业微信、钉钉", Version: "1.0.3", Author: "FlowStack Team", Category: "通知", Enabled: true},
|
||||
{PluginID: "k8s-rollout", Name: "K8s 滚动发布", Description: "Deployment 滚动更新与回滚", Version: "2.3.1", Author: "CloudNative", Category: "构建", Enabled: false},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"flowstack/internal/crypto"
|
||||
"flowstack/internal/database"
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// Login POST /api/auth/login {username, password}
|
||||
func Login(c *gin.Context) {
|
||||
var body struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "用户名和密码不能为空"})
|
||||
return
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if err := database.DB.Where("username = ?", body.Username).First(&user).Error; err != nil {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
if !crypto.VerifyPassword(body.Password, user.Password) {
|
||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "用户名或密码错误"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true, "username": user.Username, "nickname": user.Nickname, "role": user.Role,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"flowstack/internal/database"
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// argoApps 模拟 Argo CD 应用状态(演示用)
|
||||
var argoApps = []gin.H{
|
||||
{"metadata": gin.H{"name": "aiops-backend"},
|
||||
"spec": gin.H{"source": gin.H{"repoURL": "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", "path": "apps/aiops-backend", "targetRevision": "main"}, "destination": gin.H{"server": "https://k8s.prod", "namespace": "aiops"}},
|
||||
"status": gin.H{"sync": gin.H{"status": "Synced"}, "health": gin.H{"status": "Healthy"}, "operationState": gin.H{"phase": "Succeeded"}}},
|
||||
{"metadata": gin.H{"name": "aiops-web"},
|
||||
"spec": gin.H{"source": gin.H{"repoURL": "https://gitea.91ai.io/rlwj-lab/aiops-gitops.git", "path": "apps/aiops-web", "targetRevision": "main"}, "destination": gin.H{"server": "https://k8s.prod", "namespace": "aiops"}},
|
||||
"status": gin.H{"sync": gin.H{"status": "OutOfSync"}, "health": gin.H{"status": "Progressing"}, "operationState": gin.H{"phase": "Running"}}},
|
||||
}
|
||||
|
||||
// ListGitOpsApps GET /api/gitops/apps
|
||||
func ListGitOpsApps(c *gin.Context) {
|
||||
var apps []model.Application
|
||||
database.DB.Order("updated_at DESC").Find(&apps)
|
||||
c.JSON(http.StatusOK, apps)
|
||||
}
|
||||
|
||||
// ArgoList GET /api/gitops/argo/apps
|
||||
func ArgoList(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, argoApps)
|
||||
}
|
||||
|
||||
// ArgoSync POST /api/gitops/argo/sync {appName}
|
||||
func ArgoSync(c *gin.Context) {
|
||||
var body struct {
|
||||
AppName string `json:"appName" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "appName required"})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "appName": body.AppName, "message": "Sync triggered"})
|
||||
}
|
||||
|
||||
// ArgoResourceTree GET /api/gitops/argo/:appName/tree
|
||||
func ArgoResourceTree(c *gin.Context) {
|
||||
name := c.Param("appName")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"nodes": []gin.H{
|
||||
{"uid": "1", "name": name + "-deployment", "kind": "Deployment", "namespace": "aiops", "status": "healthy", "resourceVersion": "v1"},
|
||||
{"uid": "2", "name": name + "-svc", "kind": "Service", "namespace": "aiops", "status": "healthy", "resourceVersion": "v1"},
|
||||
{"uid": "3", "name": name + "-hpa", "kind": "HorizontalPodAutoscaler", "namespace": "aiops", "status": "healthy", "resourceVersion": "v2"},
|
||||
},
|
||||
"edges": []gin.H{{"source": "1", "target": "2"}, {"source": "1", "target": "3"}},
|
||||
})
|
||||
}
|
||||
|
||||
// ArgoDiff GET /api/gitops/argo/:appName/diff
|
||||
func ArgoDiff(c *gin.Context) {
|
||||
name := c.Param("appName")
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"diffs": []gin.H{
|
||||
{"kind": "Deployment", "name": name, "namespace": "aiops", "diff": "- image: registry.example.com/aiops/" + name + ":202610261130\n+ image: registry.example.com/aiops/" + name + ":202610271200", "status": "modified"},
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Hub 按 runId 分组的 SSE 订阅中心
|
||||
type Hub struct {
|
||||
mu sync.Mutex
|
||||
subs map[string]map[chan []byte]struct{}
|
||||
}
|
||||
|
||||
func NewHub() *Hub {
|
||||
return &Hub{subs: make(map[string]map[chan []byte]struct{})}
|
||||
}
|
||||
|
||||
func (h *Hub) Subscribe(runId string) chan []byte {
|
||||
ch := make(chan []byte, 64)
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if h.subs[runId] == nil {
|
||||
h.subs[runId] = make(map[chan []byte]struct{})
|
||||
}
|
||||
h.subs[runId][ch] = struct{}{}
|
||||
return ch
|
||||
}
|
||||
|
||||
func (h *Hub) Unsubscribe(runId string, ch chan []byte) {
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
if set := h.subs[runId]; set != nil {
|
||||
delete(set, ch)
|
||||
if len(set) == 0 {
|
||||
delete(h.subs, runId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Publish 向 runId 的所有订阅者推送一条 SSE 帧(event + data)
|
||||
func (h *Hub) Publish(runId, event string, payload any) {
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
frame := []byte(fmt.Sprintf("event: %s\ndata: %s\n\n", event, data))
|
||||
h.mu.Lock()
|
||||
defer h.mu.Unlock()
|
||||
for ch := range h.subs[runId] {
|
||||
select {
|
||||
case ch <- frame:
|
||||
default: // 订阅者消费过慢则丢弃,避免阻塞模拟引擎
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"flowstack/internal/database"
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// ListPipelines GET /api/pipelines
|
||||
func ListPipelines(c *gin.Context) {
|
||||
var list []model.Pipeline
|
||||
database.DB.Order("updated_at DESC").Find(&list)
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
// GetPipeline GET /api/pipelines/:id —— 详情含画布节点与连线
|
||||
func GetPipeline(c *gin.Context) {
|
||||
var p model.Pipeline
|
||||
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||
return
|
||||
}
|
||||
var nodes []model.PipelineNode
|
||||
database.DB.Where("pipeline_id = ?", p.ID).Order("position_x").Find(&nodes)
|
||||
var edges []model.PipelineEdge
|
||||
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"pipeline": p, "nodes": nodes, "edges": edges,
|
||||
"name": p.Name, "description": p.Description, "type": p.Type,
|
||||
"sourceRepo": p.SourceRepo, "sourceBranch": p.SourceBranch,
|
||||
"gitopsRepo": p.GitopsRepo, "gitopsPath": p.GitopsPath,
|
||||
"deployType": p.DeployType, "env": p.Env,
|
||||
"id": p.ID, "createdAt": p.CreatedAt, "updatedAt": p.UpdatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
// CreatePipeline POST /api/pipelines
|
||||
func CreatePipeline(c *gin.Context) {
|
||||
var input struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Type string `json:"type"`
|
||||
SourceRepo string `json:"sourceRepo"`
|
||||
SourceBranch string `json:"sourceBranch"`
|
||||
DeployType string `json:"deployType"`
|
||||
Env string `json:"env"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "name 不能为空"})
|
||||
return
|
||||
}
|
||||
setDefault(&input.Type, "native")
|
||||
setDefault(&input.SourceBranch, "main")
|
||||
setDefault(&input.DeployType, "host")
|
||||
setDefault(&input.Env, "staging")
|
||||
|
||||
p := model.Pipeline{
|
||||
Name: input.Name, Description: input.Description, Type: input.Type,
|
||||
SourceRepo: input.SourceRepo, SourceBranch: input.SourceBranch,
|
||||
DeployType: input.DeployType, Env: input.Env,
|
||||
}
|
||||
if err := database.DB.Create(&p).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, p)
|
||||
}
|
||||
|
||||
// UpdatePipeline PUT /api/pipelines/:id
|
||||
func UpdatePipeline(c *gin.Context) {
|
||||
var p model.Pipeline
|
||||
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||
return
|
||||
}
|
||||
var input map[string]any
|
||||
if err := c.ShouldBindJSON(&input); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
allowed := map[string]bool{"name": true, "description": true, "sourceRepo": true, "sourceBranch": true, "gitopsRepo": true, "gitopsPath": true, "deployType": true, "env": true}
|
||||
updates := map[string]any{}
|
||||
for k, v := range input {
|
||||
if allowed[k] {
|
||||
updates[k] = v
|
||||
}
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
updates["updated_at"] = time.Now()
|
||||
database.DB.Model(&p).Updates(updates)
|
||||
}
|
||||
c.JSON(http.StatusOK, p)
|
||||
}
|
||||
|
||||
// DeletePipeline DELETE /api/pipelines/:id —— 级联删除节点与连线
|
||||
func DeletePipeline(c *gin.Context) {
|
||||
id := c.Param("id")
|
||||
database.DB.Where("pipeline_id = ?", id).Delete(&model.PipelineEdge{})
|
||||
database.DB.Where("pipeline_id = ?", id).Delete(&model.PipelineNode{})
|
||||
database.DB.Delete(&model.Pipeline{}, id)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// SaveNodes PUT /api/pipelines/:id/nodes —— 全量覆盖保存
|
||||
func SaveNodes(c *gin.Context) {
|
||||
pipelineID := c.Param("id")
|
||||
var p model.Pipeline
|
||||
if err := database.DB.First(&p, pipelineID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Nodes []model.PipelineNode `json:"nodes"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
database.DB.Where("pipeline_id = ?", p.ID).Delete(&model.PipelineNode{})
|
||||
for i := range body.Nodes {
|
||||
body.Nodes[i].ID = 0
|
||||
body.Nodes[i].PipelineID = p.ID
|
||||
database.DB.Create(&body.Nodes[i])
|
||||
}
|
||||
database.DB.Model(&p).Update("updated_at", time.Now())
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// SaveEdges PUT /api/pipelines/:id/edges —— 全量覆盖保存
|
||||
func SaveEdges(c *gin.Context) {
|
||||
pipelineID := c.Param("id")
|
||||
var p model.Pipeline
|
||||
if err := database.DB.First(&p, pipelineID).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Edges []model.PipelineEdge `json:"edges"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
database.DB.Where("pipeline_id = ?", p.ID).Delete(&model.PipelineEdge{})
|
||||
for i := range body.Edges {
|
||||
body.Edges[i].ID = 0
|
||||
body.Edges[i].PipelineID = p.ID
|
||||
database.DB.Create(&body.Edges[i])
|
||||
}
|
||||
database.DB.Model(&p).Update("updated_at", time.Now())
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func setDefault(v *string, def string) {
|
||||
if *v == "" {
|
||||
*v = def
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"flowstack/internal/database"
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// availablePlugins 可安装插件市场(演示数据)
|
||||
var availablePlugins = []gin.H{
|
||||
{"pluginId": "gitops-sync", "name": "GitOps Sync", "description": "Argo CD integration", "version": "1.3.0", "author": "OpsX Team", "category": "部署", "installs": 3420},
|
||||
{"pluginId": "tekton-tasks", "name": "Tekton Tasks", "description": "Cloud-native CI tasks", "version": "2.1.0", "author": "CloudNative", "category": "构建", "installs": 5180},
|
||||
{"pluginId": "dark-theme", "name": "Dark Theme", "description": "Dark UI theme", "version": "1.1.0", "author": "DesignLab", "category": "主题", "installs": 8920},
|
||||
{"pluginId": "sonarqube", "name": "SonarQube Scanner", "description": "Code quality scan", "version": "3.2.0", "author": "CodeQuality", "category": "扫描", "installs": 2150},
|
||||
{"pluginId": "vault-secrets", "name": "Vault Secrets", "description": "HashiCorp Vault integration", "version": "1.6.0", "author": "SecurityTeam", "category": "安全", "installs": 980},
|
||||
{"pluginId": "matrix-build", "name": "Matrix Build", "description": "Multi-dimension parallel build", "version": "2.0.0", "author": "BuildMaster", "category": "构建", "installs": 1670},
|
||||
{"pluginId": "slack-notify", "name": "Slack Notify", "description": "Slack notifications", "version": "1.3.0", "author": "NotifyHub", "category": "通知", "installs": 2890},
|
||||
}
|
||||
|
||||
// ListPlugins GET /api/plugins
|
||||
func ListPlugins(c *gin.Context) {
|
||||
var list []model.Plugin
|
||||
database.DB.Order("created_at DESC").Find(&list)
|
||||
c.JSON(http.StatusOK, list)
|
||||
}
|
||||
|
||||
// AvailablePlugins GET /api/plugins/available
|
||||
func AvailablePlugins(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, availablePlugins)
|
||||
}
|
||||
|
||||
// InstallPlugin POST /api/plugins/install
|
||||
func InstallPlugin(c *gin.Context) {
|
||||
var body struct {
|
||||
PluginID string `json:"pluginId" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version" binding:"required"`
|
||||
Author string `json:"author"`
|
||||
Category string `json:"category"`
|
||||
EntryPath string `json:"entryPath"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
if body.Category == "" {
|
||||
body.Category = "general"
|
||||
}
|
||||
p := model.Plugin{
|
||||
PluginID: body.PluginID, Name: body.Name, Description: body.Description,
|
||||
Version: body.Version, Author: body.Author, Category: body.Category, EntryPath: body.EntryPath,
|
||||
Installed: true, Enabled: true, Config: map[string]any{},
|
||||
}
|
||||
if err := database.DB.Create(&p).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, p)
|
||||
}
|
||||
|
||||
// TogglePlugin POST /api/plugins/:id/toggle {enabled}
|
||||
func TogglePlugin(c *gin.Context) {
|
||||
var p model.Plugin
|
||||
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "plugin not found"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
database.DB.Model(&p).Update("enabled", body.Enabled)
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "enabled": body.Enabled})
|
||||
}
|
||||
|
||||
// UninstallPlugin DELETE /api/plugins/:id
|
||||
func UninstallPlugin(c *gin.Context) {
|
||||
database.DB.Delete(&model.Plugin{}, c.Param("id"))
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"flowstack/internal/database"
|
||||
"flowstack/internal/model"
|
||||
)
|
||||
|
||||
// CreateRun 创建运行并启动模拟执行引擎
|
||||
func CreateRun(hub *Hub) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
var p model.Pipeline
|
||||
if err := database.DB.First(&p, c.Param("id")).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "pipeline not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Branch string `json:"branch"`
|
||||
Env string `json:"env"`
|
||||
TriggerBy string `json:"triggerBy"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&body)
|
||||
if body.Branch == "" {
|
||||
body.Branch = p.SourceBranch
|
||||
}
|
||||
if body.Env == "" {
|
||||
body.Env = p.Env
|
||||
}
|
||||
if body.TriggerBy == "" {
|
||||
body.TriggerBy = "admin"
|
||||
}
|
||||
|
||||
var nodes []model.PipelineNode
|
||||
database.DB.Where("pipeline_id = ?", p.ID).Find(&nodes)
|
||||
if len(nodes) == 0 {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "流水线没有节点,请先在画布中编排并保存"})
|
||||
return
|
||||
}
|
||||
var edges []model.PipelineEdge
|
||||
database.DB.Where("pipeline_id = ?", p.ID).Find(&edges)
|
||||
nodesJSON, _ := json.Marshal(nodes)
|
||||
edgesJSON, _ := json.Marshal(edges)
|
||||
|
||||
run := model.PipelineRun{
|
||||
RunID: fmt.Sprintf("pr-%d", time.Now().UnixMilli()),
|
||||
PipelineID: p.ID, PipelineName: p.Name,
|
||||
Branch: body.Branch, Env: body.Env, DeployType: p.DeployType, Type: p.Type,
|
||||
TriggerBy: body.TriggerBy, TriggerType: "manual",
|
||||
Status: "running", StartedAt: time.Now(),
|
||||
NodesSnapshot: string(nodesJSON), EdgesSnapshot: string(edgesJSON),
|
||||
}
|
||||
if err := database.DB.Create(&run).Error; err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 按画布 X 坐标从左到右生成执行阶段
|
||||
sort.Slice(nodes, func(i, j int) bool { return nodes[i].PositionX < nodes[j].PositionX })
|
||||
stages := make([]model.RunStage, len(nodes))
|
||||
for i, n := range nodes {
|
||||
stages[i] = model.RunStage{RunID: run.ID, NodeID: n.NodeID, Name: n.Name, Status: "pending"}
|
||||
database.DB.Create(&stages[i])
|
||||
}
|
||||
|
||||
go simulateRun(hub, run, stages)
|
||||
|
||||
c.JSON(http.StatusOK, run)
|
||||
}
|
||||
}
|
||||
|
||||
// simulateRun 模拟执行:逐阶段 pending→running→success,逐条写日志并 SSE 推送
|
||||
func simulateRun(hub *Hub, run model.PipelineRun, stages []model.RunStage) {
|
||||
start := time.Now()
|
||||
emit := func(level, msg string, stageID uint) {
|
||||
entry := model.LogEntry{RunID: run.ID, StageID: stageID, Level: level, Message: msg, Source: "runner", Timestamp: time.Now()}
|
||||
database.DB.Create(&entry)
|
||||
hub.Publish(run.RunID, "log", entry)
|
||||
}
|
||||
setStage := func(stage *model.RunStage, status string) {
|
||||
now := time.Now()
|
||||
updates := map[string]any{"status": status}
|
||||
if status == "running" {
|
||||
stage.StartedAt = &now
|
||||
updates["started_at"] = now
|
||||
} else {
|
||||
stage.CompletedAt = &now
|
||||
updates["completed_at"] = now
|
||||
exit := 0
|
||||
stage.ExitCode = &exit
|
||||
updates["exit_code"] = exit
|
||||
}
|
||||
database.DB.Model(&model.RunStage{}).Where("id = ?", stage.ID).Updates(updates)
|
||||
stage.Status = status
|
||||
hub.Publish(run.RunID, "stage", gin.H{"stageId": stage.ID, "nodeId": stage.NodeID, "name": stage.Name, "status": status})
|
||||
}
|
||||
setRun := func(status string) {
|
||||
now := time.Now()
|
||||
updates := map[string]any{"status": status, "duration_ms": time.Since(start).Milliseconds(), "completed_at": now}
|
||||
database.DB.Model(&model.PipelineRun{}).Where("id = ?", run.ID).Updates(updates)
|
||||
hub.Publish(run.RunID, "run", gin.H{"runId": run.RunID, "status": status, "durationMs": time.Since(start).Milliseconds()})
|
||||
}
|
||||
|
||||
emit("info", "Pipeline 触发成功,开始调度执行引擎…", 0)
|
||||
for i := range stages {
|
||||
st := &stages[i]
|
||||
setStage(st, "running")
|
||||
for _, line := range stageScript(st) {
|
||||
emit("info", line, st.ID)
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
setStage(st, "success")
|
||||
}
|
||||
setRun("success")
|
||||
}
|
||||
|
||||
// stageScript 按节点类别生成贴近真实构建过程的日志
|
||||
func stageScript(st *model.RunStage) []string {
|
||||
switch st.Name {
|
||||
case "git-clone":
|
||||
return []string{
|
||||
fmt.Sprintf("$ git clone --depth=50 --branch=master %s", "https://gitee.com/attacker/aiops.git"),
|
||||
"Cloning into 'source'...",
|
||||
"HEAD is now at 8f3c21a feat: release v2.4.0",
|
||||
}
|
||||
case "unit-test":
|
||||
return []string{
|
||||
"$ go test -v -race ./...",
|
||||
"ok \tflowstack/internal/handler\t2.318s",
|
||||
"ok \tflowstack/internal/model\t0.402s",
|
||||
"coverage: 87.4% of statements",
|
||||
}
|
||||
case "kaniko-build":
|
||||
return []string{
|
||||
"$ kaniko --dockerfile Dockerfile --destination registry.example.com/aiops/aiops-backend:" + time.Now().Format("200601021504"),
|
||||
"INFO[0002] Retrieving image manifest golang:1.24-alpine",
|
||||
"INFO[0018] Executing 12 build steps",
|
||||
"INFO[0041] Pushing image to registry.example.com",
|
||||
}
|
||||
case "trivy-scan":
|
||||
return []string{
|
||||
"$ trivy image --severity HIGH,CRITICAL registry.example.com/aiops/aiops-backend:latest",
|
||||
"alpine (apk): 0 HIGH, 0 CRITICAL",
|
||||
"Scan completed: no critical vulnerabilities found",
|
||||
}
|
||||
case "kubectl-deploy":
|
||||
return []string{
|
||||
"$ kubectl set image deployment/aiops-backend app=registry.example.com/aiops/aiops-backend:" + time.Now().Format("200601021504") + " -n aiops",
|
||||
"deployment.apps/aiops-backend image updated",
|
||||
"Waiting for rollout: 3/3 updated",
|
||||
"deployment \"aiops-backend\" successfully rolled out",
|
||||
}
|
||||
case "npm build":
|
||||
return []string{
|
||||
"$ npm ci --registry=https://registry.npmmirror.com",
|
||||
"$ npm run build",
|
||||
"vite v7.x building for production...",
|
||||
"✓ built in 18.4s",
|
||||
}
|
||||
case "npm audit":
|
||||
return []string{
|
||||
"$ npm audit --audit-level=high",
|
||||
"found 0 vulnerabilities",
|
||||
}
|
||||
case "主机部署":
|
||||
return []string{
|
||||
"$ tar zcf dist.tar.gz dist/ && scp dist.tar.gz production-group:/home/admin/app/",
|
||||
"$ rsync -avz --delete /tmp/dist/ /usr/share/nginx/html/",
|
||||
"$ nginx -t && nginx -s reload",
|
||||
"2/2 主机部署完成",
|
||||
}
|
||||
case "刷新 CDN":
|
||||
return []string{
|
||||
"$ curl -X POST https://cdn.aliyuncs.com/api/refresh",
|
||||
`{"urls":["https://aiops.example.com/*"]}`,
|
||||
"RefreshTaskId: 202610271200****",
|
||||
}
|
||||
default:
|
||||
return []string{
|
||||
fmt.Sprintf("[%s] 开始执行…", st.Name),
|
||||
fmt.Sprintf("[%s] 执行完成 (exit 0)", st.Name),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ListRuns GET /api/runs?pipelineId=&status=
|
||||
func ListRuns(c *gin.Context) {
|
||||
q := database.DB.Model(&model.PipelineRun{}).Order("started_at DESC").Limit(100)
|
||||
if v := c.Query("pipelineId"); v != "" {
|
||||
q = q.Where("pipeline_id = ?", v)
|
||||
}
|
||||
if v := c.Query("status"); v != "" && v != "all" {
|
||||
q = q.Where("status = ?", v)
|
||||
}
|
||||
var runs []model.PipelineRun
|
||||
q.Find(&runs)
|
||||
c.JSON(http.StatusOK, runs)
|
||||
}
|
||||
|
||||
// GetRun GET /api/runs/:runId
|
||||
func GetRun(c *gin.Context) {
|
||||
var run model.PipelineRun
|
||||
if err := database.DB.Where("run_id = ?", c.Param("runId")).First(&run).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "run not found"})
|
||||
return
|
||||
}
|
||||
var stages []model.RunStage
|
||||
database.DB.Where("run_id = ?", run.ID).Order("id").Find(&stages)
|
||||
var logs []model.LogEntry
|
||||
database.DB.Where("run_id = ?", run.ID).Order("timestamp").Find(&logs)
|
||||
c.JSON(http.StatusOK, gin.H{"run": run, "stages": stages, "logs": logs})
|
||||
}
|
||||
|
||||
// StreamLogs GET /api/runs/:runId/logs/stream —— SSE:先回放历史日志再实时推送
|
||||
func StreamLogs(hub *Hub) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
runId := c.Param("runId")
|
||||
var run model.PipelineRun
|
||||
if err := database.DB.Where("run_id = ?", runId).First(&run).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "run not found"})
|
||||
return
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
c.Header("Connection", "keep-alive")
|
||||
c.Header("X-Accel-Buffering", "no")
|
||||
|
||||
flush := func() { if f, ok := c.Writer.(http.Flusher); ok { f.Flush() } }
|
||||
|
||||
fmt.Fprintf(c.Writer, "event: connected\ndata: {\"runId\":\"%s\"}\n\n", runId)
|
||||
flush()
|
||||
|
||||
// 回放历史日志
|
||||
var history []model.LogEntry
|
||||
database.DB.Where("run_id = ?", run.ID).Order("timestamp").Find(&history)
|
||||
for _, entry := range history {
|
||||
data, _ := json.Marshal(entry)
|
||||
fmt.Fprintf(c.Writer, "event: log\ndata: %s\n\n", data)
|
||||
}
|
||||
if len(history) > 0 {
|
||||
flush()
|
||||
}
|
||||
|
||||
ch := hub.Subscribe(runId)
|
||||
defer hub.Unsubscribe(runId, ch)
|
||||
|
||||
heartbeat := time.NewTicker(15 * time.Second)
|
||||
defer heartbeat.Stop()
|
||||
|
||||
ctx := c.Request.Context()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-heartbeat.C:
|
||||
fmt.Fprint(c.Writer, ": ping\n\n")
|
||||
flush()
|
||||
case frame := <-ch:
|
||||
c.Writer.Write(frame)
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// User 系统用户
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Username string `gorm:"uniqueIndex" json:"username"`
|
||||
Password string `json:"-"` // argon2id 哈希,绝不返回给前端
|
||||
Nickname string `json:"nickname"`
|
||||
Role string `gorm:"default:user" json:"role"` // admin | user
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Pipeline 流水线定义
|
||||
type Pipeline struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"index" json:"name"`
|
||||
Description string `json:"description"`
|
||||
Type string `gorm:"default:native" json:"type"` // native | jenkins | tekton
|
||||
SourceRepo string `json:"sourceRepo"`
|
||||
SourceBranch string `gorm:"default:main" json:"sourceBranch"`
|
||||
GitopsRepo string `json:"gitopsRepo"`
|
||||
GitopsPath string `json:"gitopsPath"`
|
||||
DeployType string `gorm:"default:host" json:"deployType"` // host | k8s | gitops
|
||||
Env string `gorm:"default:staging" json:"env"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// PipelineNode 画布节点(JSON 字段名与前端 React Flow 对齐)
|
||||
type PipelineNode struct {
|
||||
ID uint `gorm:"primaryKey" json:"dbId"`
|
||||
PipelineID uint `gorm:"index" json:"pipelineId"`
|
||||
NodeID string `gorm:"index" json:"nodeId"`
|
||||
Type string `json:"type"`
|
||||
Name string `json:"name"`
|
||||
PositionX float64 `json:"positionX"`
|
||||
PositionY float64 `json:"positionY"`
|
||||
Width float64 `json:"width"`
|
||||
Height float64 `json:"height"`
|
||||
Category string `json:"category"` // source|build|test|security|deploy|gitops|custom
|
||||
Config map[string]interface{} `gorm:"serializer:json" json:"config"`
|
||||
}
|
||||
|
||||
// PipelineEdge 画布连线
|
||||
type PipelineEdge struct {
|
||||
ID uint `gorm:"primaryKey" json:"dbId"`
|
||||
PipelineID uint `gorm:"index" json:"pipelineId"`
|
||||
EdgeID string `gorm:"index" json:"edgeId"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Label string `json:"label"`
|
||||
Animated bool `json:"animated"`
|
||||
}
|
||||
|
||||
// PipelineRun 一次流水线运行
|
||||
type PipelineRun struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
RunID string `gorm:"uniqueIndex" json:"runId"` // pr-<timestamp>
|
||||
PipelineID uint `gorm:"index" json:"pipelineId"`
|
||||
PipelineName string `json:"pipelineName"`
|
||||
Branch string `gorm:"default:main" json:"branch"`
|
||||
Env string `gorm:"default:staging" json:"env"`
|
||||
DeployType string `gorm:"default:host" json:"deployType"`
|
||||
Type string `gorm:"default:native" json:"type"`
|
||||
TriggerBy string `gorm:"default:system" json:"triggerBy"`
|
||||
TriggerType string `gorm:"default:manual" json:"triggerType"` // manual|webhook|cron|batch
|
||||
Status string `gorm:"default:pending" json:"status"` // pending|running|success|failed|cancelled
|
||||
DurationMs int64 `json:"durationMs"`
|
||||
NodesSnapshot string `json:"nodesSnapshot"`
|
||||
EdgesSnapshot string `json:"edgesSnapshot"`
|
||||
StartedAt time.Time `json:"startedAt"`
|
||||
CompletedAt *time.Time `json:"completedAt"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// RunStage 运行中的阶段(对应一个节点)
|
||||
type RunStage struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
RunID uint `gorm:"index" json:"runId"`
|
||||
NodeID string `json:"nodeId"`
|
||||
Name string `json:"name"`
|
||||
PodName string `json:"podName"`
|
||||
Status string `gorm:"default:pending" json:"status"` // pending|running|success|failed|skipped
|
||||
ExitCode *int `json:"exitCode"`
|
||||
StartedAt *time.Time `json:"startedAt"`
|
||||
CompletedAt *time.Time `json:"completedAt"`
|
||||
}
|
||||
|
||||
// LogEntry 运行日志
|
||||
type LogEntry struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
RunID uint `gorm:"index" json:"runId"`
|
||||
StageID uint `json:"stageId"`
|
||||
Level string `gorm:"default:info" json:"level"` // debug|info|warn|error
|
||||
Message string `json:"message"`
|
||||
Source string `gorm:"default:runner" json:"source"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Application GitOps 应用
|
||||
type Application struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"uniqueIndex" json:"name"`
|
||||
Repo string `json:"repo"`
|
||||
Branch string `gorm:"default:main" json:"branch"`
|
||||
Path string `json:"path"`
|
||||
Cluster string `gorm:"default:default" json:"cluster"`
|
||||
Namespace string `gorm:"default:default" json:"namespace"`
|
||||
ManifestType string `gorm:"default:plain" json:"manifestType"` // helm|kustomize|plain
|
||||
SyncPolicy string `gorm:"default:manual" json:"syncPolicy"` // manual|automated
|
||||
AutoPrune bool `json:"autoPrune"`
|
||||
SelfHeal bool `json:"selfHeal"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
// Plugin 插件
|
||||
type Plugin struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PluginID string `gorm:"uniqueIndex" json:"pluginId"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Version string `json:"version"`
|
||||
Author string `json:"author"`
|
||||
Category string `gorm:"default:general" json:"category"`
|
||||
EntryPath string `json:"entryPath"`
|
||||
Installed bool `gorm:"default:true" json:"installed"`
|
||||
Enabled bool `gorm:"default:true" json:"enabled"`
|
||||
Config map[string]interface{} `gorm:"serializer:json" json:"config"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-contrib/cors"
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"flowstack/internal/database"
|
||||
"flowstack/internal/handler"
|
||||
)
|
||||
|
||||
// 前端产物在构建期拷贝到 static/ 并嵌入二进制(run.sh build)
|
||||
//
|
||||
//go:embed all:static
|
||||
var staticFS embed.FS
|
||||
|
||||
func main() {
|
||||
if err := database.Init(); err != nil {
|
||||
panic("数据库初始化失败: " + err.Error())
|
||||
}
|
||||
|
||||
hub := handler.NewHub()
|
||||
r := gin.Default()
|
||||
|
||||
// ?_clear=1 时下发 Clear-Site-Data,用于清除浏览器缓存的错误重定向等脏缓存
|
||||
// (历史 bug:/index.html 曾返回 301 → ./,浏览器永久缓存后反复触发 ERR_TOO_MANY_REDIRECTS)
|
||||
r.Use(func(c *gin.Context) {
|
||||
if c.Query("_clear") != "" {
|
||||
c.Header("Clear-Site-Data", "\"cache\"")
|
||||
}
|
||||
c.Next()
|
||||
})
|
||||
|
||||
r.Use(cors.New(cors.Config{
|
||||
AllowAllOrigins: true,
|
||||
AllowMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||
AllowHeaders: []string{"*"},
|
||||
AllowCredentials: false,
|
||||
}))
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "app": "flowstack"})
|
||||
})
|
||||
|
||||
api := r.Group("/api")
|
||||
{
|
||||
api.POST("/auth/login", handler.Login)
|
||||
|
||||
api.GET("/pipelines", handler.ListPipelines)
|
||||
api.POST("/pipelines", handler.CreatePipeline)
|
||||
api.GET("/pipelines/:id", handler.GetPipeline)
|
||||
api.PUT("/pipelines/:id", handler.UpdatePipeline)
|
||||
api.DELETE("/pipelines/:id", handler.DeletePipeline)
|
||||
api.PUT("/pipelines/:id/nodes", handler.SaveNodes)
|
||||
api.PUT("/pipelines/:id/edges", handler.SaveEdges)
|
||||
api.POST("/pipelines/:id/runs", handler.CreateRun(hub))
|
||||
|
||||
api.GET("/runs", handler.ListRuns)
|
||||
api.GET("/runs/:runId", handler.GetRun)
|
||||
api.GET("/runs/:runId/logs/stream", handler.StreamLogs(hub))
|
||||
|
||||
api.GET("/gitops/apps", handler.ListGitOpsApps)
|
||||
api.GET("/gitops/argo/apps", handler.ArgoList)
|
||||
api.POST("/gitops/argo/sync", handler.ArgoSync)
|
||||
api.GET("/gitops/argo/:appName/tree", handler.ArgoResourceTree)
|
||||
api.GET("/gitops/argo/:appName/diff", handler.ArgoDiff)
|
||||
|
||||
api.GET("/plugins", handler.ListPlugins)
|
||||
api.GET("/plugins/available", handler.AvailablePlugins)
|
||||
api.POST("/plugins/install", handler.InstallPlugin)
|
||||
api.POST("/plugins/:id/toggle", handler.TogglePlugin)
|
||||
api.DELETE("/plugins/:id", handler.UninstallPlugin)
|
||||
}
|
||||
|
||||
distFS, _ := fs.Sub(staticFS, "static")
|
||||
r.NoRoute(func(c *gin.Context) {
|
||||
path := strings.TrimPrefix(c.Request.URL.Path, "/")
|
||||
if path != "" {
|
||||
if f, err := distFS.Open(path); err == nil {
|
||||
f.Close()
|
||||
c.FileFromFS("/"+path, http.FS(distFS))
|
||||
return
|
||||
}
|
||||
}
|
||||
// SPA 路由回退:直接返回 index.html 内容
|
||||
// (不能用 FileFromFS,http.FileServer 会把 /index.html 301 到 ./ 造成死循环)
|
||||
index, err := fs.ReadFile(distFS, "index.html")
|
||||
if err != nil {
|
||||
c.String(http.StatusNotFound, "前端资源未构建")
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "text/html; charset=utf-8", index)
|
||||
})
|
||||
|
||||
port := os.Getenv("PORT")
|
||||
if port == "" {
|
||||
port = "9527"
|
||||
}
|
||||
if err := r.Run(":" + port); err != nil {
|
||||
panic("服务启动失败: " + err.Error())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head><meta charset="UTF-8"><title>FlowStack</title></head>
|
||||
<body style="font-family: -apple-system, sans-serif; display: flex; align-items: center; justify-content: center; height: 100vh; margin: 0; background: #f6f8fa;">
|
||||
<div style="text-align: center; color: #57606a;">
|
||||
<h1 style="font-size: 20px;">FlowStack 前端尚未构建</h1>
|
||||
<p>请在项目根目录执行 <code style="background:#eaeef2;padding:2px 6px;border-radius:4px;">./run.sh build</code> 生成前端产物后重新编译。</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>FlowStack · 发布系统</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+6860
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,87 @@
|
||||
{
|
||||
"name": "flowstack-frontend",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"check": "tsc -b",
|
||||
"format": "prettier --write ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@hookform/resolvers": "^5.9.2",
|
||||
"@radix-ui/react-accordion": "^1.3.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.2.1",
|
||||
"@radix-ui/react-aspect-ratio": "^1.2.0",
|
||||
"@radix-ui/react-avatar": "^1.3.1",
|
||||
"@radix-ui/react-checkbox": "^1.4.1",
|
||||
"@radix-ui/react-collapsible": "^1.2.1",
|
||||
"@radix-ui/react-context-menu": "^2.4.1",
|
||||
"@radix-ui/react-dialog": "^1.3.1",
|
||||
"@radix-ui/react-dropdown-menu": "^2.2.1",
|
||||
"@radix-ui/react-hover-card": "^1.2.1",
|
||||
"@radix-ui/react-label": "^2.1.17",
|
||||
"@radix-ui/react-menubar": "^1.2.1",
|
||||
"@radix-ui/react-navigation-menu": "^1.4.1",
|
||||
"@radix-ui/react-popover": "^1.3.1",
|
||||
"@radix-ui/react-progress": "^1.2.1",
|
||||
"@radix-ui/react-radio-group": "^1.5.1",
|
||||
"@radix-ui/react-scroll-area": "^1.4.0",
|
||||
"@radix-ui/react-select": "^2.4.1",
|
||||
"@radix-ui/react-separator": "^1.2.0",
|
||||
"@radix-ui/react-slider": "^1.6.1",
|
||||
"@radix-ui/react-slot": "^1.5.0",
|
||||
"@radix-ui/react-switch": "^1.4.1",
|
||||
"@radix-ui/react-tabs": "^1.2.1",
|
||||
"@radix-ui/react-toggle": "^1.2.1",
|
||||
"@radix-ui/react-toggle-group": "^1.2.1",
|
||||
"@radix-ui/react-tooltip": "^1.4.1",
|
||||
"@tanstack/react-query": "^5.105.0",
|
||||
"@xyflow/react": "^12.12.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.4.0",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"input-otp": "^1.5.0",
|
||||
"lucide-react": "^0.562.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.3.1",
|
||||
"react-day-picker": "^9.14.0",
|
||||
"react-dom": "^19.3.1",
|
||||
"react-hook-form": "^7.91.0",
|
||||
"react-resizable-panels": "^4.15.0",
|
||||
"react-router": "^7.18.4",
|
||||
"recharts": "^2.15.4",
|
||||
"sonner": "^2.0.8",
|
||||
"tailwind-merge": "^3.7.0",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.6.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.39.5",
|
||||
"@types/node": "^24.19.2",
|
||||
"@types/react": "^19.3.0",
|
||||
"@types/react-dom": "^19.3.0",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"autoprefixer": "^10.4.23",
|
||||
"eslint": "^9.39.5",
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.7",
|
||||
"globals": "^16.5.0",
|
||||
"postcss": "^8.5.6",
|
||||
"prettier": "^3.9.10",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3",
|
||||
"typescript-eslint": "^8.72.1",
|
||||
"vite": "^7.3.7"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild@0.28.2": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Toaster } from 'sonner';
|
||||
import Sidebar from '@/components/Sidebar';
|
||||
import TopBar from '@/components/TopBar';
|
||||
import PipelineFlowEditor from '@/components/flow/PipelineFlowEditor';
|
||||
import BasicInfoPanel from '@/components/BasicInfoPanel';
|
||||
import TriggerPanel from '@/components/TriggerPanel';
|
||||
import VariablesPanel from '@/components/VariablesPanel';
|
||||
import ReleasePanel from '@/components/ReleasePanel';
|
||||
import PipelineList from '@/pages/PipelineList';
|
||||
import EnvironmentManager from '@/pages/EnvironmentManager';
|
||||
import CredentialManager from '@/pages/CredentialManager';
|
||||
import BuildNodeManager from '@/pages/BuildNodeManager';
|
||||
import ToolConfig from '@/pages/ToolConfig';
|
||||
import JenkinsImport from '@/pages/JenkinsImport';
|
||||
import BatchRelease from '@/pages/BatchRelease';
|
||||
import ReleaseRecords from '@/pages/ReleaseRecords';
|
||||
import WebhookConfig from '@/pages/WebhookConfig';
|
||||
import GitOpsDeploy from '@/pages/GitOpsDeploy';
|
||||
import PluginManager from '@/pages/PluginManager';
|
||||
import SystemSettings from '@/pages/SystemSettings';
|
||||
import { pipelineScenarios } from '@/data/pipelineData';
|
||||
|
||||
export type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings';
|
||||
export type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
|
||||
|
||||
export default function App() {
|
||||
// Module navigation
|
||||
const [activeModule, setActiveModule] = useState<ModuleType>('pipelines');
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
|
||||
|
||||
// Pipeline list/editor view toggle
|
||||
const [pipelineView, setPipelineView] = useState<'list' | 'editor'>('list');
|
||||
// Current editing pipeline ID for flow editor
|
||||
const [currentPipelineId, setCurrentPipelineId] = useState<number>(1);
|
||||
// Pipeline editor state (only used when in pipeline editor mode)
|
||||
const [activeScenario, setActiveScenario] = useState<'go-backend' | 'vue-frontend'>('go-backend');
|
||||
const [activeTab, setActiveTab] = useState<EditorTab>('流程配置');
|
||||
const [isSimulating, setIsSimulating] = useState(false);
|
||||
const [executionStatus, setExecutionStatus] = useState<'idle' | 'running' | 'success' | 'failed'>('idle');
|
||||
|
||||
const scenario = pipelineScenarios[activeScenario];
|
||||
|
||||
const handleScenarioChange = (key: 'go-backend' | 'vue-frontend') => {
|
||||
setActiveScenario(key);
|
||||
};
|
||||
|
||||
const handleSelectPipeline = useCallback((id: string) => {
|
||||
setCurrentPipelineId(Number(id) || 1);
|
||||
setPipelineView('editor');
|
||||
setActiveTab('流程配置');
|
||||
}, []);
|
||||
|
||||
const handleSimulateRun = useCallback(() => {
|
||||
setIsSimulating(true);
|
||||
setExecutionStatus('running');
|
||||
setTimeout(() => {
|
||||
setIsSimulating(false);
|
||||
setExecutionStatus('success');
|
||||
}, 3000);
|
||||
}, []);
|
||||
|
||||
// Render content based on active module
|
||||
const renderContent = () => {
|
||||
switch (activeModule) {
|
||||
case 'pipelines':
|
||||
if (pipelineView === 'list') {
|
||||
return <PipelineList onSelectPipeline={handleSelectPipeline} />;
|
||||
}
|
||||
// Pipeline editor mode
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<TopBar
|
||||
pipelineName={scenario.name}
|
||||
activeTab={activeTab}
|
||||
onTabChange={setActiveTab}
|
||||
onSimulateRun={handleSimulateRun}
|
||||
isSimulating={isSimulating}
|
||||
scenarioKey={activeScenario}
|
||||
onScenarioChange={handleScenarioChange}
|
||||
executionStatus={executionStatus}
|
||||
onBack={() => setPipelineView('list')}
|
||||
/>
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{activeTab === '流程配置' && (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<PipelineFlowEditor pipelineId={currentPipelineId} />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === '基本信息' && (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<BasicInfoPanel />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === '触发设置' && (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<TriggerPanel />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === '变量和缓存' && (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<VariablesPanel />
|
||||
</div>
|
||||
)}
|
||||
{activeTab === '发布' && (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<ReleasePanel />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
case 'environments':
|
||||
return <EnvironmentManager />;
|
||||
case 'credentials':
|
||||
return <CredentialManager />;
|
||||
case 'build-nodes':
|
||||
return <BuildNodeManager />;
|
||||
case 'tools':
|
||||
return <ToolConfig />;
|
||||
case 'jenkins-import':
|
||||
return <JenkinsImport />;
|
||||
case 'batch-release':
|
||||
return <BatchRelease />;
|
||||
case 'release-records':
|
||||
return <ReleaseRecords />;
|
||||
case 'webhooks':
|
||||
return <WebhookConfig />;
|
||||
case 'gitops':
|
||||
return <GitOpsDeploy />;
|
||||
case 'plugins':
|
||||
return <PluginManager />;
|
||||
case 'settings':
|
||||
return <SystemSettings />;
|
||||
default:
|
||||
return <PipelineList onSelectPipeline={handleSelectPipeline} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex overflow-hidden" style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }}>
|
||||
<Toaster position="top-right" richColors />
|
||||
<Sidebar
|
||||
activeModule={activeModule}
|
||||
onModuleChange={setActiveModule}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
{renderContent()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useState } from 'react';
|
||||
import { Bell, Info, TestTube } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function BasicInfoPanel() {
|
||||
const [pipelineName, setPipelineName] = useState('gateway-pre-serverless');
|
||||
const [description, setDescription] = useState('');
|
||||
|
||||
// Feishu notification
|
||||
const [feishuEnabled, setFeishuEnabled] = useState(false);
|
||||
const [feishuWebhook, setFeishuWebhook] = useState('');
|
||||
const [feishuTiming, setFeishuTiming] = useState('all');
|
||||
|
||||
// DingTalk notification
|
||||
const [dingtalkEnabled, setDingtalkEnabled] = useState(false);
|
||||
const [dingtalkWebhook, setDingtalkWebhook] = useState('');
|
||||
const [dingtalkTiming, setDingtalkTiming] = useState('all');
|
||||
|
||||
const handleTestConnection = (type: 'feishu' | 'dingtalk') => {
|
||||
toast.success(`${type === 'feishu' ? '飞书' : '钉钉'} Webhook 连接测试成功`, {
|
||||
description: '通知服务可正常访问',
|
||||
});
|
||||
};
|
||||
|
||||
const timingOptions = [
|
||||
{ value: 'all', label: '全部事件' },
|
||||
{ value: 'success', label: '仅成功' },
|
||||
{ value: 'failure', label: '仅失败' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="tab-content-fade space-y-6 w-full px-6 py-6">
|
||||
{/* Basic Information */}
|
||||
<div
|
||||
className="rounded-lg border p-6 shadow-sm"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--border-color)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<Info className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||
<h3
|
||||
className="text-base font-semibold"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
基本信息
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-x-6 gap-y-4">
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
|
||||
流水线名称
|
||||
</Label>
|
||||
<Input
|
||||
value={pipelineName}
|
||||
onChange={(e) => setPipelineName(e.target.value)}
|
||||
placeholder="输入流水线名称"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-sm font-medium mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
|
||||
描述
|
||||
</Label>
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="输入流水线描述(可选)"
|
||||
rows={1}
|
||||
className="w-full px-3 py-2 text-sm rounded-md border outline-none focus:ring-1 focus:ring-[var(--accent-blue)] focus:border-transparent transition-all resize-none h-9"
|
||||
style={{
|
||||
background: 'var(--canvas-bg)',
|
||||
borderColor: 'var(--border-color)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label className="text-xs mb-1 block" style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
|
||||
创建人
|
||||
</Label>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
admin
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs mb-1 block" style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
|
||||
创建时间
|
||||
</Label>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
2024-06-20
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs mb-1 block" style={{ color: 'var(--text-secondary)', opacity: 0.7 }}>
|
||||
最近执行
|
||||
</Label>
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
未执行
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Notification Settings */}
|
||||
<div
|
||||
className="rounded-lg border p-6 shadow-sm"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--border-color)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-5">
|
||||
<Bell className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||
<h3
|
||||
className="text-base font-semibold"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
通知设置
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Feishu */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
飞书通知
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>通过飞书群机器人发送流水线执行通知</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch checked={feishuEnabled} onCheckedChange={setFeishuEnabled} />
|
||||
</div>
|
||||
|
||||
{feishuEnabled && (
|
||||
<div className="tab-content-fade pl-0 space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
|
||||
Webhook 地址
|
||||
</Label>
|
||||
<Input
|
||||
value={feishuWebhook}
|
||||
onChange={(e) => setFeishuWebhook(e.target.value)}
|
||||
placeholder="https://open.feishu.cn/open-apis/bot/v2/hook/..."
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs mb-2 block" style={{ color: 'var(--text-secondary)' }}>
|
||||
通知时机
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={feishuTiming}
|
||||
onValueChange={setFeishuTiming}
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
{timingOptions.map((opt) => (
|
||||
<div key={opt.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={opt.value} id={`feishu-${opt.value}`} />
|
||||
<Label htmlFor={`feishu-${opt.value}`} className="text-sm cursor-pointer">
|
||||
{opt.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleTestConnection('feishu')}
|
||||
className="h-8"
|
||||
>
|
||||
<TestTube className="w-3.5 h-3.5 mr-1.5" />
|
||||
测试连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator style={{ backgroundColor: 'var(--border-color)' }} />
|
||||
|
||||
{/* DingTalk */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium" style={{ color: 'var(--text-primary)' }}>
|
||||
钉钉通知
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>通过钉钉群机器人发送流水线执行通知</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Switch checked={dingtalkEnabled} onCheckedChange={setDingtalkEnabled} />
|
||||
</div>
|
||||
|
||||
{dingtalkEnabled && (
|
||||
<div className="tab-content-fade pl-0 space-y-3">
|
||||
<div>
|
||||
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
|
||||
Webhook 地址
|
||||
</Label>
|
||||
<Input
|
||||
value={dingtalkWebhook}
|
||||
onChange={(e) => setDingtalkWebhook(e.target.value)}
|
||||
placeholder="https://oapi.dingtalk.com/robot/send?access_token=..."
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs mb-2 block" style={{ color: 'var(--text-secondary)' }}>
|
||||
通知时机
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={dingtalkTiming}
|
||||
onValueChange={setDingtalkTiming}
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
{timingOptions.map((opt) => (
|
||||
<div key={opt.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem value={opt.value} id={`dingtalk-${opt.value}`} />
|
||||
<Label htmlFor={`dingtalk-${opt.value}`} className="text-sm cursor-pointer">
|
||||
{opt.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
</div>
|
||||
<div className="pt-1">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleTestConnection('dingtalk')}
|
||||
className="h-8"
|
||||
>
|
||||
<TestTube className="w-3.5 h-3.5 mr-1.5" />
|
||||
测试连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus, ChevronDown, ChevronRight, ChevronLeft, Box, Layers, GitBranch, Container, Terminal, Shield, Package, BookOpen, X } from 'lucide-react';
|
||||
import type { PipelineStage } from '@/types/pipeline';
|
||||
import StageCard from './StageCard';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface TektonTask {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: typeof Box;
|
||||
category: 'source' | 'build' | 'test' | 'security' | 'deploy' | 'gitops';
|
||||
params: { name: string; type: string; default?: string; description: string }[];
|
||||
workspaces: string[];
|
||||
results?: string[];
|
||||
}
|
||||
|
||||
const taskCatalog: TektonTask[] = [
|
||||
{ id: 'tk-git-clone', name: 'git-clone', description: '从 Git 仓库克隆代码', icon: GitBranch, category: 'source', params: [{ name: 'url', type: 'string', description: 'Git 仓库 URL' }, { name: 'revision', type: 'string', default: 'main', description: '分支或标签' }], workspaces: ['source'] },
|
||||
{ id: 'tk-maven', name: 'maven', description: 'Maven 构建 Java 项目', icon: Package, category: 'build', params: [{ name: 'goals', type: 'array', default: 'clean package', description: 'Maven 目标' }, { name: 'settings', type: 'string', description: 'settings.xml 路径' }], workspaces: ['source', 'm2-cache'] },
|
||||
{ id: 'tk-npm', name: 'npm', description: 'npm install + build', icon: Package, category: 'build', params: [{ name: 'script', type: 'string', default: 'build', description: 'npm 脚本' }], workspaces: ['source'] },
|
||||
{ id: 'tk-go-build', name: 'go-build', description: 'Go 编译', icon: Package, category: 'build', params: [{ name: 'package', type: 'string', description: '包路径' }, { name: 'flags', type: 'string', description: '编译参数' }], workspaces: ['source'] },
|
||||
{ id: 'tk-kaniko', name: 'kaniko-build', description: '无需特权构建 Docker 镜像', icon: Container, category: 'build', params: [{ name: 'dockerfile', type: 'string', default: 'Dockerfile', description: 'Dockerfile 路径' }, { name: 'image', type: 'string', description: '目标镜像地址' }], workspaces: ['source'], results: ['IMAGE_DIGEST'] },
|
||||
{ id: 'tk-trivy', name: 'trivy-scan', description: '容器镜像漏洞扫描', icon: Shield, category: 'security', params: [{ name: 'image', type: 'string', description: '待扫描镜像' }, { name: 'severity', type: 'string', default: 'HIGH,CRITICAL', description: '严重等级' }], workspaces: [] },
|
||||
{ id: 'tk-unit-test', name: 'unit-test', description: '运行单元测试', icon: Terminal, category: 'test', params: [{ name: 'test-command', type: 'string', description: '测试命令' }], workspaces: ['source'] },
|
||||
{ id: 'tk-kubectl', name: 'kubectl-deploy', description: 'kubectl apply 部署', icon: Layers, category: 'deploy', params: [{ name: 'manifest', type: 'string', description: 'manifest 路径' }, { name: 'namespace', type: 'string', description: '目标命名空间' }], workspaces: ['source'] },
|
||||
{ id: 'tk-update-manifest', name: 'update-manifest', description: '更新 GitOps 仓库镜像标签', icon: BookOpen, category: 'gitops', params: [{ name: 'repo', type: 'string', description: 'GitOps 仓库' }, { name: 'image', type: 'string', description: '新镜像地址' }, { name: 'path', type: 'string', description: 'manifest 路径' }], workspaces: ['source'] },
|
||||
{ id: 'tk-helm-upgrade', name: 'helm-upgrade', description: 'Helm 部署/升级', icon: Layers, category: 'deploy', params: [{ name: 'release', type: 'string', description: 'Release 名称' }, { name: 'chart', type: 'string', description: 'Chart 路径' }, { name: 'namespace', type: 'string', description: '命名空间' }], workspaces: ['source'] },
|
||||
];
|
||||
|
||||
const categoryConfig: Record<string, { label: string; color: string }> = {
|
||||
source: { label: '源码', color: '#0969da' },
|
||||
build: { label: '构建', color: '#1a7f37' },
|
||||
test: { label: '测试', color: '#9a6700' },
|
||||
security: { label: '安全', color: '#cf222e' },
|
||||
deploy: { label: '部署', color: '#8250df' },
|
||||
gitops: { label: 'GitOps', color: '#0e7c7b' },
|
||||
};
|
||||
|
||||
interface PipelineStageEditorProps {
|
||||
stages: PipelineStage[];
|
||||
selectedTaskId: string | null;
|
||||
onSelectTask: (taskId: string) => void;
|
||||
onUpdateStages: (stages: PipelineStage[]) => void;
|
||||
}
|
||||
|
||||
export default function PipelineStageEditor({
|
||||
stages,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
onUpdateStages,
|
||||
}: PipelineStageEditorProps) {
|
||||
const [catalogOpen, setCatalogOpen] = useState(true);
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [showWorkspaceConfig, setShowWorkspaceConfig] = useState(false);
|
||||
|
||||
const handleMoveStage = (index: number, direction: 'up' | 'down') => {
|
||||
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||
if (newIndex < 0 || newIndex >= stages.length) return;
|
||||
const newStages = [...stages];
|
||||
[newStages[index], newStages[newIndex]] = [newStages[newIndex], newStages[index]];
|
||||
onUpdateStages(newStages);
|
||||
};
|
||||
|
||||
const handleDeleteStage = (index: number) => {
|
||||
const newStages = stages.filter((_, i) => i !== index);
|
||||
onUpdateStages(newStages);
|
||||
};
|
||||
|
||||
const handleUpdateStageName = (index: number, name: string) => {
|
||||
const newStages = stages.map((s, i) => (i === index ? { ...s, name } : s));
|
||||
onUpdateStages(newStages);
|
||||
};
|
||||
|
||||
const handleAddStage = () => {
|
||||
const newStage: PipelineStage = {
|
||||
id: `stage-${Date.now()}`,
|
||||
name: '新阶段',
|
||||
type: 'custom',
|
||||
tasks: [],
|
||||
};
|
||||
onUpdateStages([...stages, newStage]);
|
||||
};
|
||||
|
||||
const addTaskFromCatalog = (task: TektonTask) => {
|
||||
// Add to last stage or create new stage
|
||||
if (stages.length === 0) {
|
||||
handleAddStage();
|
||||
return;
|
||||
}
|
||||
const lastStageIndex = stages.length - 1;
|
||||
const newTask: any = {
|
||||
id: `${task.id}-${Date.now()}`,
|
||||
name: task.name,
|
||||
type: (task.category === 'deploy' || task.category === 'gitops' ? 'deploy' : task.category === 'build' ? 'build' : 'custom') as 'build' | 'deploy' | 'test' | 'custom',
|
||||
icon: task.category,
|
||||
config: {
|
||||
params: task.params.reduce((acc, p) => ({ ...acc, [p.name]: p.default || '' }), {} as Record<string, string>),
|
||||
workspaces: task.workspaces.reduce((acc, w) => ({ ...acc, [w]: w }), {} as Record<string, string>),
|
||||
results: task.results || [],
|
||||
},
|
||||
};
|
||||
const newStages = stages.map((s, i) =>
|
||||
i === lastStageIndex ? { ...s, tasks: [...s.tasks, newTask] } : s
|
||||
);
|
||||
onUpdateStages(newStages);
|
||||
toast.success(`已添加 Task: ${task.name}`, { description: `${task.workspaces.length} 个 Workspace` });
|
||||
};
|
||||
|
||||
const filteredTasks = selectedCategory === 'all' ? taskCatalog : taskCatalog.filter(t => t.category === selectedCategory);
|
||||
|
||||
return (
|
||||
<div className="min-h-full flex" style={{ background: 'var(--canvas-bg)' }}>
|
||||
{/* Task Catalog Sidebar */}
|
||||
<div
|
||||
className="border-r flex-shrink-0 transition-all duration-300 overflow-hidden"
|
||||
style={{
|
||||
width: catalogOpen ? '260px' : '0px',
|
||||
borderColor: 'var(--border-color)',
|
||||
background: 'var(--panel-bg)',
|
||||
}}
|
||||
>
|
||||
<div className="w-[260px] h-full flex flex-col">
|
||||
<div className="px-3 py-2.5 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>Task 任务库</span>
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>Tekton</span>
|
||||
</div>
|
||||
<div className="p-2 border-b" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<button onClick={() => setSelectedCategory('all')}
|
||||
className="text-[10px] px-2 py-[3px] rounded font-medium transition-colors"
|
||||
style={{ background: selectedCategory === 'all' ? 'var(--accent-blue)' : 'var(--panel-bg-secondary)', color: selectedCategory === 'all' ? '#fff' : 'var(--text-tertiary)' }}>
|
||||
全部
|
||||
</button>
|
||||
{Object.entries(categoryConfig).map(([key, cfg]) => (
|
||||
<button key={key} onClick={() => setSelectedCategory(key)}
|
||||
className="text-[10px] px-2 py-[3px] rounded font-medium transition-colors"
|
||||
style={{ background: selectedCategory === key ? cfg.color : 'var(--panel-bg-secondary)', color: selectedCategory === key ? '#fff' : 'var(--text-tertiary)' }}>
|
||||
{cfg.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
|
||||
{filteredTasks.map(task => {
|
||||
const Icon = task.icon;
|
||||
const cat = categoryConfig[task.category];
|
||||
return (
|
||||
<div key={task.id}
|
||||
onClick={() => addTaskFromCatalog(task)}
|
||||
className="p-2.5 rounded-md border cursor-pointer hover:bg-black/[0.02] transition-colors group"
|
||||
style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Icon className="w-3.5 h-3.5" style={{ color: cat.color }} />
|
||||
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{task.name}</span>
|
||||
<span className="text-[9px] px-1 py-[1px] rounded font-medium ml-auto" style={{ background: cat.color + '15', color: cat.color }}>{cat.label}</span>
|
||||
</div>
|
||||
<p className="text-[10px] mb-1" style={{ color: 'var(--text-secondary)' }}>{task.description}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{task.workspaces.length > 0 && (
|
||||
<span className="text-[9px] flex items-center gap-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<Box className="w-2.5 h-2.5" />{task.workspaces.length} ws
|
||||
</span>
|
||||
)}
|
||||
{task.params.length > 0 && (
|
||||
<span className="text-[9px]" style={{ color: 'var(--text-tertiary)' }}>{task.params.length} params</span>
|
||||
)}
|
||||
{task.results && (
|
||||
<span className="text-[9px]" style={{ color: 'var(--text-tertiary)' }}>{task.results.length} results</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="px-3 py-2 border-t text-[10px]" style={{ borderColor: 'var(--border-color)', color: 'var(--text-tertiary)' }}>
|
||||
点击 Task 添加到流水线末尾阶段
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Editor */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="px-6 py-5">
|
||||
{/* Pipeline metadata bar */}
|
||||
<div className="flex items-center justify-between mb-5 w-full" style={{ maxWidth: '720px', marginLeft: 'auto', marginRight: 'auto' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={() => setCatalogOpen(!catalogOpen)}
|
||||
className="flex items-center gap-1 px-2 py-[5px] text-[11px] font-medium rounded-md border"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||
title={catalogOpen ? '收起任务库' : '展开任务库'}
|
||||
>
|
||||
{catalogOpen ? <ChevronLeft className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
||||
Task 库
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold tracking-tight" style={{ color: 'var(--text-primary)' }}>
|
||||
流水线阶段
|
||||
</span>
|
||||
<span className="text-[11px] font-medium px-1.5 py-0.5 rounded-full" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||
{stages.length}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowWorkspaceConfig(!showWorkspaceConfig)}
|
||||
className="flex items-center gap-1 px-2 py-[5px] text-[11px] font-medium rounded-md border"
|
||||
style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}
|
||||
>
|
||||
<Box className="w-3 h-3" />Workspace
|
||||
</button>
|
||||
<button
|
||||
onClick={handleAddStage}
|
||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[12px] font-medium rounded-md text-white transition-all hover:brightness-110 active:scale-[0.98]"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />添加阶段
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Workspace config panel */}
|
||||
{showWorkspaceConfig && (
|
||||
<div className="mb-4 rounded-lg border p-3 mx-auto" style={{ maxWidth: '720px', borderColor: 'var(--border-color)', background: 'var(--panel-bg)' }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>Pipeline Workspace 配置</span>
|
||||
<button onClick={() => setShowWorkspaceConfig(false)} className="p-0.5 rounded hover:bg-black/[0.02]" style={{ color: 'var(--text-tertiary)' }}><X className="w-3.5 h-3.5" /></button>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{['source', 'm2-cache', 'docker-config'].map(ws => (
|
||||
<div key={ws} className="flex items-center gap-2 p-2 rounded-md border text-[11px]" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<Box className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span style={{ color: 'var(--text-primary)' }}>{ws}</span>
|
||||
<select className="ml-auto text-[10px] rounded border outline-none px-1 py-[2px]" style={{ borderColor: 'var(--border-color-light)', background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
|
||||
<option>EmptyDir</option><option>PVC</option><option>ConfigMap</option><option>Secret</option>
|
||||
</select>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stage list */}
|
||||
<div className="flex flex-col items-center" style={{ maxWidth: '720px', marginLeft: 'auto', marginRight: 'auto' }}>
|
||||
{stages.map((stage, index) => (
|
||||
<div key={stage.id} className="w-full flex flex-col items-center">
|
||||
<StageCard
|
||||
stage={stage}
|
||||
index={index}
|
||||
isFirst={index === 0}
|
||||
isLast={index === stages.length - 1}
|
||||
selectedTaskId={selectedTaskId}
|
||||
onSelectTask={onSelectTask}
|
||||
onMoveUp={() => handleMoveStage(index, 'up')}
|
||||
onMoveDown={() => handleMoveStage(index, 'down')}
|
||||
onDelete={() => handleDeleteStage(index)}
|
||||
onUpdateName={(name) => handleUpdateStageName(index, name)}
|
||||
/>
|
||||
{index < stages.length - 1 && (
|
||||
<div className="flex flex-col items-center py-2">
|
||||
<div className="flex flex-col items-center gap-[2px]">
|
||||
<div className="w-px h-3" style={{ background: 'var(--border-color)' }} />
|
||||
<ChevronDown className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<div className="w-px h-3" style={{ background: 'var(--border-color)' }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{stages.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 rounded-lg border-2 border-dashed mx-auto" style={{ maxWidth: '720px', borderColor: 'var(--border-color-light)', background: 'var(--panel-bg)' }}>
|
||||
<div className="w-12 h-12 rounded-full flex items-center justify-center mb-3" style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
<Plus className="w-5 h-5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</div>
|
||||
<p className="text-[13px] font-medium mb-1" style={{ color: 'var(--text-secondary)' }}>暂无阶段</p>
|
||||
<p className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>从左侧 Task 库选择任务,或添加阶段</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Clock,
|
||||
RotateCcw,
|
||||
Eye,
|
||||
GitCommit,
|
||||
Hand,
|
||||
Timer,
|
||||
} from 'lucide-react';
|
||||
import type { ExecutionRecord } from '@/types/pipeline';
|
||||
|
||||
const mockExecutions: ExecutionRecord[] = [
|
||||
{
|
||||
id: 5,
|
||||
status: 'running',
|
||||
trigger: 'Manual',
|
||||
triggerBy: 'admin',
|
||||
duration: '1分12秒',
|
||||
timestamp: '2024-06-20 15:45:00',
|
||||
pipelineName: 'aiops-backend',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
status: 'success',
|
||||
trigger: 'Git Push',
|
||||
triggerBy: 'system',
|
||||
duration: '3分22秒',
|
||||
timestamp: '2024-06-20 14:30:15',
|
||||
pipelineName: 'aiops-backend',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
status: 'failed',
|
||||
trigger: 'Manual',
|
||||
triggerBy: 'admin',
|
||||
duration: '45秒',
|
||||
timestamp: '2024-06-20 13:20:00',
|
||||
pipelineName: 'aiops-backend',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
status: 'success',
|
||||
trigger: 'Cron',
|
||||
triggerBy: 'system',
|
||||
duration: '5分10秒',
|
||||
timestamp: '2024-06-20 02:00:00',
|
||||
pipelineName: 'aiops-backend',
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
status: 'success',
|
||||
trigger: 'Git Push',
|
||||
triggerBy: 'system',
|
||||
duration: '4分55秒',
|
||||
timestamp: '2024-06-19 18:45:30',
|
||||
pipelineName: 'aiops-backend',
|
||||
},
|
||||
];
|
||||
|
||||
const fullLogLines: string[] = [
|
||||
'[15:45:00] ===== 流水线开始执行: aiops-backend =====',
|
||||
'[15:45:00] 触发方式: 手动 (admin)',
|
||||
'',
|
||||
'[15:45:01] ===== 阶段: 拉取代码 =====',
|
||||
'[15:45:01] > Git 拉取',
|
||||
'[15:45:03] Cloning into \'aiops\'...',
|
||||
'[15:45:08] Already up to date. commit: 7a3f2e1',
|
||||
'[15:45:08] \u2713 Git 拉取 完成 (7s)',
|
||||
'',
|
||||
'[15:45:08] ===== 阶段: 构建镜像 =====',
|
||||
'[15:45:09] > Docker 构建 & 推送',
|
||||
'[15:45:10] === 开始构建 Docker 镜像 ===',
|
||||
'[15:45:10] 镜像地址: registry.example.com/aiops/aiops-backend:202506201545',
|
||||
'[15:45:11] Docker登录...',
|
||||
'[15:45:12] Login Succeeded',
|
||||
'[15:45:15] Building image...',
|
||||
'[15:45:45] Successfully built 9a8b7c6d5e4f',
|
||||
'[15:45:46] 推送镜像...',
|
||||
'[15:46:02] latest: digest: sha256:abc123... size: 2847',
|
||||
'[15:46:02] \u2713 Docker 构建 & 推送 完成 (53s)',
|
||||
'[15:46:02] > 单元测试',
|
||||
'[15:46:03] go test -v -race -coverprofile=coverage.out ./...',
|
||||
'[15:46:15] PASS: TestUserLogin (0.23s)',
|
||||
'[15:46:15] PASS: TestDataQuery (1.12s)',
|
||||
'[15:46:18] PASS: TestWebhookHandler (0.45s)',
|
||||
'[15:46:20] coverage: 82.3% of statements',
|
||||
'[15:46:20] \u2713 单元测试 完成 (18s) \u2014 覆盖率: 82.3%',
|
||||
'',
|
||||
'[15:46:20] ===== 阶段: K8s 部署 =====',
|
||||
'[15:46:21] > K8s 滚动更新',
|
||||
'[15:46:22] 更新镜像: registry.example.com/aiops/aiops-backend:202506201545',
|
||||
'[15:46:25] deployment.apps/aiops-backend image updated',
|
||||
'[15:46:25] 等待滚动更新...',
|
||||
'[15:46:30] [=====================> ] 2/3 pods ready',
|
||||
'[15:46:35] [========================>] 3/3 pods ready',
|
||||
'[15:46:36] deployment "aiops-backend" successfully rolled out',
|
||||
'[15:46:36] Pod状态:',
|
||||
'[15:46:36] NAME READY STATUS',
|
||||
'[15:46:36] aiops-backend-7a3f2e1-x1 1/1 Running',
|
||||
'[15:46:36] aiops-backend-7a3f2e1-x2 1/1 Running',
|
||||
'[15:46:36] aiops-backend-7a3f2e1-x3 1/1 Running',
|
||||
'[15:46:36] \u2713 K8s 滚动更新 完成 (15s)',
|
||||
'',
|
||||
'[15:46:37] ===== 阶段: 后置操作 =====',
|
||||
'[15:46:37] > 刷新CDN',
|
||||
'[15:46:37] 刷新CDN缓存...',
|
||||
'[15:46:38] {"code": 200, "message": "refresh submitted"}',
|
||||
'[15:46:38] \u2713 刷新CDN 完成 (1s)',
|
||||
'',
|
||||
'[15:46:38] ===== 流水线执行完成 =====',
|
||||
'[15:46:38] 总耗时: 1分38秒',
|
||||
'[15:46:38] 结果: \u2713 成功',
|
||||
];
|
||||
|
||||
function getStatusIcon(status: string, className = 'w-5 h-5') {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircle2 className={`${className} text-green-500`} />;
|
||||
case 'failed':
|
||||
return <XCircle className={`${className} text-red-500`} />;
|
||||
case 'running':
|
||||
return <Loader2 className={`${className} text-blue-500 animate-spin`} />;
|
||||
default:
|
||||
return <Clock className={`${className} text-gray-400`} />;
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusText(status: string) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return '成功';
|
||||
case 'failed':
|
||||
return '失败';
|
||||
case 'running':
|
||||
return '正在执行';
|
||||
default:
|
||||
return '等待中';
|
||||
}
|
||||
}
|
||||
|
||||
function getTriggerIcon(trigger: string, className = 'w-3.5 h-3.5') {
|
||||
switch (trigger) {
|
||||
case 'Git Push':
|
||||
return <GitCommit className={className} />;
|
||||
case 'Manual':
|
||||
return <Hand className={className} />;
|
||||
case 'Cron':
|
||||
return <Timer className={className} />;
|
||||
default:
|
||||
return <Clock className={className} />;
|
||||
}
|
||||
}
|
||||
|
||||
function LogLine({ line }: { line: string }) {
|
||||
if (!line) {
|
||||
return <div className="h-4" />;
|
||||
}
|
||||
|
||||
const timestamp = line.match(/^\[(\d{2}:\d{2}:\d{2})\]/)?.[0] || '';
|
||||
const content = line.slice(timestamp.length);
|
||||
|
||||
// Determine color based on content
|
||||
let contentColor = '#e6e6e6';
|
||||
if (content.includes('\u2713') || content.includes('成功') || content.includes('PASS')) {
|
||||
contentColor = '#7ee787';
|
||||
} else if (content.includes('失败') || content.includes('error') || content.includes('Error')) {
|
||||
contentColor = '#f85149';
|
||||
} else if (content.includes('警告') || content.includes('warn') || content.includes('Waiting')) {
|
||||
contentColor = '#d29922';
|
||||
} else if (content.startsWith(' =====')) {
|
||||
contentColor = '#79c0ff';
|
||||
} else if (content.startsWith(' > ')) {
|
||||
contentColor = '#d2a8ff';
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="font-mono text-sm leading-relaxed whitespace-pre-wrap">
|
||||
{timestamp && (
|
||||
<span style={{ color: '#8b949e' }}>{timestamp}</span>
|
||||
)}
|
||||
<span style={{ color: contentColor }}>{content}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ReleasePanel() {
|
||||
const [selectedExecutionId, setSelectedExecutionId] = useState(5);
|
||||
const [displayedLines, setDisplayedLines] = useState<string[]>([]);
|
||||
const [isAnimating, setIsAnimating] = useState(false);
|
||||
const logEndRef = useRef<HTMLDivElement>(null);
|
||||
const animatingRef = useRef(false);
|
||||
|
||||
const selectedExecution = mockExecutions.find((e) => e.id === selectedExecutionId);
|
||||
const isRunning = selectedExecution?.status === 'running';
|
||||
|
||||
// Animate log lines for running execution
|
||||
useEffect(() => {
|
||||
if (isRunning) {
|
||||
setDisplayedLines([]);
|
||||
setIsAnimating(true);
|
||||
animatingRef.current = true;
|
||||
let index = 0;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
if (!animatingRef.current) {
|
||||
clearInterval(interval);
|
||||
return;
|
||||
}
|
||||
|
||||
if (index < fullLogLines.length) {
|
||||
setDisplayedLines((prev) => [...prev, fullLogLines[index]]);
|
||||
index++;
|
||||
} else {
|
||||
setIsAnimating(false);
|
||||
animatingRef.current = false;
|
||||
clearInterval(interval);
|
||||
}
|
||||
}, 80); // Each line appears every 80ms
|
||||
|
||||
return () => {
|
||||
animatingRef.current = false;
|
||||
clearInterval(interval);
|
||||
};
|
||||
} else {
|
||||
// Show full log immediately for completed executions
|
||||
setDisplayedLines(fullLogLines);
|
||||
setIsAnimating(false);
|
||||
}
|
||||
}, [selectedExecutionId, isRunning]);
|
||||
|
||||
// Auto-scroll to bottom
|
||||
useEffect(() => {
|
||||
if (logEndRef.current) {
|
||||
logEndRef.current.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}, [displayedLines]);
|
||||
|
||||
return (
|
||||
<div className="flex h-full" style={{ background: 'var(--panel-bg)' }}>
|
||||
{/* Left: Execution History (40%) */}
|
||||
<div
|
||||
className="w-[40%] border-r flex flex-col overflow-hidden"
|
||||
style={{
|
||||
borderColor: 'var(--border-color)',
|
||||
minWidth: '340px',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 border-b"
|
||||
style={{ borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
执行记录
|
||||
</span>
|
||||
<span className="text-xs px-2 py-0.5 rounded-full" style={{ color: 'var(--text-secondary)', background: 'var(--panel-bg-secondary)' }}>
|
||||
{mockExecutions.length} 条记录
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{mockExecutions.map((execution) => (
|
||||
<div
|
||||
key={execution.id}
|
||||
onClick={() => setSelectedExecutionId(execution.id)}
|
||||
className={`px-4 py-3 border-b cursor-pointer transition-colors ${
|
||||
selectedExecutionId === execution.id ? 'bg-blue-50/50' : 'hover:bg-[var(--panel-bg-secondary)]'
|
||||
}`}
|
||||
style={{ borderColor: 'var(--border-color-light)' }}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{getStatusIcon(execution.status)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span
|
||||
className="text-sm font-semibold"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
>
|
||||
#{execution.id}
|
||||
</span>
|
||||
<span
|
||||
className="text-xs px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background:
|
||||
execution.status === 'success'
|
||||
? '#dafbe1'
|
||||
: execution.status === 'failed'
|
||||
? '#ffebe9'
|
||||
: execution.status === 'running'
|
||||
? '#ddf4ff'
|
||||
: '#f6f8fa',
|
||||
color:
|
||||
execution.status === 'success'
|
||||
? '#1a7f37'
|
||||
: execution.status === 'failed'
|
||||
? '#cf222e'
|
||||
: execution.status === 'running'
|
||||
? '#0969da'
|
||||
: '#6e7781',
|
||||
}}
|
||||
>
|
||||
{getStatusText(execution.status)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 text-xs" style={{ color: 'var(--text-secondary)' }}>
|
||||
<span className="flex items-center gap-1">
|
||||
{getTriggerIcon(execution.trigger)}
|
||||
{execution.trigger}
|
||||
</span>
|
||||
<span>{execution.triggerBy}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{execution.duration}
|
||||
</span>
|
||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{execution.timestamp}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-1">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-[var(--panel-bg-secondary)] transition-colors"
|
||||
title="重新执行"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-[var(--panel-bg-secondary)] transition-colors"
|
||||
title="查看日志"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedExecutionId(execution.id);
|
||||
}}
|
||||
>
|
||||
<Eye className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Log Viewer (60%) */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden min-w-0">
|
||||
{/* Log header */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-3 border-b"
|
||||
style={{ borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--text-primary)' }}>
|
||||
执行日志
|
||||
</span>
|
||||
{isRunning && isAnimating && (
|
||||
<span className="text-xs px-2 py-0.5 rounded-full bg-blue-50 text-blue-600 animate-pulse">
|
||||
实时输出中...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{selectedExecution?.pipelineName} #{selectedExecutionId}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Log content */}
|
||||
<div
|
||||
className="flex-1 overflow-y-auto custom-scrollbar px-4 py-3"
|
||||
style={{ background: '#1e1e1e' }}
|
||||
>
|
||||
{displayedLines.map((line, index) => (
|
||||
<LogLine key={`${selectedExecutionId}-${index}`} line={line} />
|
||||
))}
|
||||
{isAnimating && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<div className="w-2 h-2 rounded-full bg-green-400 animate-pulse" />
|
||||
<span className="text-xs" style={{ color: '#8b949e' }}>
|
||||
等待新输出...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={logEndRef} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,237 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
|
||||
interface ScriptEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
language: 'shell' | 'python';
|
||||
theme?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
// Basic syntax highlighting patterns
|
||||
const shellKeywords = ['if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'case', 'esac', 'function', 'return', 'exit', 'echo', 'printf', 'read', 'source', 'export', 'unset', 'local', 'shift', 'break', 'continue', 'in', 'trap'];
|
||||
const pythonKeywords = ['and', 'as', 'assert', 'break', 'class', 'continue', 'def', 'del', 'elif', 'else', 'except', 'False', 'finally', 'for', 'from', 'global', 'if', 'import', 'in', 'is', 'lambda', 'None', 'nonlocal', 'not', 'or', 'pass', 'raise', 'return', 'True', 'try', 'while', 'with', 'yield'];
|
||||
const builtinFunctions = ['print', 'len', 'range', 'str', 'int', 'float', 'list', 'dict', 'set', 'tuple', 'type', 'isinstance', 'hasattr', 'getattr', 'open', 'input', 'map', 'filter', 'zip', 'enumerate', 'sorted', 'reversed', 'sum', 'min', 'max', 'abs', 'round', 'pow', 'divmod', 'chr', 'ord', 'hex', 'bin', 'oct', 'format'];
|
||||
|
||||
// Dark theme syntax colors
|
||||
const darkSyntaxColors = {
|
||||
comment: '#6a9955',
|
||||
string: '#ce9178',
|
||||
keyword: '#569cd6',
|
||||
function: '#dcdcaa',
|
||||
number: '#b5cea8',
|
||||
default: '#d4d4d4',
|
||||
};
|
||||
|
||||
// Light theme syntax colors
|
||||
const lightSyntaxColors = {
|
||||
comment: '#1a7f37',
|
||||
string: '#cf222e',
|
||||
keyword: '#8250df',
|
||||
function: '#8250df',
|
||||
number: '#9a6700',
|
||||
default: '#1f2328',
|
||||
};
|
||||
|
||||
function highlightLine(line: string, language: 'shell' | 'python', isDark: boolean): string {
|
||||
let html = '';
|
||||
let remaining = line;
|
||||
|
||||
const colors = isDark ? darkSyntaxColors : lightSyntaxColors;
|
||||
const keywords = language === 'shell' ? shellKeywords : pythonKeywords;
|
||||
const kwPattern = new RegExp(`^(\\s*)(${keywords.join('|')})(?![a-zA-Z0-9_])`);
|
||||
const funcPattern = /^(\s*)([a-zA-Z_][a-zA-Z0-9_]*)(\s*\()/;
|
||||
const stringPattern = /^(["'])(.*?)(\1)/;
|
||||
const commentPattern = language === 'shell' ? /^(#.*)/ : /^(#.*)/;
|
||||
const numberPattern = /^([0-9]+\.?[0-9]*)/;
|
||||
const wordPattern = /^([a-zA-Z_][a-zA-Z0-9_]*)/;
|
||||
const spacePattern = /^(\s+)/;
|
||||
const otherPattern = /^(.)/;
|
||||
|
||||
while (remaining.length > 0) {
|
||||
// Check for comment first
|
||||
const commentMatch = remaining.match(commentPattern);
|
||||
if (commentMatch) {
|
||||
html += `<span style="color: ${colors.comment}; font-style: italic;">${escapeHtml(commentMatch[0])}</span>`;
|
||||
remaining = remaining.slice(commentMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// String literals
|
||||
const stringMatch = remaining.match(stringPattern);
|
||||
if (stringMatch) {
|
||||
html += `<span style="color: ${colors.string};">${escapeHtml(stringMatch[0])}</span>`;
|
||||
remaining = remaining.slice(stringMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Function calls
|
||||
const funcMatch = remaining.match(funcPattern);
|
||||
if (funcMatch && language === 'python' && builtinFunctions.includes(funcMatch[2])) {
|
||||
html += `${escapeHtml(funcMatch[1])}<span style="color: ${colors.function};">${escapeHtml(funcMatch[2])}</span>${escapeHtml(funcMatch[3])}`;
|
||||
remaining = remaining.slice(funcMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Keywords
|
||||
const kwMatch = remaining.match(kwPattern);
|
||||
if (kwMatch) {
|
||||
html += `${escapeHtml(kwMatch[1])}<span style="color: ${colors.keyword}; font-weight: 600;">${escapeHtml(kwMatch[2])}</span>`;
|
||||
remaining = remaining.slice(kwMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Numbers
|
||||
const numMatch = remaining.match(numberPattern);
|
||||
if (numMatch) {
|
||||
html += `<span style="color: ${colors.number};">${escapeHtml(numMatch[0])}</span>`;
|
||||
remaining = remaining.slice(numMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Words
|
||||
const wordMatch = remaining.match(wordPattern);
|
||||
if (wordMatch) {
|
||||
html += escapeHtml(wordMatch[0]);
|
||||
remaining = remaining.slice(wordMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Whitespace
|
||||
const spaceMatch = remaining.match(spacePattern);
|
||||
if (spaceMatch) {
|
||||
html += escapeHtml(spaceMatch[0]);
|
||||
remaining = remaining.slice(spaceMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Other characters
|
||||
const otherMatch = remaining.match(otherPattern);
|
||||
if (otherMatch) {
|
||||
html += escapeHtml(otherMatch[0]);
|
||||
remaining = remaining.slice(otherMatch[0].length);
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return html || ' ';
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
export default function ScriptEditor({ value, onChange, language, theme = 'light' }: ScriptEditorProps) {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const highlightRef = useRef<HTMLDivElement>(null);
|
||||
const [focused, setFocused] = useState(false);
|
||||
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
const lines = value.split('\n');
|
||||
const lineNumbers = lines.map((_, i) => i + 1).join('\n');
|
||||
|
||||
const highlightedHtml = lines
|
||||
.map((line) => highlightLine(line, language, isDark))
|
||||
.join('<br>');
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Tab') {
|
||||
e.preventDefault();
|
||||
const target = e.currentTarget;
|
||||
const start = target.selectionStart;
|
||||
const end = target.selectionEnd;
|
||||
const newValue = value.substring(0, start) + ' ' + value.substring(end);
|
||||
onChange(newValue);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
target.selectionStart = target.selectionEnd = start + 2;
|
||||
});
|
||||
}
|
||||
},
|
||||
[value, onChange]
|
||||
);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (textareaRef.current && highlightRef.current) {
|
||||
highlightRef.current.scrollTop = textareaRef.current.scrollTop;
|
||||
highlightRef.current.scrollLeft = textareaRef.current.scrollLeft;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
handleScroll();
|
||||
}, [value, handleScroll]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative rounded-md border overflow-hidden script-editor ${
|
||||
focused ? 'ring-1' : ''
|
||||
}`}
|
||||
style={{
|
||||
borderColor: isDark ? '#333333' : 'var(--border-color)',
|
||||
minHeight: '200px',
|
||||
'--tw-ring-color': 'rgba(9, 105, 218, 0.25)',
|
||||
background: isDark ? '#1e1e1e' : 'var(--panel-bg)',
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<div className="flex" style={{ minHeight: '200px' }}>
|
||||
{/* Line numbers */}
|
||||
<div
|
||||
className="flex-shrink-0 py-3 px-2 text-right select-none"
|
||||
style={{
|
||||
background: isDark ? '#1e1e1e' : 'var(--panel-bg-secondary)',
|
||||
color: isDark ? '#858585' : 'var(--text-tertiary)',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.6',
|
||||
minWidth: '40px',
|
||||
borderRight: `1px solid ${isDark ? '#333333' : 'var(--border-color-light)'}`,
|
||||
}}
|
||||
>
|
||||
{lineNumbers.split('\n').map((num, i) => (
|
||||
<div key={i}>{num}</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Editor area */}
|
||||
<div className="relative flex-1" style={{ minHeight: '200px' }}>
|
||||
{/* Highlight layer */}
|
||||
<div
|
||||
ref={highlightRef}
|
||||
className="absolute inset-0 py-3 px-3 pointer-events-none overflow-hidden whitespace-pre"
|
||||
style={{
|
||||
color: isDark ? '#d4d4d4' : 'var(--text-primary)',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.6',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: highlightedHtml }}
|
||||
/>
|
||||
|
||||
{/* Textarea layer */}
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => setFocused(false)}
|
||||
onScroll={handleScroll}
|
||||
spellCheck={false}
|
||||
className="absolute inset-0 w-full h-full py-3 px-3 resize-none bg-transparent script-editor"
|
||||
style={{
|
||||
color: 'transparent',
|
||||
caretColor: isDark ? '#d4d4d4' : 'var(--text-primary)',
|
||||
fontSize: '13px',
|
||||
lineHeight: '1.6',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
GitBranch,
|
||||
Server,
|
||||
Key,
|
||||
Cpu,
|
||||
Wrench,
|
||||
Download,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Rocket,
|
||||
Zap,
|
||||
History,
|
||||
Webhook,
|
||||
CloudSync,
|
||||
Puzzle,
|
||||
Settings
|
||||
} from 'lucide-react';
|
||||
|
||||
type ModuleType = 'pipelines' | 'gitops' | 'batch-release' | 'release-records' | 'environments' | 'credentials' | 'build-nodes' | 'tools' | 'jenkins-import' | 'webhooks' | 'plugins' | 'settings';
|
||||
|
||||
interface SidebarProps {
|
||||
activeModule: ModuleType;
|
||||
onModuleChange: (module: ModuleType) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}
|
||||
|
||||
const menuGroups: { label: string; items: { id: ModuleType; label: string; icon: typeof GitBranch; badge: string | null }[] }[] = [
|
||||
{
|
||||
label: '发布',
|
||||
items: [
|
||||
{ id: 'pipelines', label: '流水线', icon: GitBranch, badge: null },
|
||||
{ id: 'gitops', label: 'GitOps 部署', icon: CloudSync, badge: 'New' },
|
||||
{ id: 'batch-release', label: '批量发布', icon: Zap, badge: null },
|
||||
{ id: 'release-records', label: '发布记录', icon: History, badge: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '资源',
|
||||
items: [
|
||||
{ id: 'environments', label: '环境管理', icon: Server, badge: null },
|
||||
{ id: 'credentials', label: '凭证管理', icon: Key, badge: null },
|
||||
{ id: 'build-nodes', label: '构建节点', icon: Cpu, badge: '3' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '集成',
|
||||
items: [
|
||||
{ id: 'jenkins-import', label: 'Jenkins导入', icon: Download, badge: null },
|
||||
{ id: 'webhooks', label: 'Webhook', icon: Webhook, badge: null },
|
||||
{ id: 'tools', label: '工具配置', icon: Wrench, badge: null },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: '系统',
|
||||
items: [
|
||||
{ id: 'plugins', label: '插件市场', icon: Puzzle, badge: '12' },
|
||||
{ id: 'settings', label: '系统设置', icon: Settings, badge: null },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function Sidebar({ activeModule, onModuleChange, collapsed, onToggleCollapse }: SidebarProps) {
|
||||
return (
|
||||
<div
|
||||
className="flex flex-col border-r transition-all duration-300 ease-in-out h-full"
|
||||
style={{
|
||||
width: collapsed ? '56px' : '200px',
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--border-color)',
|
||||
}}
|
||||
>
|
||||
{/* Logo area */}
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 h-12 border-b flex-shrink-0"
|
||||
style={{ borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
<div
|
||||
className="w-7 h-7 rounded-md flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
>
|
||||
<Rocket className="w-4 h-4 text-white" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<span className="text-[13px] font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
|
||||
应用发布
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Menu groups */}
|
||||
<div className="flex-1 py-2 overflow-y-auto custom-scrollbar">
|
||||
{menuGroups.map((group) => (
|
||||
<div key={group.label} className="mb-2">
|
||||
{!collapsed && (
|
||||
<div
|
||||
className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wider"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{group.label}
|
||||
</div>
|
||||
)}
|
||||
<div className="px-2 space-y-[2px]">
|
||||
{group.items.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = activeModule === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => onModuleChange(item.id)}
|
||||
className="w-full flex items-center gap-2.5 px-2.5 py-[7px] rounded-md transition-all duration-150 group relative"
|
||||
style={{
|
||||
background: isActive ? 'var(--accent-blue-light)' : 'transparent',
|
||||
color: isActive ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
}}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<Icon className="w-[18px] h-[18px] flex-shrink-0" />
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span className="text-[13px] font-medium truncate flex-1 text-left">
|
||||
{item.label}
|
||||
</span>
|
||||
{item.badge && (
|
||||
<span
|
||||
className="text-[9px] px-1 py-[1px] rounded font-medium"
|
||||
style={{
|
||||
background: item.badge === 'New' ? '#ddf4ff' : 'var(--panel-bg-secondary)',
|
||||
color: item.badge === 'New' ? 'var(--accent-blue)' : 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{isActive && !collapsed && (
|
||||
<div
|
||||
className="absolute left-0 top-1/2 -translate-y-1/2 w-[3px] h-5 rounded-r-full"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Collapse toggle */}
|
||||
<div
|
||||
className="px-2 py-2 border-t flex justify-center"
|
||||
style={{ borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
<button
|
||||
onClick={onToggleCollapse}
|
||||
className="p-1.5 rounded-md transition-colors hover:bg-black/5"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
title={collapsed ? '展开' : '收起'}
|
||||
>
|
||||
{collapsed ? <ChevronRight className="w-4 h-4" /> : <ChevronLeft className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ChevronUp,
|
||||
ChevronDown,
|
||||
X,
|
||||
Plus,
|
||||
GitBranch,
|
||||
Wrench,
|
||||
Server,
|
||||
FlaskConical,
|
||||
Settings,
|
||||
} from 'lucide-react';
|
||||
import type { PipelineStage } from '@/types/pipeline';
|
||||
import TaskCard from './TaskCard';
|
||||
|
||||
interface StageCardProps {
|
||||
stage: PipelineStage;
|
||||
index: number;
|
||||
isFirst: boolean;
|
||||
isLast: boolean;
|
||||
selectedTaskId: string | null;
|
||||
onSelectTask: (taskId: string) => void;
|
||||
onMoveUp: () => void;
|
||||
onMoveDown: () => void;
|
||||
onDelete: () => void;
|
||||
onUpdateName: (name: string) => void;
|
||||
}
|
||||
|
||||
// Only use color for the LEFT BORDER — keep everything else neutral
|
||||
const stageTypeConfig: Record<string, { color: string; icon: typeof GitBranch }> = {
|
||||
source: { color: '#0969da', icon: GitBranch },
|
||||
build: { color: '#9a6700', icon: Wrench },
|
||||
deploy: { color: '#1a7f37', icon: Server },
|
||||
test: { color: '#8250df', icon: FlaskConical },
|
||||
custom: { color: '#6e7781', icon: Settings },
|
||||
};
|
||||
|
||||
export default function StageCard({
|
||||
stage,
|
||||
index,
|
||||
isFirst,
|
||||
isLast,
|
||||
selectedTaskId,
|
||||
onSelectTask,
|
||||
onMoveUp,
|
||||
onMoveDown,
|
||||
onDelete,
|
||||
onUpdateName,
|
||||
}: StageCardProps) {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editName, setEditName] = useState(stage.name);
|
||||
const config = stageTypeConfig[stage.type] || stageTypeConfig.custom;
|
||||
const IconComponent = config.icon;
|
||||
|
||||
const handleNameSubmit = () => {
|
||||
if (editName.trim()) {
|
||||
onUpdateName(editName.trim());
|
||||
} else {
|
||||
setEditName(stage.name);
|
||||
}
|
||||
setIsEditing(false);
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') handleNameSubmit();
|
||||
else if (e.key === 'Escape') {
|
||||
setEditName(stage.name);
|
||||
setIsEditing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div
|
||||
className="rounded-lg overflow-hidden transition-shadow duration-200 hover:shadow-sm"
|
||||
style={{
|
||||
background: 'var(--panel-bg)',
|
||||
border: '1px solid var(--border-color)',
|
||||
borderLeft: `3px solid ${config.color}`,
|
||||
}}
|
||||
>
|
||||
{/* Header — unified neutral background, NO colorful bg */}
|
||||
<div
|
||||
className="flex items-center justify-between px-4 py-2.5 border-b"
|
||||
style={{
|
||||
borderColor: 'var(--border-color-light)',
|
||||
background: '#f8f9fa',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
<IconComponent className="w-3.5 h-3.5 flex-shrink-0" style={{ color: config.color }} />
|
||||
{isEditing ? (
|
||||
<input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onBlur={handleNameSubmit}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
className="text-[13px] font-semibold bg-white border rounded px-2 py-0.5 outline-none"
|
||||
style={{
|
||||
color: 'var(--text-primary)',
|
||||
borderColor: config.color,
|
||||
minWidth: '140px',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setIsEditing(true)}
|
||||
className="text-[13px] font-semibold hover:opacity-70 transition-opacity truncate text-left"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
title="点击编辑"
|
||||
>
|
||||
阶段 {index + 1}: {stage.name}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Refined action buttons — smaller, tighter */}
|
||||
<div className="flex items-center gap-[2px] flex-shrink-0">
|
||||
<button
|
||||
onClick={onMoveUp}
|
||||
disabled={isFirst}
|
||||
className="p-1 rounded transition-all hover:bg-black/5 disabled:opacity-25 disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||
title="上移"
|
||||
>
|
||||
<ChevronUp className="w-3 h-3" style={{ color: 'var(--text-secondary)' }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onMoveDown}
|
||||
disabled={isLast}
|
||||
className="p-1 rounded transition-all hover:bg-black/5 disabled:opacity-25 disabled:cursor-not-allowed disabled:hover:bg-transparent"
|
||||
title="下移"
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" style={{ color: 'var(--text-secondary)' }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={onDelete}
|
||||
className="p-1 rounded transition-all hover:bg-red-50"
|
||||
title="删除阶段"
|
||||
>
|
||||
<X className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Body: Task list */}
|
||||
<div className="px-2 py-1.5">
|
||||
{stage.tasks.map((task) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
isSelected={selectedTaskId === task.id}
|
||||
onSelect={() => onSelectTask(task.id)}
|
||||
onDelete={() => {/* TODO */}}
|
||||
/>
|
||||
))}
|
||||
{stage.tasks.length === 0 && (
|
||||
<div
|
||||
className="text-center py-4 rounded-md"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
<p className="text-[12px]">暂无任务</p>
|
||||
<p className="text-[11px] mt-0.5" style={{ color: 'var(--text-tertiary)' }}>
|
||||
点击下方按钮添加
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer: Add task — refined style */}
|
||||
<div
|
||||
className="px-2 py-1.5 border-t"
|
||||
style={{ borderColor: 'var(--border-color-light)' }}
|
||||
>
|
||||
<button
|
||||
className="flex items-center justify-center gap-1.5 w-full py-[6px] text-[12px] rounded-md border border-dashed transition-all hover:border-solid hover:bg-[var(--panel-bg-secondary)]"
|
||||
style={{
|
||||
borderColor: 'var(--border-color-light)',
|
||||
color: 'var(--text-tertiary)',
|
||||
}}
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
添加任务
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Pencil, Trash2 } from 'lucide-react';
|
||||
import { iconMap } from '@/data/pipelineData';
|
||||
import type { PipelineTask } from '@/types/pipeline';
|
||||
|
||||
interface TaskCardProps {
|
||||
task: PipelineTask;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
// Subtle type indicator colors — just for the small dot/icon, NOT background
|
||||
const typeConfig: Record<string, { color: string; label: string }> = {
|
||||
build: { color: '#9a6700', label: '构建' },
|
||||
deploy: { color: '#1a7f37', label: '部署' },
|
||||
test: { color: '#8250df', label: '测试' },
|
||||
custom: { color: '#6e7781', label: '自定义' },
|
||||
};
|
||||
|
||||
export default function TaskCard({ task, isSelected, onSelect, onDelete }: TaskCardProps) {
|
||||
const config = typeConfig[task.type] || typeConfig.custom;
|
||||
const IconComponent = iconMap[task.icon] || iconMap.Settings;
|
||||
|
||||
const handleDelete = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
onDelete();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={onSelect}
|
||||
className="flex items-center gap-2.5 px-3 py-[10px] rounded-md cursor-pointer transition-all duration-150 group mb-[2px] last:mb-0"
|
||||
style={{
|
||||
background: isSelected ? '#f0f5ff' : 'transparent',
|
||||
border: isSelected ? '1px solid #b7d4f0' : '1px solid transparent',
|
||||
...(isSelected
|
||||
? { boxShadow: '0 0 0 1px rgba(9,105,218,0.08)' }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
{/* Type icon — small colored dot with icon inside */}
|
||||
<div
|
||||
className="w-6 h-6 rounded-[6px] flex items-center justify-center flex-shrink-0"
|
||||
style={{
|
||||
background: `${config.color}10`,
|
||||
border: `1px solid ${config.color}25`,
|
||||
}}
|
||||
>
|
||||
<IconComponent className="w-3 h-3" style={{ color: config.color }} />
|
||||
</div>
|
||||
|
||||
{/* Task info */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="text-[13px] font-medium truncate leading-tight"
|
||||
style={{ color: isSelected ? 'var(--text-primary)' : 'var(--text-primary)' }}
|
||||
>
|
||||
{task.name}
|
||||
</div>
|
||||
<div
|
||||
className="text-[11px] truncate leading-tight mt-[1px]"
|
||||
style={{ color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{config.label}
|
||||
{task.scriptType && ` · ${task.scriptType}`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions — hover reveal */}
|
||||
<div className="flex items-center gap-[2px] opacity-0 group-hover:opacity-100 transition-opacity duration-150 flex-shrink-0">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onSelect(); }}
|
||||
className="p-1 rounded transition-colors hover:bg-black/5"
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="p-1 rounded transition-colors hover:bg-red-50"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { ChevronLeft, Save, Play, MonitorPlay, ChevronDown } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
|
||||
type EditorTab = '基本信息' | '流程配置' | '触发设置' | '变量和缓存' | '发布';
|
||||
|
||||
interface TopBarProps {
|
||||
pipelineName: string;
|
||||
activeTab: EditorTab;
|
||||
onTabChange: (tab: EditorTab) => void;
|
||||
onSimulateRun: () => void;
|
||||
isSimulating: boolean;
|
||||
scenarioKey: 'go-backend' | 'vue-frontend';
|
||||
onScenarioChange: (key: 'go-backend' | 'vue-frontend') => void;
|
||||
executionStatus: 'idle' | 'running' | 'success' | 'failed';
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
const tabs: EditorTab[] = ['基本信息', '流程配置', '触发设置', '变量和缓存', '发布'];
|
||||
|
||||
const scenarios = [
|
||||
{ key: 'go-backend' as const, label: 'aiops-backend (Go + K8s)' },
|
||||
{ key: 'vue-frontend' as const, label: 'aiops-web (Vue + Host)' },
|
||||
];
|
||||
|
||||
export default function TopBar({
|
||||
pipelineName,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
onSimulateRun,
|
||||
isSimulating,
|
||||
scenarioKey,
|
||||
onScenarioChange,
|
||||
executionStatus,
|
||||
onBack,
|
||||
}: TopBarProps) {
|
||||
const [pipelineDropdownOpen, setPipelineDropdownOpen] = useState(false);
|
||||
|
||||
const statusConfig = {
|
||||
idle: null,
|
||||
running: { color: '#0969da', bg: '#ddf4ff', label: '执行中' },
|
||||
success: { color: '#1a7f37', bg: '#dafbe1', label: '成功' },
|
||||
failed: { color: '#cf222e', bg: '#ffebe9', label: '失败' },
|
||||
};
|
||||
|
||||
const status = statusConfig[executionStatus];
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between px-4 border-b flex-shrink-0"
|
||||
style={{
|
||||
height: '44px',
|
||||
background: 'var(--panel-bg)',
|
||||
borderColor: 'var(--border-color)',
|
||||
}}
|
||||
>
|
||||
{/* Left: Back + Pipeline Name + Switcher */}
|
||||
<div className="flex items-center gap-3 min-w-[280px]">
|
||||
<button
|
||||
onClick={onBack}
|
||||
className="p-1 rounded-md hover:bg-[var(--panel-bg-secondary)] transition-colors"
|
||||
title="返回列表"
|
||||
>
|
||||
<ChevronLeft className="w-[18px] h-[18px]" style={{ color: 'var(--text-primary)' }} />
|
||||
</button>
|
||||
|
||||
{/* Pipeline name with dropdown switcher */}
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setPipelineDropdownOpen(!pipelineDropdownOpen)}
|
||||
className="flex items-center gap-1 px-1.5 py-[3px] rounded-md hover:bg-[var(--panel-bg-secondary)] transition-colors"
|
||||
>
|
||||
<span className="text-[13px] font-semibold truncate" style={{ color: 'var(--text-primary)' }}>
|
||||
{pipelineName}
|
||||
</span>
|
||||
<ChevronDown
|
||||
className={`w-3.5 h-3.5 transition-transform ${pipelineDropdownOpen ? 'rotate-180' : ''}`}
|
||||
style={{ color: 'var(--text-secondary)' }}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{pipelineDropdownOpen && (
|
||||
<>
|
||||
<div className="fixed inset-0 z-40" onClick={() => setPipelineDropdownOpen(false)} />
|
||||
<div
|
||||
className="absolute top-full left-0 mt-1 py-1 rounded-md border shadow-lg z-50 min-w-[200px]"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
{scenarios.map((s) => (
|
||||
<button
|
||||
key={s.key}
|
||||
onClick={() => {
|
||||
onScenarioChange(s.key);
|
||||
setPipelineDropdownOpen(false);
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-[13px] transition-colors hover:bg-[var(--panel-bg-secondary)]"
|
||||
style={{
|
||||
color: scenarioKey === s.key ? 'var(--accent-blue)' : 'var(--text-primary)',
|
||||
fontWeight: scenarioKey === s.key ? 600 : 400,
|
||||
}}
|
||||
>
|
||||
{s.label}
|
||||
{scenarioKey === s.key && (
|
||||
<span className="ml-2 text-[11px]" style={{ color: 'var(--accent-blue)' }}>✓</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Execution status badge */}
|
||||
{status && (
|
||||
<span
|
||||
className="text-[10px] px-1.5 py-[2px] rounded-full font-medium"
|
||||
style={{ background: status.bg, color: status.color }}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Center: Tabs */}
|
||||
<div className="flex items-center gap-0">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => onTabChange(tab)}
|
||||
className="relative px-3 py-1.5 text-[12px] font-medium transition-colors"
|
||||
style={{
|
||||
color: tab === activeTab ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
{tab}
|
||||
{tab === activeTab && (
|
||||
<span className="absolute bottom-0 left-1 right-1 h-0.5 rounded-full" style={{ background: 'var(--accent-blue)' }} />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Right: Action Buttons */}
|
||||
<div className="flex items-center gap-2 min-w-[280px] justify-end">
|
||||
<button
|
||||
onClick={onSimulateRun}
|
||||
disabled={isSimulating}
|
||||
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors disabled:opacity-50"
|
||||
style={{
|
||||
color: 'var(--accent-blue)',
|
||||
background: 'var(--accent-blue-light)',
|
||||
border: '1px solid rgba(9, 105, 218, 0.2)',
|
||||
}}
|
||||
>
|
||||
<MonitorPlay className="w-4 h-4" />
|
||||
{isSimulating ? '运行中...' : '模拟运行'}
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md transition-colors"
|
||||
style={{
|
||||
color: 'var(--text-secondary)',
|
||||
background: 'transparent',
|
||||
border: '1px solid var(--border-color)',
|
||||
}}
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
仅保存
|
||||
</button>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-1 text-[12px] font-medium rounded-md text-white transition-colors"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
>
|
||||
<Play className="w-4 h-4" />
|
||||
保存并运行
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import { useState } from 'react';
|
||||
import { GitCommit, Clock, Webhook, Hand, Info, Copy, Check } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function TriggerPanel() {
|
||||
// Code commit trigger
|
||||
const [codeCommitEnabled, setCodeCommitEnabled] = useState(true);
|
||||
const [watchBranch, setWatchBranch] = useState('');
|
||||
const [excludeBranch, setExcludeBranch] = useState('');
|
||||
|
||||
// Cron trigger
|
||||
const [cronEnabled, setCronEnabled] = useState(false);
|
||||
const [cronExpression, setCronExpression] = useState('');
|
||||
const [nextExecution, setNextExecution] = useState('2024-06-21 02:00:00');
|
||||
|
||||
// Webhook trigger
|
||||
const [webhookEnabled, setWebhookEnabled] = useState(false);
|
||||
const [webhookSecret, setWebhookSecret] = useState('');
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const webhookUrl = 'https://api.example.com/webhook/pipeline/gateway-pre-serverless';
|
||||
|
||||
const handleCopyWebhook = () => {
|
||||
navigator.clipboard.writeText(webhookUrl).then(() => {
|
||||
setCopied(true);
|
||||
toast.success('Webhook URL 已复制');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const cronPresets = [
|
||||
{ label: '每天凌晨2点', value: '0 2 * * *' },
|
||||
{ label: '每小时', value: '0 * * * *' },
|
||||
{ label: '自定义', value: '' },
|
||||
];
|
||||
|
||||
const handleCronPreset = (value: string) => {
|
||||
setCronExpression(value);
|
||||
if (value === '0 2 * * *') {
|
||||
setNextExecution('2024-06-21 02:00:00');
|
||||
} else if (value === '0 * * * *') {
|
||||
setNextExecution('2024-06-20 15:00:00');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-content-fade space-y-4 w-full px-6 py-6">
|
||||
{/* Code Commit Trigger */}
|
||||
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitCommit className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||
<CardTitle className="text-base">代码提交触发</CardTitle>
|
||||
</div>
|
||||
<Switch checked={codeCommitEnabled} onCheckedChange={setCodeCommitEnabled} />
|
||||
</div>
|
||||
<CardDescription>
|
||||
当指定分支有新的代码提交时自动触发流水线
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{codeCommitEnabled && (
|
||||
<CardContent className="tab-content-fade space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>监听分支</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>支持正则表达式,如 release/*</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
value={watchBranch}
|
||||
onChange={(e) => setWatchBranch(e.target.value)}
|
||||
placeholder="支持正则,如 release/*"
|
||||
className="h-9"
|
||||
/>
|
||||
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
|
||||
多个分支用逗号分隔,支持 * 通配符
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
|
||||
排除分支
|
||||
</Label>
|
||||
<Input
|
||||
value={excludeBranch}
|
||||
onChange={(e) => setExcludeBranch(e.target.value)}
|
||||
placeholder="如 hotfix/*"
|
||||
className="h-9"
|
||||
/>
|
||||
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
|
||||
匹配的分支不会触发流水线
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Cron Trigger */}
|
||||
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||
<CardTitle className="text-base">定时触发</CardTitle>
|
||||
</div>
|
||||
<Switch checked={cronEnabled} onCheckedChange={setCronEnabled} />
|
||||
</div>
|
||||
<CardDescription>
|
||||
按照 Cron 表达式定时触发流水线
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{cronEnabled && (
|
||||
<CardContent className="tab-content-fade space-y-4">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{cronPresets.map((preset) => (
|
||||
<Badge
|
||||
key={preset.label}
|
||||
variant={cronExpression === preset.value && preset.value !== '' ? 'default' : 'outline'}
|
||||
className="cursor-pointer h-7 px-3"
|
||||
onClick={() => handleCronPreset(preset.value)}
|
||||
>
|
||||
{preset.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-sm mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
|
||||
Cron 表达式
|
||||
</Label>
|
||||
<Input
|
||||
value={cronExpression}
|
||||
onChange={(e) => setCronExpression(e.target.value)}
|
||||
placeholder="0 2 * * *"
|
||||
className="h-9 font-mono"
|
||||
/>
|
||||
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
|
||||
格式:分 时 日 月 周
|
||||
</p>
|
||||
</div>
|
||||
{cronExpression && (
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-md text-sm"
|
||||
style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
<Clock className="w-4 h-4" style={{ color: 'var(--accent-blue)' }} />
|
||||
下次执行: {nextExecution}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Webhook Trigger */}
|
||||
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Webhook className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||
<CardTitle className="text-base">Webhook 触发</CardTitle>
|
||||
</div>
|
||||
<Switch checked={webhookEnabled} onCheckedChange={setWebhookEnabled} />
|
||||
</div>
|
||||
<CardDescription>
|
||||
通过外部系统的 Webhook 调用触发流水线
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{webhookEnabled && (
|
||||
<CardContent className="tab-content-fade space-y-4">
|
||||
<div>
|
||||
<Label className="text-sm mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>
|
||||
Webhook URL
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<div
|
||||
className="flex-1 px-3 py-2 text-sm rounded-md border font-mono"
|
||||
style={{
|
||||
background: 'var(--canvas-bg)',
|
||||
borderColor: 'var(--border-color)',
|
||||
color: 'var(--text-primary)',
|
||||
}}
|
||||
>
|
||||
{webhookUrl}
|
||||
</div>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9" onClick={handleCopyWebhook}>
|
||||
{copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
|
||||
将此 URL 配置到外部系统的 Webhook 中
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>Secret Token</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>用于验证 Webhook 请求的签名</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
value={webhookSecret}
|
||||
onChange={(e) => setWebhookSecret(e.target.value)}
|
||||
placeholder="输入 Secret Token"
|
||||
type="password"
|
||||
className="h-9"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Manual Trigger - always on */}
|
||||
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Hand className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||
<CardTitle className="text-base">手动触发</CardTitle>
|
||||
<Badge variant="secondary" className="h-5 text-xs">默认启用</Badge>
|
||||
</div>
|
||||
<CardDescription>
|
||||
支持通过控制台手动触发或 API 调用触发
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div
|
||||
className="px-3 py-2.5 rounded-md text-sm"
|
||||
style={{ background: 'var(--canvas-bg)', color: 'var(--text-primary)' }}
|
||||
>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>
|
||||
API 调用示例:
|
||||
</span>
|
||||
<code className="ml-2 text-xs font-mono" style={{ color: 'var(--accent-blue)' }}>
|
||||
POST /api/v1/pipelines/gateway-pre-serverless/run
|
||||
</code>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useState } from 'react';
|
||||
import { Variable, Database, Trash2, Plus, Info, Eye, EyeOff } from 'lucide-react';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
|
||||
interface EnvVar {
|
||||
id: string;
|
||||
name: string;
|
||||
value: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export default function VariablesPanel() {
|
||||
const [envVars, setEnvVars] = useState<EnvVar[]>([
|
||||
{ id: '1', name: 'REGISTRY_URL', value: 'registry.cn-hangzhou.aliyuncs.com', description: '镜像仓库地址' },
|
||||
{ id: '2', name: 'NAMESPACE', value: 'gateway-ns', description: 'K8s 命名空间' },
|
||||
{ id: '3', name: 'APP_NAME', value: 'gateway-serverless', description: '应用名称' },
|
||||
{ id: '4', name: 'ENV', value: 'pre', description: '环境标识' },
|
||||
]);
|
||||
|
||||
const [showAddForm, setShowAddForm] = useState(false);
|
||||
const [newVarName, setNewVarName] = useState('');
|
||||
const [newVarValue, setNewVarValue] = useState('');
|
||||
const [newVarDesc, setNewVarDesc] = useState('');
|
||||
const [visibleValues, setVisibleValues] = useState<Record<string, boolean>>({});
|
||||
|
||||
// Cache config
|
||||
const [cacheEnabled, setCacheEnabled] = useState(true);
|
||||
const [cachePath, setCachePath] = useState('/root/.m2');
|
||||
const [cacheKey, setCacheKey] = useState('${BRANCH_NAME}-${BUILD_ID}');
|
||||
|
||||
const handleAddVar = () => {
|
||||
if (!newVarName.trim() || !newVarValue.trim()) return;
|
||||
const newVar: EnvVar = {
|
||||
id: Date.now().toString(),
|
||||
name: newVarName.trim(),
|
||||
value: newVarValue.trim(),
|
||||
description: newVarDesc.trim(),
|
||||
};
|
||||
setEnvVars([...envVars, newVar]);
|
||||
setNewVarName('');
|
||||
setNewVarValue('');
|
||||
setNewVarDesc('');
|
||||
setShowAddForm(false);
|
||||
};
|
||||
|
||||
const handleDeleteVar = (id: string) => {
|
||||
setEnvVars(envVars.filter((v) => v.id !== id));
|
||||
};
|
||||
|
||||
const toggleVisible = (id: string) => {
|
||||
setVisibleValues((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="tab-content-fade space-y-4 w-full px-6 py-6">
|
||||
{/* Environment Variables */}
|
||||
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Variable className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||
<CardTitle className="text-base">环境变量</CardTitle>
|
||||
</div>
|
||||
<CardDescription className="flex items-center gap-1">
|
||||
在流水线各阶段可通过
|
||||
<code className="px-1 py-0.5 rounded text-xs font-mono" style={{ background: 'var(--canvas-bg)' }}>
|
||||
${'{VAR_NAME}'}
|
||||
</code>
|
||||
引用
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<div className="rounded-lg border overflow-hidden" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow style={{ background: 'var(--canvas-bg)' }}>
|
||||
<TableHead className="text-xs font-semibold h-9">变量名</TableHead>
|
||||
<TableHead className="text-xs font-semibold h-9">变量值</TableHead>
|
||||
<TableHead className="text-xs font-semibold h-9">说明</TableHead>
|
||||
<TableHead className="text-xs font-semibold h-9 w-16">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{envVars.map((envVar) => (
|
||||
<TableRow key={envVar.id} className="group">
|
||||
<TableCell className="font-mono text-sm py-2.5">{envVar.name}</TableCell>
|
||||
<TableCell className="py-2.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-sm">
|
||||
{visibleValues[envVar.id] ? envVar.value : '••••••••'}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => toggleVisible(envVar.id)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
{visibleValues[envVar.id] ? (
|
||||
<EyeOff className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
|
||||
) : (
|
||||
<Eye className="w-3.5 h-3.5" style={{ color: 'var(--text-secondary)' }} />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm py-2.5" style={{ color: 'var(--text-secondary)' }}>
|
||||
{envVar.description}
|
||||
</TableCell>
|
||||
<TableCell className="py-2.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => handleDeleteVar(envVar.id)}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: '#EF4444' }} />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{envVars.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="text-center py-6 text-sm" style={{ color: 'var(--text-secondary)' }}>
|
||||
暂无环境变量
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{showAddForm ? (
|
||||
<div className="tab-content-fade space-y-3 p-4 rounded-lg border" style={{ borderColor: 'var(--border-color)', background: 'var(--canvas-bg)' }}>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>变量名</Label>
|
||||
<Input
|
||||
value={newVarName}
|
||||
onChange={(e) => setNewVarName(e.target.value)}
|
||||
placeholder="如 BUILD_ARGS"
|
||||
className="h-8 font-mono"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>变量值</Label>
|
||||
<Input
|
||||
value={newVarValue}
|
||||
onChange={(e) => setNewVarValue(e.target.value)}
|
||||
placeholder="变量值"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs mb-1.5 block" style={{ color: 'var(--text-secondary)' }}>说明</Label>
|
||||
<Input
|
||||
value={newVarDesc}
|
||||
onChange={(e) => setNewVarDesc(e.target.value)}
|
||||
placeholder="变量说明(可选)"
|
||||
className="h-8"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button size="sm" className="h-8" onClick={handleAddVar}>
|
||||
确认添加
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="h-8" onClick={() => setShowAddForm(false)}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8"
|
||||
onClick={() => setShowAddForm(true)}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5 mr-1.5" />
|
||||
添加变量
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Cache Config */}
|
||||
<Card className="border shadow-sm" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Database className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||
<CardTitle className="text-base">缓存配置</CardTitle>
|
||||
</div>
|
||||
<Switch checked={cacheEnabled} onCheckedChange={setCacheEnabled} />
|
||||
</div>
|
||||
<CardDescription>
|
||||
配置构建缓存可显著加速后续构建速度
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
{cacheEnabled && (
|
||||
<CardContent className="tab-content-fade space-y-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>缓存路径</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>需要缓存的目录路径,多个路径用逗号分隔</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
value={cachePath}
|
||||
onChange={(e) => setCachePath(e.target.value)}
|
||||
placeholder="/root/.m2, node_modules"
|
||||
className="h-9"
|
||||
/>
|
||||
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
|
||||
多个路径用逗号分隔
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
<Label className="text-sm" style={{ color: 'var(--text-secondary)' }}>缓存 Key</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Info className="w-3.5 h-3.5 cursor-help" style={{ color: 'var(--text-secondary)', opacity: 0.5 }} />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>支持使用变量,用于区分不同场景的缓存</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Input
|
||||
value={cacheKey}
|
||||
onChange={(e) => setCacheKey(e.target.value)}
|
||||
placeholder="${BRANCH_NAME}-${BUILD_ID}"
|
||||
className="h-9 font-mono"
|
||||
/>
|
||||
<p className="text-xs mt-1.5" style={{ color: 'var(--text-secondary)', opacity: 0.5 }}>
|
||||
支持变量引用,相同的 Key 会命中已有缓存
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
Controls,
|
||||
MiniMap,
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
addEdge,
|
||||
type Connection,
|
||||
type Edge,
|
||||
type Node,
|
||||
ReactFlowProvider,
|
||||
Panel,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
import {
|
||||
ChevronLeft, ChevronRight, Play, Save,
|
||||
GitBranch, Package, Shield, Layers,
|
||||
Terminal, BookOpen, Box, X, CircleDot
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import TektonNode, { type TektonNodeData } from './TektonNode';
|
||||
import { apiClient } from '@/lib/api';
|
||||
|
||||
interface FlowEditorProps {
|
||||
pipelineId: number;
|
||||
}
|
||||
|
||||
interface CatalogTask {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
icon: typeof GitBranch;
|
||||
params: { name: string; type: string; default?: string }[];
|
||||
workspaces: string[];
|
||||
}
|
||||
|
||||
const catalogTasks: CatalogTask[] = [
|
||||
{ id: 'git-clone', name: 'git-clone', category: 'source', description: 'Clone Git repo', icon: GitBranch, params: [{ name: 'url', type: 'string' }, { name: 'revision', type: 'string', default: 'main' }], workspaces: ['source'] },
|
||||
{ id: 'maven', name: 'maven', category: 'build', description: 'Maven build', icon: Package, params: [{ name: 'goals', type: 'string', default: 'clean package' }], workspaces: ['source', 'm2-cache'] },
|
||||
{ id: 'npm', name: 'npm', category: 'build', description: 'npm install + build', icon: Package, params: [{ name: 'script', type: 'string', default: 'build' }], workspaces: ['source'] },
|
||||
{ id: 'go-build', name: 'go-build', category: 'build', description: 'Go compile', icon: Package, params: [{ name: 'package', type: 'string' }, { name: 'flags', type: 'string' }], workspaces: ['source'] },
|
||||
{ id: 'kaniko', name: 'kaniko-build', category: 'build', description: 'Docker build (rootless)', icon: Package, params: [{ name: 'dockerfile', type: 'string', default: 'Dockerfile' }, { name: 'image', type: 'string' }], workspaces: ['source'] },
|
||||
{ id: 'trivy', name: 'trivy-scan', category: 'security', description: 'Vulnerability scan', icon: Shield, params: [{ name: 'image', type: 'string' }, { name: 'severity', type: 'string', default: 'HIGH,CRITICAL' }], workspaces: [] },
|
||||
{ id: 'unit-test', name: 'unit-test', category: 'test', description: 'Run unit tests', icon: Terminal, params: [{ name: 'test-command', type: 'string' }], workspaces: ['source'] },
|
||||
{ id: 'kubectl', name: 'kubectl-deploy', category: 'deploy', description: 'kubectl apply', icon: Layers, params: [{ name: 'manifest', type: 'string' }, { name: 'namespace', type: 'string' }], workspaces: ['source'] },
|
||||
{ id: 'update-manifest', name: 'update-manifest', category: 'gitops', description: 'Update GitOps repo', icon: BookOpen, params: [{ name: 'repo', type: 'string' }, { name: 'image', type: 'string' }, { name: 'path', type: 'string' }], workspaces: ['source'] },
|
||||
{ id: 'helm', name: 'helm-upgrade', category: 'deploy', description: 'Helm deploy', icon: Layers, params: [{ name: 'release', type: 'string' }, { name: 'chart', type: 'string' }, { name: 'namespace', type: 'string' }], workspaces: ['source'] },
|
||||
];
|
||||
|
||||
const categoryColors: Record<string, string> = {
|
||||
source: '#0969da', build: '#1a7f37', test: '#9a6700',
|
||||
security: '#cf222e', deploy: '#8250df', gitops: '#0e7c7b', custom: '#6e7781',
|
||||
};
|
||||
|
||||
const nodeTypes = { tektonNode: TektonNode };
|
||||
|
||||
function FlowEditorInner({ pipelineId }: FlowEditorProps) {
|
||||
const [nodes, setNodes, onNodesChange] = useNodesState<Node<TektonNodeData>>([]);
|
||||
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
|
||||
const [catalogOpen, setCatalogOpen] = useState(true);
|
||||
const [selectedCategory, setSelectedCategory] = useState('all');
|
||||
const [selectedNode, setSelectedNode] = useState<Node<TektonNodeData> | null>(null);
|
||||
const [isRunning, setIsRunning] = useState(false);
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
const [logs, setLogs] = useState<{ level: string; message: string; timestamp: string }[]>([]);
|
||||
const logBoxRef = useRef<HTMLDivElement>(null);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: pipelineData } = useQuery({
|
||||
queryKey: ['pipeline', pipelineId],
|
||||
queryFn: () => apiClient.getPipeline(pipelineId),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (pipelineData?.nodes) {
|
||||
const loadedNodes: Node<TektonNodeData>[] = (pipelineData.nodes as any[]).map((n: any) => ({
|
||||
id: n.nodeId,
|
||||
type: 'tektonNode',
|
||||
position: { x: n.positionX, y: n.positionY },
|
||||
data: { label: n.name, category: n.category, config: n.config } as TektonNodeData,
|
||||
width: n.width || 220,
|
||||
height: n.height || 120,
|
||||
}));
|
||||
setNodes(loadedNodes);
|
||||
}
|
||||
if (pipelineData?.edges) {
|
||||
const loadedEdges: Edge[] = (pipelineData.edges as any[]).map((e: any) => ({
|
||||
id: e.edgeId,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: e.label,
|
||||
animated: e.animated,
|
||||
}));
|
||||
setEdges(loadedEdges);
|
||||
}
|
||||
}, [pipelineData]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
const nodeData = nodes.map((n) => ({
|
||||
nodeId: n.id,
|
||||
type: n.data?.category || 'custom',
|
||||
name: n.data?.label || n.id,
|
||||
positionX: n.position.x,
|
||||
positionY: n.position.y,
|
||||
width: n.width || 220,
|
||||
height: n.height || 120,
|
||||
config: n.data?.config || {},
|
||||
category: n.data?.category || 'custom',
|
||||
}));
|
||||
const edgeData = edges.map((e) => ({
|
||||
edgeId: e.id,
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
label: typeof e.label === 'string' ? e.label : '',
|
||||
animated: e.animated || false,
|
||||
}));
|
||||
try {
|
||||
await apiClient.saveNodes(pipelineId, nodeData);
|
||||
await apiClient.saveEdges(pipelineId, edgeData);
|
||||
toast.success('流水线已保存', { description: `${nodeData.length} 个节点、${edgeData.length} 条连线` });
|
||||
queryClient.invalidateQueries({ queryKey: ['pipeline', pipelineId] });
|
||||
} catch (err) {
|
||||
toast.error('保存失败', { description: (err as Error).message });
|
||||
}
|
||||
}, [nodes, edges, pipelineId, queryClient]);
|
||||
|
||||
const setAllNodeStatus = useCallback((status: TektonNodeData['status']) => {
|
||||
setNodes((nds) => nds.map((n) => ({ ...n, data: { ...n.data!, status } })));
|
||||
}, [setNodes]);
|
||||
|
||||
const setNodeStatus = useCallback((nodeId: string, status: TektonNodeData['status']) => {
|
||||
setNodes((nds) => nds.map((n) => (n.id === nodeId ? { ...n, data: { ...n.data!, status } } : n)));
|
||||
}, [setNodes]);
|
||||
|
||||
// 执行:创建运行 → SSE 订阅日志与阶段状态 → 画布节点实时推进
|
||||
const handleRun = useCallback(async () => {
|
||||
if (isRunning) return;
|
||||
if (nodes.length === 0) {
|
||||
toast.error('画布为空', { description: '请先从左侧任务库添加节点并保存' });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await apiClient.saveNodes(pipelineId, nodes.map((n) => ({
|
||||
nodeId: n.id, type: n.data?.category || 'custom', name: n.data?.label || n.id,
|
||||
positionX: n.position.x, positionY: n.position.y, width: n.width || 220, height: n.height || 120,
|
||||
config: n.data?.config || {}, category: n.data?.category || 'custom',
|
||||
})));
|
||||
await apiClient.saveEdges(pipelineId, edges.map((e) => ({
|
||||
edgeId: e.id, source: e.source, target: e.target,
|
||||
label: typeof e.label === 'string' ? e.label : '', animated: e.animated || false,
|
||||
})));
|
||||
} catch (err) {
|
||||
toast.error('保存失败', { description: (err as Error).message });
|
||||
return;
|
||||
}
|
||||
|
||||
let run;
|
||||
try {
|
||||
run = await apiClient.createRun(pipelineId);
|
||||
} catch (err) {
|
||||
toast.error('触发运行失败', { description: (err as Error).message });
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRunning(true);
|
||||
setLogs([]);
|
||||
setLogOpen(true);
|
||||
setAllNodeStatus('pending');
|
||||
toast.success(`运行已触发 ${run.runId}`, { description: `${nodes.length} 个节点开始执行` });
|
||||
|
||||
const es = new EventSource(`/api/runs/${run.runId}/logs/stream`);
|
||||
es.addEventListener('log', (evt) => {
|
||||
try {
|
||||
const entry = JSON.parse((evt as MessageEvent).data);
|
||||
setLogs((prev) => [...prev, entry]);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
es.addEventListener('stage', (evt) => {
|
||||
try {
|
||||
const stage = JSON.parse((evt as MessageEvent).data);
|
||||
setNodeStatus(stage.nodeId, stage.status);
|
||||
} catch { /* ignore */ }
|
||||
});
|
||||
es.addEventListener('run', (evt) => {
|
||||
try {
|
||||
const result = JSON.parse((evt as MessageEvent).data);
|
||||
if (result.status === 'success') {
|
||||
toast.success('执行成功', { description: `耗时 ${(result.durationMs / 1000).toFixed(1)}s` });
|
||||
} else {
|
||||
toast.error(`执行${result.status === 'failed' ? '失败' : '已取消'}`);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
es.close();
|
||||
setIsRunning(false);
|
||||
queryClient.invalidateQueries({ queryKey: ['runs'] });
|
||||
});
|
||||
es.onerror = () => {
|
||||
// 服务端结束推送(run 完成)时浏览器会触发 error,此时若已收到 run 事件则忽略
|
||||
es.close();
|
||||
setIsRunning(false);
|
||||
};
|
||||
}, [isRunning, nodes, edges, pipelineId, setAllNodeStatus, setNodeStatus, queryClient]);
|
||||
|
||||
// 日志自动滚动到底部
|
||||
useEffect(() => {
|
||||
if (logBoxRef.current) {
|
||||
logBoxRef.current.scrollTop = logBoxRef.current.scrollHeight;
|
||||
}
|
||||
}, [logs, logOpen]);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(params: Connection) => setEdges((eds) => addEdge({ ...params, animated: true, style: { stroke: '#0969da' } }, eds)),
|
||||
[setEdges]
|
||||
);
|
||||
|
||||
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
|
||||
setSelectedNode(node as Node<TektonNodeData>);
|
||||
}, []);
|
||||
|
||||
const addNode = useCallback((task: CatalogTask) => {
|
||||
const newNode: Node<TektonNodeData> = {
|
||||
id: `${task.id}-${Date.now()}`,
|
||||
type: 'tektonNode',
|
||||
position: { x: 100 + nodes.length * 40, y: 100 + nodes.length * 40 },
|
||||
data: {
|
||||
label: task.name,
|
||||
category: task.category,
|
||||
config: task.params.reduce((acc, p) => ({ ...acc, [p.name]: p.default || '' }), {}),
|
||||
},
|
||||
};
|
||||
setNodes((nds) => [...nds, newNode]);
|
||||
}, [nodes.length, setNodes]);
|
||||
|
||||
const filteredTasks = selectedCategory === 'all'
|
||||
? catalogTasks
|
||||
: catalogTasks.filter((t) => t.category === selectedCategory);
|
||||
|
||||
return (
|
||||
<div className="flex h-full" style={{ background: 'var(--canvas-bg)' }}>
|
||||
<div
|
||||
className="border-r flex-shrink-0 transition-all duration-300 overflow-hidden flex flex-col"
|
||||
style={{ width: catalogOpen ? 260 : 0, borderColor: 'var(--border-color)', background: 'var(--panel-bg)' }}
|
||||
>
|
||||
<div className="px-3 py-2.5 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>Task 任务库</span>
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>Tekton</span>
|
||||
</div>
|
||||
<div className="p-2 border-b" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
<button onClick={() => setSelectedCategory('all')} className="text-[10px] px-2 py-[3px] rounded font-medium transition-colors" style={{ background: selectedCategory === 'all' ? 'var(--accent-blue)' : 'var(--panel-bg-secondary)', color: selectedCategory === 'all' ? '#fff' : 'var(--text-tertiary)' }}>全部</button>
|
||||
{Object.entries(categoryColors).map(([key, color]) => (
|
||||
<button key={key} onClick={() => setSelectedCategory(key)} className="text-[10px] px-2 py-[3px] rounded font-medium transition-colors" style={{ background: selectedCategory === key ? color : 'var(--panel-bg-secondary)', color: selectedCategory === key ? '#fff' : 'var(--text-tertiary)' }}>
|
||||
{key === 'source' ? '源码' : key === 'build' ? '构建' : key === 'test' ? '测试' : key === 'security' ? '安全' : key === 'deploy' ? '部署' : key === 'gitops' ? 'GitOps' : '其他'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
|
||||
{filteredTasks.map((task) => {
|
||||
const Icon = task.icon;
|
||||
const color = categoryColors[task.category];
|
||||
return (
|
||||
<div key={task.id} onClick={() => addNode(task)} className="p-2.5 rounded-md border cursor-pointer hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Icon className="w-3.5 h-3.5" style={{ color }} />
|
||||
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{task.name}</span>
|
||||
<span className="text-[9px] px-1 py-[1px] rounded font-medium ml-auto" style={{ background: color + '15', color }}>
|
||||
{task.category}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[10px] mb-1" style={{ color: 'var(--text-secondary)' }}>{task.description}</p>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{task.workspaces.length > 0 && <span className="text-[9px] flex items-center gap-0.5" style={{ color: 'var(--text-tertiary)' }}><Box className="w-2.5 h-2.5" />{task.workspaces.length} ws</span>}
|
||||
{task.params.length > 0 && <span className="text-[9px]" style={{ color: 'var(--text-tertiary)' }}>{task.params.length} params</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 relative flex flex-col">
|
||||
<div className="flex-1 relative min-h-0">
|
||||
<ReactFlow
|
||||
nodes={nodes}
|
||||
edges={edges}
|
||||
onNodesChange={onNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onConnect={onConnect}
|
||||
onNodeClick={onNodeClick}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
style={{ background: 'var(--canvas-bg)' }}
|
||||
>
|
||||
<Background gap={16} size={1} color="#d0d7de" />
|
||||
<Controls />
|
||||
<MiniMap nodeStrokeWidth={3} zoomable pannable style={{ background: 'var(--panel-bg)' }} />
|
||||
<Panel position="top-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => setCatalogOpen(!catalogOpen)} className="flex items-center gap-1 px-2 py-[5px] text-[11px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
{catalogOpen ? <ChevronLeft className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}Task 库
|
||||
</button>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button onClick={handleSave} className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md border transition-colors hover:bg-black/[0.02]" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
<Save className="w-3 h-3" />保存
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRun}
|
||||
disabled={isRunning}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[11px] font-medium rounded-md text-white disabled:opacity-60"
|
||||
style={{ background: isRunning ? '#6e7781' : 'var(--accent-blue)' }}
|
||||
>
|
||||
{isRunning
|
||||
? <><CircleDot className="w-3 h-3 animate-spin" />执行中…</>
|
||||
: <><Play className="w-3 h-3" />执行</>}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Panel>
|
||||
</ReactFlow>
|
||||
|
||||
{selectedNode && (
|
||||
<div className="absolute right-4 top-4 w-64 rounded-lg border shadow-lg p-3 z-10" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-[12px] font-semibold" style={{ color: 'var(--text-primary)' }}>节点配置</span>
|
||||
<button onClick={() => setSelectedNode(null)} className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]">
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-[11px] font-mono mb-2" style={{ color: 'var(--text-tertiary)' }}>{selectedNode.id}</div>
|
||||
<div className="text-[12px] font-medium mb-1" style={{ color: 'var(--text-primary)' }}>{selectedNode.data?.label}</div>
|
||||
<div className="text-[10px] mb-2" style={{ color: 'var(--text-secondary)' }}>Category: {selectedNode.data?.category}</div>
|
||||
{selectedNode.data?.config && Object.keys(selectedNode.data.config).length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{Object.entries(selectedNode.data.config).map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<label className="block text-[10px] mb-0.5" style={{ color: 'var(--text-tertiary)' }}>{k}</label>
|
||||
<input
|
||||
value={String(v)}
|
||||
onChange={(e) => {
|
||||
setNodes((nds) =>
|
||||
nds.map((n) =>
|
||||
n.id === selectedNode.id
|
||||
? { ...n, data: { ...n.data, config: { ...n.data?.config, [k]: e.target.value } } }
|
||||
: n
|
||||
)
|
||||
);
|
||||
}}
|
||||
className="w-full px-2 py-[3px] text-[11px] rounded border outline-none"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 底部日志控制台:执行时实时滚动 */}
|
||||
{logOpen && (
|
||||
<div className="border-t flex flex-col" style={{ height: 200, borderColor: 'var(--border-color)', background: 'var(--panel-bg)' }}>
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-3 h-3" style={{ color: 'var(--accent-blue)' }} />
|
||||
<span className="text-[11px] font-semibold" style={{ color: 'var(--text-primary)' }}>执行日志</span>
|
||||
{isRunning && (
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium animate-pulse" style={{ background: '#fff8c5', color: '#9a6700' }}>
|
||||
RUNNING
|
||||
</span>
|
||||
)}
|
||||
<span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>{logs.length} 行</span>
|
||||
</div>
|
||||
<button onClick={() => setLogOpen(false)} className="text-[var(--text-tertiary)] hover:text-[var(--text-primary)]">
|
||||
<X className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div ref={logBoxRef} className="flex-1 overflow-y-auto custom-scrollbar px-3 py-1.5 font-mono text-[11px] leading-5">
|
||||
{logs.length === 0 && (
|
||||
<div style={{ color: 'var(--text-tertiary)' }}>等待日志输出…</div>
|
||||
)}
|
||||
{logs.map((entry, i) => (
|
||||
<div key={i} className="flex gap-2">
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>
|
||||
{entry.timestamp ? new Date(entry.timestamp).toLocaleTimeString('zh-CN', { hour12: false }) : ''}
|
||||
</span>
|
||||
<span style={{ color: entry.level === 'error' ? '#cf222e' : entry.level === 'warn' ? '#9a6700' : 'var(--text-secondary)' }}>
|
||||
{entry.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PipelineFlowEditor(props: FlowEditorProps) {
|
||||
return (
|
||||
<ReactFlowProvider>
|
||||
<FlowEditorInner {...props} />
|
||||
</ReactFlowProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { memo } from 'react';
|
||||
import { Handle, Position } from '@xyflow/react';
|
||||
import type { NodeProps } from '@xyflow/react';
|
||||
import {
|
||||
GitBranch, Package, Shield, Layers,
|
||||
Terminal, BookOpen, Play, CheckCircle2, XCircle, Clock, Loader2
|
||||
} from 'lucide-react';
|
||||
|
||||
export interface TektonNodeData {
|
||||
[key: string]: unknown;
|
||||
label: string;
|
||||
category: string;
|
||||
status?: 'pending' | 'running' | 'success' | 'failed' | 'skipped';
|
||||
duration?: string;
|
||||
config?: Record<string, any>;
|
||||
}
|
||||
|
||||
const categoryConfig: Record<string, { color: string; bg: string; icon: typeof GitBranch }> = {
|
||||
source: { color: '#0969da', bg: '#ddf4ff', icon: GitBranch },
|
||||
build: { color: '#1a7f37', bg: '#dafbe1', icon: Package },
|
||||
test: { color: '#9a6700', bg: '#fff8c5', icon: Terminal },
|
||||
security: { color: '#cf222e', bg: '#ffebe9', icon: Shield },
|
||||
deploy: { color: '#8250df', bg: '#fbefff', icon: Layers },
|
||||
gitops: { color: '#0e7c7b', bg: '#d5f5f6', icon: BookOpen },
|
||||
custom: { color: '#6e7781', bg: '#f6f8fa', icon: Play },
|
||||
};
|
||||
|
||||
const statusConfig: Record<string, { icon: typeof CheckCircle2; color: string }> = {
|
||||
pending: { icon: Clock, color: '#6e7781' },
|
||||
running: { icon: Loader2, color: '#9a6700' },
|
||||
success: { icon: CheckCircle2, color: '#1a7f37' },
|
||||
failed: { icon: XCircle, color: '#cf222e' },
|
||||
skipped: { icon: Clock, color: '#6e7781' },
|
||||
};
|
||||
|
||||
const TektonNode = memo((props: NodeProps) => {
|
||||
const data = props.data as TektonNodeData;
|
||||
const selected = props.selected;
|
||||
const cat = categoryConfig[data?.category] || categoryConfig.custom;
|
||||
const Icon = cat.icon;
|
||||
const st = data?.status ? statusConfig[data.status] : null;
|
||||
const StatusIcon = st?.icon;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rounded-lg border shadow-sm transition-all"
|
||||
style={{
|
||||
width: 220,
|
||||
background: selected ? cat.bg : 'var(--panel-bg)',
|
||||
borderColor: selected ? cat.color : 'var(--border-color)',
|
||||
borderWidth: selected ? 2 : 1,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-2 rounded-t-lg"
|
||||
style={{ background: cat.bg }}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" style={{ color: cat.color }} />
|
||||
<span className="text-[12px] font-semibold truncate flex-1" style={{ color: cat.color }}>
|
||||
{data?.label}
|
||||
</span>
|
||||
{StatusIcon && (
|
||||
<StatusIcon className={`w-3.5 h-3.5 ${data?.status === 'running' ? 'animate-spin' : ''}`} style={{ color: st!.color }} />
|
||||
)}
|
||||
</div>
|
||||
<div className="px-3 py-2">
|
||||
{data?.duration && (
|
||||
<div className="text-[10px] mb-1" style={{ color: 'var(--text-tertiary)' }}>
|
||||
耗时: {data.duration}
|
||||
</div>
|
||||
)}
|
||||
{data?.config && Object.keys(data.config).length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{Object.entries(data.config).slice(0, 3).map(([k, v]) => (
|
||||
<div key={k} className="flex items-center gap-1 text-[10px]">
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>{k}:</span>
|
||||
<span className="font-mono truncate" style={{ color: 'var(--text-secondary)' }}>{String(v)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Handle type="target" position={Position.Top} style={{ width: 8, height: 8, background: cat.color }} />
|
||||
<Handle type="source" position={Position.Bottom} style={{ width: 8, height: 8, background: cat.color }} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
TektonNode.displayName = 'TektonNode';
|
||||
export default TektonNode;
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
@@ -0,0 +1,11 @@
|
||||
"use client"
|
||||
|
||||
import * as AspectRatioPrimitive from "@radix-ui/react-aspect-ratio"
|
||||
|
||||
function AspectRatio({
|
||||
...props
|
||||
}: React.ComponentProps<typeof AspectRatioPrimitive.Root>) {
|
||||
return <AspectRatioPrimitive.Root data-slot="aspect-ratio" {...props} />
|
||||
}
|
||||
|
||||
export { AspectRatio }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,109 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "div"
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Checkbox({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
|
||||
return (
|
||||
<CheckboxPrimitive.Root
|
||||
data-slot="checkbox"
|
||||
className={cn(
|
||||
"peer border-input dark:bg-input/30 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground dark:data-[state=checked]:bg-primary data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<CheckboxPrimitive.Indicator
|
||||
data-slot="checkbox-indicator"
|
||||
className="grid place-content-center text-current transition-none"
|
||||
>
|
||||
<CheckIcon className="size-3.5" />
|
||||
</CheckboxPrimitive.Indicator>
|
||||
</CheckboxPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Checkbox }
|
||||
@@ -0,0 +1,31 @@
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Drawer as DrawerPrimitive } from "vaul"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Drawer({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn(
|
||||
"flex flex-col gap-0.5 p-4 group-data-[vaul-drawer-direction=bottom]/drawer-content:text-center group-data-[vaul-drawer-direction=top]/drawer-content:text-center md:gap-1.5 md:text-left",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
import * as React from "react"
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function DropdownMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.CheckboxItem
|
||||
data-slot="dropdown-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioGroup
|
||||
data-slot="dropdown-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.RadioItem
|
||||
data-slot="dropdown-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<DropdownMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</DropdownMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</DropdownMenuPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Label
|
||||
data-slot="dropdown-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Separator
|
||||
data-slot="dropdown-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="dropdown-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
DropdownMenuPortal,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuSub,
|
||||
DropdownMenuSubTrigger,
|
||||
DropdownMenuSubContent,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,274 @@
|
||||
import * as React from "react"
|
||||
import * as MenubarPrimitive from "@radix-ui/react-menubar"
|
||||
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Menubar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Root>) {
|
||||
return (
|
||||
<MenubarPrimitive.Root
|
||||
data-slot="menubar"
|
||||
className={cn(
|
||||
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarMenu({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Menu>) {
|
||||
return <MenubarPrimitive.Menu data-slot="menubar-menu" {...props} />
|
||||
}
|
||||
|
||||
function MenubarGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Group>) {
|
||||
return <MenubarPrimitive.Group data-slot="menubar-group" {...props} />
|
||||
}
|
||||
|
||||
function MenubarPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Portal>) {
|
||||
return <MenubarPrimitive.Portal data-slot="menubar-portal" {...props} />
|
||||
}
|
||||
|
||||
function MenubarRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioGroup data-slot="menubar-radio-group" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Trigger>) {
|
||||
return (
|
||||
<MenubarPrimitive.Trigger
|
||||
data-slot="menubar-trigger"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex items-center rounded-sm px-2 py-1 text-sm font-medium outline-hidden select-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarContent({
|
||||
className,
|
||||
align = "start",
|
||||
alignOffset = -4,
|
||||
sideOffset = 8,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Content>) {
|
||||
return (
|
||||
<MenubarPortal>
|
||||
<MenubarPrimitive.Content
|
||||
data-slot="menubar-content"
|
||||
align={align}
|
||||
alignOffset={alignOffset}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[12rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-md",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</MenubarPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Item> & {
|
||||
inset?: boolean
|
||||
variant?: "default" | "destructive"
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Item
|
||||
data-slot="menubar-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.CheckboxItem
|
||||
data-slot="menubar-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.CheckboxItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.RadioItem>) {
|
||||
return (
|
||||
<MenubarPrimitive.RadioItem
|
||||
data-slot="menubar-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-xs py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<MenubarPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</MenubarPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</MenubarPrimitive.RadioItem>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Label> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.Label
|
||||
data-slot="menubar-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Separator>) {
|
||||
return (
|
||||
<MenubarPrimitive.Separator
|
||||
data-slot="menubar-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="menubar-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.Sub>) {
|
||||
return <MenubarPrimitive.Sub data-slot="menubar-sub" {...props} />
|
||||
}
|
||||
|
||||
function MenubarSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubTrigger> & {
|
||||
inset?: boolean
|
||||
}) {
|
||||
return (
|
||||
<MenubarPrimitive.SubTrigger
|
||||
data-slot="menubar-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-none select-none data-[inset]:pl-8",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto h-4 w-4" />
|
||||
</MenubarPrimitive.SubTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
function MenubarSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof MenubarPrimitive.SubContent>) {
|
||||
return (
|
||||
<MenubarPrimitive.SubContent
|
||||
data-slot="menubar-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-menubar-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
Menubar,
|
||||
MenubarPortal,
|
||||
MenubarMenu,
|
||||
MenubarTrigger,
|
||||
MenubarContent,
|
||||
MenubarGroup,
|
||||
MenubarSeparator,
|
||||
MenubarLabel,
|
||||
MenubarItem,
|
||||
MenubarShortcut,
|
||||
MenubarCheckboxItem,
|
||||
MenubarRadioGroup,
|
||||
MenubarRadioItem,
|
||||
MenubarSub,
|
||||
MenubarSubTrigger,
|
||||
MenubarSubContent,
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import * as React from "react"
|
||||
import * as NavigationMenuPrimitive from "@radix-ui/react-navigation-menu"
|
||||
import { cva } from "class-variance-authority"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function NavigationMenu({
|
||||
className,
|
||||
children,
|
||||
viewport = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Root> & {
|
||||
viewport?: boolean
|
||||
}) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Root
|
||||
data-slot="navigation-menu"
|
||||
data-viewport={viewport}
|
||||
className={cn(
|
||||
"group/navigation-menu relative flex max-w-max flex-1 items-center justify-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{viewport && <NavigationMenuViewport />}
|
||||
</NavigationMenuPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.List>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.List
|
||||
data-slot="navigation-menu-list"
|
||||
className={cn(
|
||||
"group flex flex-1 list-none items-center justify-center gap-1",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Item>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Item
|
||||
data-slot="navigation-menu-item"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const navigationMenuTriggerStyle = cva(
|
||||
"group inline-flex h-9 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground disabled:pointer-events-none disabled:opacity-50 data-[state=open]:hover:bg-accent data-[state=open]:text-accent-foreground data-[state=open]:focus:bg-accent data-[state=open]:bg-accent/50 focus-visible:ring-ring/50 outline-none transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
)
|
||||
|
||||
function NavigationMenuTrigger({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Trigger
|
||||
data-slot="navigation-menu-trigger"
|
||||
className={cn(navigationMenuTriggerStyle(), "group", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}{" "}
|
||||
<ChevronDownIcon
|
||||
className="relative top-[1px] ml-1 size-3 transition duration-300 group-data-[state=open]:rotate-180"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</NavigationMenuPrimitive.Trigger>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Content>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Content
|
||||
data-slot="navigation-menu-content"
|
||||
className={cn(
|
||||
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
|
||||
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuViewport({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Viewport>) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-full left-0 isolate z-50 flex justify-center"
|
||||
)}
|
||||
>
|
||||
<NavigationMenuPrimitive.Viewport
|
||||
data-slot="navigation-menu-viewport"
|
||||
className={cn(
|
||||
"origin-top-center bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-90 relative mt-1.5 h-[var(--radix-navigation-menu-viewport-height)] w-full overflow-hidden rounded-md border shadow md:w-[var(--radix-navigation-menu-viewport-width)]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuLink({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Link>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Link
|
||||
data-slot="navigation-menu-link"
|
||||
className={cn(
|
||||
"data-[active=true]:focus:bg-accent data-[active=true]:hover:bg-accent data-[active=true]:bg-accent/50 data-[active=true]:text-accent-foreground hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring/50 [&_svg:not([class*='text-'])]:text-muted-foreground flex flex-col gap-1 rounded-sm p-2 text-sm transition-all outline-none focus-visible:ring-[3px] focus-visible:outline-1 [&_svg:not([class*='size-'])]:size-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function NavigationMenuIndicator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NavigationMenuPrimitive.Indicator>) {
|
||||
return (
|
||||
<NavigationMenuPrimitive.Indicator
|
||||
data-slot="navigation-menu-indicator"
|
||||
className={cn(
|
||||
"data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in top-full z-[1] flex h-1.5 items-end justify-center overflow-hidden",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-border relative top-[60%] h-2 w-2 rotate-45 rounded-tl-sm shadow-md" />
|
||||
</NavigationMenuPrimitive.Indicator>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
NavigationMenu,
|
||||
NavigationMenuList,
|
||||
NavigationMenuItem,
|
||||
NavigationMenuContent,
|
||||
NavigationMenuTrigger,
|
||||
NavigationMenuLink,
|
||||
NavigationMenuIndicator,
|
||||
NavigationMenuViewport,
|
||||
navigationMenuTriggerStyle,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react"
|
||||
import * as ProgressPrimitive from "@radix-ui/react-progress"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Progress({
|
||||
className,
|
||||
value,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ProgressPrimitive.Root>) {
|
||||
return (
|
||||
<ProgressPrimitive.Root
|
||||
data-slot="progress"
|
||||
className={cn(
|
||||
"bg-primary/20 relative h-2 w-full overflow-hidden rounded-full",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ProgressPrimitive.Indicator
|
||||
data-slot="progress-indicator"
|
||||
className="bg-primary h-full w-full flex-1 transition-all"
|
||||
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
|
||||
/>
|
||||
</ProgressPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
export { Progress }
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { CircleIcon } from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function RadioGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
data-slot="radio-group"
|
||||
className={cn("grid gap-3", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function RadioGroupItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
data-slot="radio-group-item"
|
||||
className={cn(
|
||||
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator
|
||||
data-slot="radio-group-indicator"
|
||||
className="relative flex items-center justify-center"
|
||||
>
|
||||
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { RadioGroup, RadioGroupItem }
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as React from "react"
|
||||
import { GripVerticalIcon } from "lucide-react"
|
||||
import * as ResizablePrimitive from "react-resizable-panels"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ResizablePanelGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Group>) {
|
||||
return (
|
||||
<ResizablePrimitive.Group
|
||||
data-slot="resizable-panel-group"
|
||||
className={cn(
|
||||
"flex h-full w-full data-[panel-group-direction=vertical]:flex-col",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function ResizablePanel({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Panel>) {
|
||||
return <ResizablePrimitive.Panel data-slot="resizable-panel" {...props} />
|
||||
}
|
||||
|
||||
function ResizableHandle({
|
||||
withHandle,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ResizablePrimitive.Separator> & {
|
||||
withHandle?: boolean
|
||||
}) {
|
||||
return (
|
||||
<ResizablePrimitive.Separator
|
||||
data-slot="resizable-handle"
|
||||
className={cn(
|
||||
"bg-border focus-visible:ring-ring relative flex w-px items-center justify-center after:absolute after:inset-y-0 after:left-1/2 after:w-1 after:-translate-x-1/2 focus-visible:ring-1 focus-visible:ring-offset-1 focus-visible:outline-hidden data-[panel-group-direction=vertical]:h-px data-[panel-group-direction=vertical]:w-full data-[panel-group-direction=vertical]:after:left-0 data-[panel-group-direction=vertical]:after:h-1 data-[panel-group-direction=vertical]:after:w-full data-[panel-group-direction=vertical]:after:translate-x-0 data-[panel-group-direction=vertical]:after:-translate-y-1/2 [&[data-panel-group-direction=vertical]>div]:rotate-90",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{withHandle && (
|
||||
<div className="bg-border z-10 flex h-4 w-3 items-center justify-center rounded-xs border">
|
||||
<GripVerticalIcon className="size-2.5" />
|
||||
</div>
|
||||
)}
|
||||
</ResizablePrimitive.Separator>
|
||||
)
|
||||
}
|
||||
|
||||
export { ResizablePanelGroup, ResizablePanel, ResizableHandle }
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function ScrollArea({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.Root
|
||||
data-slot="scroll-area"
|
||||
className={cn("relative", className)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.Viewport
|
||||
data-slot="scroll-area-viewport"
|
||||
className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1"
|
||||
>
|
||||
{children}
|
||||
</ScrollAreaPrimitive.Viewport>
|
||||
<ScrollBar />
|
||||
<ScrollAreaPrimitive.Corner />
|
||||
</ScrollAreaPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ScrollBar({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) {
|
||||
return (
|
||||
<ScrollAreaPrimitive.ScrollAreaScrollbar
|
||||
data-slot="scroll-area-scrollbar"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"flex touch-none p-px transition-colors select-none",
|
||||
orientation === "vertical" &&
|
||||
"h-full w-2.5 border-l border-l-transparent",
|
||||
orientation === "horizontal" &&
|
||||
"h-2.5 flex-col border-t border-t-transparent",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ScrollAreaPrimitive.ScrollAreaThumb
|
||||
data-slot="scroll-area-thumb"
|
||||
className="bg-border relative flex-1 rounded-full"
|
||||
/>
|
||||
</ScrollAreaPrimitive.ScrollAreaScrollbar>
|
||||
)
|
||||
}
|
||||
|
||||
export { ScrollArea, ScrollBar }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
CircleCheckIcon,
|
||||
InfoIcon,
|
||||
Loader2Icon,
|
||||
OctagonXIcon,
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { Toaster as Sonner, type ToasterProps } from "sonner"
|
||||
|
||||
const Toaster = ({ ...props }: ToasterProps) => {
|
||||
return (
|
||||
<Sonner
|
||||
theme="light"
|
||||
className="toaster group"
|
||||
icons={{
|
||||
success: <CircleCheckIcon className="size-4" />,
|
||||
info: <InfoIcon className="size-4" />,
|
||||
warning: <TriangleAlertIcon className="size-4" />,
|
||||
error: <OctagonXIcon className="size-4" />,
|
||||
loading: <Loader2Icon className="size-4 animate-spin" />,
|
||||
}}
|
||||
style={
|
||||
{
|
||||
"--normal-bg": "var(--popover)",
|
||||
"--normal-text": "var(--popover-foreground)",
|
||||
"--normal-border": "var(--border)",
|
||||
"--border-radius": "var(--radius)",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Toaster }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={cn(
|
||||
"border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export { Textarea }
|
||||
@@ -0,0 +1,81 @@
|
||||
import * as React from "react"
|
||||
import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group"
|
||||
import { type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toggleVariants } from "@/components/ui/toggle"
|
||||
|
||||
const ToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}
|
||||
>({
|
||||
size: "default",
|
||||
variant: "default",
|
||||
spacing: 0,
|
||||
})
|
||||
|
||||
function ToggleGroup({
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
spacing = 0,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
spacing?: number
|
||||
}) {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
data-slot="toggle-group"
|
||||
data-variant={variant}
|
||||
data-size={size}
|
||||
data-spacing={spacing}
|
||||
style={{ "--gap": spacing } as React.CSSProperties}
|
||||
className={cn(
|
||||
"group/toggle-group flex w-fit items-center gap-[--spacing(var(--gap))] rounded-md data-[spacing=default]:data-[variant=outline]:shadow-xs",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<ToggleGroupContext.Provider value={{ variant, size, spacing }}>
|
||||
{children}
|
||||
</ToggleGroupContext.Provider>
|
||||
</ToggleGroupPrimitive.Root>
|
||||
)
|
||||
}
|
||||
|
||||
function ToggleGroupItem({
|
||||
className,
|
||||
children,
|
||||
variant,
|
||||
size,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants>) {
|
||||
const context = React.useContext(ToggleGroupContext)
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
data-slot="toggle-group-item"
|
||||
data-variant={context.variant || variant}
|
||||
data-size={context.size || size}
|
||||
data-spacing={context.spacing}
|
||||
className={cn(
|
||||
toggleVariants({
|
||||
variant: context.variant || variant,
|
||||
size: context.size || size,
|
||||
}),
|
||||
"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10",
|
||||
"data-[spacing=0]:rounded-none data-[spacing=0]:shadow-none data-[spacing=0]:first:rounded-l-md data-[spacing=0]:last:rounded-r-md data-[spacing=0]:data-[variant=outline]:border-l-0 data-[spacing=0]:data-[variant=outline]:first:border-l",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</ToggleGroupPrimitive.Item>
|
||||
)
|
||||
}
|
||||
|
||||
export { ToggleGroup, ToggleGroupItem }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react"
|
||||
|
||||
const MOBILE_BREAKPOINT = 768
|
||||
|
||||
export function useIsMobile() {
|
||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined)
|
||||
|
||||
React.useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`)
|
||||
const onChange = () => {
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
}
|
||||
mql.addEventListener("change", onChange)
|
||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT)
|
||||
return () => mql.removeEventListener("change", onChange)
|
||||
}, [])
|
||||
|
||||
return !!isMobile
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
|
||||
export interface LogMessage {
|
||||
id?: number;
|
||||
runId?: number;
|
||||
stageId?: number;
|
||||
level?: string;
|
||||
message?: string;
|
||||
source?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅一次运行的实时日志(SSE)。
|
||||
* runId 为后端生成的 pr-<timestamp>;不存在时连接会失败并保持 disconnected。
|
||||
*/
|
||||
export function usePipelineLogs(runId: string | null, enabled: boolean) {
|
||||
const [logs, setLogs] = useState<LogMessage[]>([]);
|
||||
const [connected, setConnected] = useState(false);
|
||||
|
||||
const clearLogs = useCallback(() => setLogs([]), []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled || !runId) {
|
||||
setConnected(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const es = new EventSource(`/api/runs/${runId}/logs/stream`);
|
||||
|
||||
es.addEventListener('connected', () => setConnected(true));
|
||||
es.addEventListener('log', (evt) => {
|
||||
try {
|
||||
const entry: LogMessage = JSON.parse((evt as MessageEvent).data);
|
||||
setLogs((prev) => [...prev, entry]);
|
||||
} catch {
|
||||
// ignore invalid JSON
|
||||
}
|
||||
});
|
||||
es.onerror = () => setConnected(false);
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
setConnected(false);
|
||||
};
|
||||
}, [runId, enabled]);
|
||||
|
||||
return { logs, connected, clearLogs };
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 240 10% 3.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--secondary: 240 4.8% 95.9%;
|
||||
--secondary-foreground: 240 5.9% 10%;
|
||||
--muted: 240 4.8% 95.9%;
|
||||
--muted-foreground: 240 3.8% 46.1%;
|
||||
--accent: 240 4.8% 95.9%;
|
||||
--accent-foreground: 240 5.9% 10%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 5.9% 90%;
|
||||
--input: 240 5.9% 90%;
|
||||
--ring: 240 5.9% 10%;
|
||||
--radius: 0.5rem;
|
||||
--sidebar-background: 0 0% 98%;
|
||||
--sidebar-foreground: 240 5.3% 26.1%;
|
||||
--sidebar-primary: 240 5.9% 10%;
|
||||
--sidebar-primary-foreground: 0 0% 98%;
|
||||
--sidebar-accent: 240 4.8% 95.9%;
|
||||
--sidebar-accent-foreground: 240 5.9% 10%;
|
||||
--sidebar-border: 220 13% 91%;
|
||||
--sidebar-ring: 217.2 91.2% 59.8%;
|
||||
|
||||
--canvas-bg: #f0f2f5;
|
||||
--panel-bg: #ffffff;
|
||||
--panel-bg-secondary: #f8f9fa;
|
||||
--border-color: #d0d7de;
|
||||
--border-color-light: #e1e4e8;
|
||||
--text-primary: #1f2328;
|
||||
--text-secondary: #656d76;
|
||||
--text-tertiary: #8c959f;
|
||||
--accent-blue: #0969da;
|
||||
--accent-blue-light: #ddf4ff;
|
||||
--accent-green: #1a7f37;
|
||||
--accent-red: #cf222e;
|
||||
--accent-amber: #9a6700;
|
||||
--accent-purple: #8250df;
|
||||
--node-source: #0969da;
|
||||
--node-build: #9a6700;
|
||||
--node-deploy: #1a7f37;
|
||||
--node-test: #8250df;
|
||||
--node-approval: #bc4c00;
|
||||
--node-custom: #6e7781;
|
||||
--line-active: #0969da;
|
||||
--line-success: #1a7f37;
|
||||
--line-pending: #d0d7de;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 240 10% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 10% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 10% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 240 5.9% 10%;
|
||||
--secondary: 240 3.7% 15.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 240 3.7% 15.9%;
|
||||
--muted-foreground: 240 5% 64.9%;
|
||||
--accent: 240 3.7% 15.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 240 3.7% 15.9%;
|
||||
--input: 240 3.7% 15.9%;
|
||||
--ring: 240 4.9% 83.9%;
|
||||
|
||||
--canvas-bg: #0d1117;
|
||||
--panel-bg: #161b22;
|
||||
--panel-bg-secondary: #21262d;
|
||||
--border-color: #30363d;
|
||||
--border-color-light: #21262d;
|
||||
--text-primary: #c9d1d9;
|
||||
--text-secondary: #8b949e;
|
||||
--text-tertiary: #6e7681;
|
||||
--accent-blue: #58a6ff;
|
||||
--accent-blue-light: #388bfd;
|
||||
--accent-green: #3fb950;
|
||||
--accent-red: #f85149;
|
||||
--accent-amber: #d29922;
|
||||
--accent-purple: #bc8cff;
|
||||
--node-source: #58a6ff;
|
||||
--node-build: #d29922;
|
||||
--node-deploy: #3fb950;
|
||||
--node-test: #bc8cff;
|
||||
--node-approval: #f0883e;
|
||||
--node-custom: #8b949e;
|
||||
--line-active: #58a6ff;
|
||||
--line-success: #3fb950;
|
||||
--line-pending: #30363d;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dot grid background */
|
||||
.dot-grid-bg {
|
||||
background-color: var(--canvas-bg);
|
||||
background-image: radial-gradient(circle, #d0d7de 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.dark .dot-grid-bg {
|
||||
background-color: var(--canvas-bg);
|
||||
background-image: radial-gradient(circle, #30363d 1px, transparent 1px);
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
/* Animated flowing line */
|
||||
.flowing-line {
|
||||
stroke-dasharray: 8 4;
|
||||
animation: flowLine 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes flowLine {
|
||||
0% {
|
||||
stroke-dashoffset: 0;
|
||||
}
|
||||
100% {
|
||||
stroke-dashoffset: -24;
|
||||
}
|
||||
}
|
||||
|
||||
/* Running status pulse */
|
||||
.running-pulse {
|
||||
animation: runningPulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes runningPulse {
|
||||
0%, 100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.6);
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
/* Glow effect for active connections */
|
||||
.glow-line {
|
||||
filter: drop-shadow(0 0 2px rgba(9, 105, 218, 0.4));
|
||||
}
|
||||
|
||||
.dark .glow-line {
|
||||
filter: drop-shadow(0 0 3px rgba(88, 166, 255, 0.5));
|
||||
}
|
||||
|
||||
/* Node hover lift - subtle */
|
||||
.node-card {
|
||||
transition: box-shadow 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.node-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Scrollbar styling */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #d0d7de;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #8c959f;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #30363d;
|
||||
}
|
||||
|
||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #484f58;
|
||||
}
|
||||
|
||||
/* Minimap styling */
|
||||
.minimap-bg {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dark .minimap-bg {
|
||||
background: rgba(22, 27, 34, 0.95);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
/* Tab indicator animation */
|
||||
.tab-indicator {
|
||||
transition: left 0.3s ease, width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Draggable item ghost */
|
||||
.dragging-ghost {
|
||||
opacity: 0.8;
|
||||
transform: rotate(2deg);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
/* Panel transitions */
|
||||
.panel-transition {
|
||||
transition: transform 0.3s ease, width 0.3s ease;
|
||||
}
|
||||
|
||||
/* Flow particle on line */
|
||||
@keyframes flowParticle {
|
||||
0% {
|
||||
offset-distance: 0%;
|
||||
opacity: 0;
|
||||
}
|
||||
10% {
|
||||
opacity: 1;
|
||||
}
|
||||
90% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
offset-distance: 100%;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.flow-particle {
|
||||
animation: flowParticle 2s linear infinite;
|
||||
offset-path: path('M0,0 C50,0 50,50 100,50');
|
||||
}
|
||||
|
||||
/* Node selection ring - subtle */
|
||||
@keyframes selectionRing {
|
||||
0% {
|
||||
box-shadow: 0 0 0 0px rgba(9, 105, 218, 0.25);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 3px rgba(9, 105, 218, 0.15);
|
||||
}
|
||||
100% {
|
||||
box-shadow: 0 0 0 0px rgba(9, 105, 218, 0.25);
|
||||
}
|
||||
}
|
||||
|
||||
.selection-ring {
|
||||
animation: selectionRing 2s ease-in-out infinite;
|
||||
box-shadow: 0 0 0 1.5px var(--accent-blue);
|
||||
}
|
||||
|
||||
/* Button hover effects */
|
||||
.btn-primary {
|
||||
background: var(--accent-blue);
|
||||
color: white;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
/* Responsive panel collapse */
|
||||
@media (max-width: 1280px) {
|
||||
.left-panel {
|
||||
width: 48px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Tab content fade-in animation */
|
||||
.tab-content-fade {
|
||||
animation: tabContentFadeIn 200ms ease-out;
|
||||
}
|
||||
|
||||
@keyframes tabContentFadeIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus ring for inputs - thinner */
|
||||
input:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
ring: 1px;
|
||||
ring-color: rgba(9, 105, 218, 0.25);
|
||||
}
|
||||
|
||||
/* Script editor styling */
|
||||
.script-editor {
|
||||
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* Context menu styling */
|
||||
.context-menu {
|
||||
background: var(--panel-bg);
|
||||
border: 1px solid var(--border-color);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
border-radius: 6px;
|
||||
padding: 4px;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s ease;
|
||||
}
|
||||
|
||||
.context-menu-item:hover {
|
||||
background: var(--panel-bg-secondary);
|
||||
}
|
||||
|
||||
.context-menu-item-danger {
|
||||
color: var(--accent-red);
|
||||
}
|
||||
|
||||
.context-menu-item-danger:hover {
|
||||
background: rgba(207, 34, 46, 0.08);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// FlowStack gin 后端 REST 客户端
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
constructor(message: string, status: number) {
|
||||
super(message)
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T = any>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
headers: { 'Content-Type': 'application/json', ...(init?.headers || {}) },
|
||||
...init,
|
||||
})
|
||||
if (!res.ok) {
|
||||
let message = `请求失败 (HTTP ${res.status})`
|
||||
try {
|
||||
const body = await res.json()
|
||||
if (body?.error) message = body.error
|
||||
} catch {
|
||||
// 非 JSON 响应体
|
||||
}
|
||||
throw new ApiError(message, res.status)
|
||||
}
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ---- 类型定义(与后端 model 对齐) ----
|
||||
|
||||
export interface PipelineNodeDTO {
|
||||
dbId?: number
|
||||
pipelineId?: number
|
||||
nodeId: string
|
||||
type: string
|
||||
name: string
|
||||
positionX: number
|
||||
positionY: number
|
||||
width: number
|
||||
height: number
|
||||
category: string
|
||||
config?: Record<string, any>
|
||||
}
|
||||
|
||||
export interface PipelineEdgeDTO {
|
||||
dbId?: number
|
||||
pipelineId?: number
|
||||
edgeId: string
|
||||
source: string
|
||||
target: string
|
||||
label?: string
|
||||
animated?: boolean
|
||||
}
|
||||
|
||||
export interface PipelineDetail {
|
||||
pipeline: any
|
||||
nodes: PipelineNodeDTO[]
|
||||
edges: PipelineEdgeDTO[]
|
||||
}
|
||||
|
||||
export interface PipelineRunDTO {
|
||||
id: number
|
||||
runId: string
|
||||
pipelineId: number
|
||||
pipelineName: string
|
||||
branch: string
|
||||
env: string
|
||||
status: 'pending' | 'running' | 'success' | 'failed' | 'cancelled'
|
||||
durationMs: number
|
||||
startedAt: string
|
||||
completedAt?: string
|
||||
}
|
||||
|
||||
export const apiClient = {
|
||||
// 流水线
|
||||
listPipelines: () => api<any[]>('/api/pipelines'),
|
||||
getPipeline: (id: number | string) => api<PipelineDetail>(`/api/pipelines/${id}`),
|
||||
createPipeline: (body: any) => api('/api/pipelines', { method: 'POST', body: JSON.stringify(body) }),
|
||||
updatePipeline: (id: number | string, body: any) => api(`/api/pipelines/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
|
||||
deletePipeline: (id: number | string) => api(`/api/pipelines/${id}`, { method: 'DELETE' }),
|
||||
saveNodes: (id: number | string, nodes: PipelineNodeDTO[]) =>
|
||||
api(`/api/pipelines/${id}/nodes`, { method: 'PUT', body: JSON.stringify({ nodes }) }),
|
||||
saveEdges: (id: number | string, edges: PipelineEdgeDTO[]) =>
|
||||
api(`/api/pipelines/${id}/edges`, { method: 'PUT', body: JSON.stringify({ edges }) }),
|
||||
// 运行
|
||||
createRun: (pipelineId: number | string, body?: any) =>
|
||||
api<PipelineRunDTO>(`/api/pipelines/${pipelineId}/runs`, { method: 'POST', body: JSON.stringify(body || {}) }),
|
||||
listRuns: (params?: { pipelineId?: number | string; status?: string }) => {
|
||||
const q = new URLSearchParams()
|
||||
if (params?.pipelineId) q.set('pipelineId', String(params.pipelineId))
|
||||
if (params?.status && params.status !== 'all') q.set('status', params.status)
|
||||
const qs = q.toString()
|
||||
return api<PipelineRunDTO[]>(`/api/runs${qs ? `?${qs}` : ''}`)
|
||||
},
|
||||
getRun: (runId: string) => api<any>(`/api/runs/${runId}`),
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { BrowserRouter } from 'react-router'
|
||||
import { Toaster } from '@/components/ui/sonner'
|
||||
import './index.css'
|
||||
import { QueryProvider } from "@/providers/query"
|
||||
import App from './App.tsx'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<BrowserRouter>
|
||||
<QueryProvider>
|
||||
<App />
|
||||
<Toaster position="top-right" />
|
||||
</QueryProvider>
|
||||
</BrowserRouter>,
|
||||
)
|
||||
@@ -0,0 +1,250 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
ChevronRight, CheckCircle2, Circle, GitBranch, Server,
|
||||
Play, Loader2, Terminal, ArrowRight, CloudCog, Package, AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface PipelineItem {
|
||||
id: string; name: string; branch: string; type: 'native' | 'jenkins';
|
||||
deployType: 'host' | 'k8s'; env: string; lastStatus: string;
|
||||
}
|
||||
|
||||
const mockPipelines: PipelineItem[] = [
|
||||
{ id: '1', name: 'aiops-backend', branch: 'master', type: 'native', deployType: 'k8s', env: 'production', lastStatus: 'success' },
|
||||
{ id: '2', name: 'aiops-web', branch: 'master', type: 'native', deployType: 'host', env: 'production', lastStatus: 'success' },
|
||||
{ id: '3', name: 'gateway-api', branch: 'release/v2.1', type: 'jenkins', deployType: 'k8s', env: 'staging', lastStatus: 'pending' },
|
||||
{ id: '4', name: 'admin-dashboard', branch: 'master', type: 'native', deployType: 'host', env: 'staging', lastStatus: 'success' },
|
||||
{ id: '5', name: 'data-processor', branch: 'develop', type: 'jenkins', deployType: 'k8s', env: 'test', lastStatus: 'failed' },
|
||||
{ id: '6', name: 'notification-service', branch: 'master', type: 'native', deployType: 'k8s', env: 'production', lastStatus: 'success' },
|
||||
];
|
||||
|
||||
type StepType = 'select' | 'configure' | 'confirm' | 'executing' | 'done';
|
||||
const steps: { id: StepType; label: string }[] = [
|
||||
{ id: 'select', label: '选择流水线' },
|
||||
{ id: 'configure', label: '配置参数' },
|
||||
{ id: 'confirm', label: '确认发布' },
|
||||
{ id: 'executing', label: '执行中' },
|
||||
{ id: 'done', label: '完成' },
|
||||
];
|
||||
|
||||
export default function BatchRelease() {
|
||||
const [currentStep, setCurrentStep] = useState<StepType>('select');
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||
const [execLogs, setExecLogs] = useState<string[]>([]);
|
||||
const [completedCount, setCompletedCount] = useState(0);
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds(prev => prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id]);
|
||||
};
|
||||
|
||||
const selectedPipelines = mockPipelines.filter(p => selectedIds.includes(p.id));
|
||||
const stepIndex = steps.findIndex(s => s.id === currentStep);
|
||||
|
||||
const startExecution = () => {
|
||||
setCurrentStep('executing');
|
||||
setExecLogs([`[${new Date().toLocaleTimeString()}] 批量发布开始,共 ${selectedIds.length} 个流水线`]);
|
||||
|
||||
let count = 0;
|
||||
selectedPipelines.forEach((p, idx) => {
|
||||
setTimeout(() => {
|
||||
setExecLogs(prev => [
|
||||
...prev,
|
||||
`[${new Date().toLocaleTimeString()}] [${idx + 1}/${selectedPipelines.length}] 开始发布: ${p.name} (${p.branch})`,
|
||||
`[${new Date().toLocaleTimeString()}] -> 拉取代码...`,
|
||||
`[${new Date().toLocaleTimeString()}] -> 构建中...`,
|
||||
`[${new Date().toLocaleTimeString()}] -> ${p.deployType === 'k8s' ? '更新 K8s Deployment...' : 'SCP 上传 + SSH 部署...'}`,
|
||||
`[${new Date().toLocaleTimeString()}] ✓ ${p.name} 发布成功`,
|
||||
]);
|
||||
count++;
|
||||
setCompletedCount(count);
|
||||
if (count === selectedPipelines.length) {
|
||||
setTimeout(() => {
|
||||
setExecLogs(prev => [...prev, `[${new Date().toLocaleTimeString()}] 批量发布全部完成`]);
|
||||
setCurrentStep('done');
|
||||
}, 500);
|
||||
}
|
||||
}, idx * 2500);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>批量发布</h1>
|
||||
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>向导</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-b flex items-center justify-center gap-0 flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
{steps.map((step, idx) => {
|
||||
const isActive = idx === stepIndex;
|
||||
const isDone = idx < stepIndex;
|
||||
return (
|
||||
<div key={step.id} className="flex items-center">
|
||||
<div className="flex items-center gap-2 px-3">
|
||||
{isDone ? <CheckCircle2 className="w-4 h-4" style={{ color: 'var(--accent-green)' }} /> :
|
||||
isActive ? <div className="w-4 h-4 rounded-full border-2 flex items-center justify-center" style={{ borderColor: 'var(--accent-blue)' }}><div className="w-2 h-2 rounded-full" style={{ background: 'var(--accent-blue)' }} /></div> :
|
||||
<Circle className="w-4 h-4" style={{ color: 'var(--border-color)' }} />}
|
||||
<span className="text-[12px] font-medium" style={{ color: isActive ? 'var(--accent-blue)' : isDone ? 'var(--text-secondary)' : 'var(--text-tertiary)' }}>{step.label}</span>
|
||||
</div>
|
||||
{idx < steps.length - 1 && <ChevronRight className="w-4 h-4 mx-1" style={{ color: idx < stepIndex ? 'var(--accent-green)' : 'var(--border-color)' }} />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||
{currentStep === 'select' && (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<p className="text-[13px]" style={{ color: 'var(--text-secondary)' }}>选择要批量发布的流水线(支持原生流水线和 Jenkins Job 混合发布)</p>
|
||||
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>已选择 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个</span>
|
||||
</div>
|
||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="grid grid-cols-[40px_1fr_120px_100px_100px_80px] gap-2 px-4 py-2 text-[11px] font-semibold uppercase" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||
<div></div><div>流水线</div><div>分支</div><div>类型</div><div>部署方式</div><div>环境</div>
|
||||
</div>
|
||||
{mockPipelines.map((p) => {
|
||||
const isSelected = selectedIds.includes(p.id);
|
||||
return (
|
||||
<div key={p.id} onClick={() => toggleSelect(p.id)}
|
||||
className="grid grid-cols-[40px_1fr_120px_100px_100px_80px] gap-2 px-4 py-2.5 border-t items-center cursor-pointer hover:bg-black/[0.02] transition-colors"
|
||||
style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<div><div className="w-4 h-4 rounded border flex items-center justify-center" style={{ borderColor: isSelected ? 'var(--accent-blue)' : 'var(--border-color)', background: isSelected ? 'var(--accent-blue)' : 'transparent' }}>
|
||||
{isSelected && <CheckCircle2 className="w-3 h-3 text-white" />}
|
||||
</div></div>
|
||||
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{p.name}</div>
|
||||
<div className="flex items-center gap-1 text-[12px]" style={{ color: 'var(--text-secondary)' }}><GitBranch className="w-3 h-3" /> {p.branch}</div>
|
||||
<div><span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: p.type === 'native' ? '#ddf4ff' : '#fbefff', color: p.type === 'native' ? '#0969da' : '#8250df' }}>{p.type === 'native' ? '原生' : 'Jenkins'}</span></div>
|
||||
<div className="text-[12px]" style={{ color: 'var(--text-secondary)' }}>{p.deployType === 'k8s' ? <span className="flex items-center gap-1"><CloudCog className="w-3 h-3" /> K8s</span> : <span className="flex items-center gap-1"><Server className="w-3 h-3" /> 主机</span>}</div>
|
||||
<div className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{p.env}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex justify-end mt-4">
|
||||
<button onClick={() => selectedIds.length > 0 ? setCurrentStep('configure') : toast.error('请至少选择一个流水线')}
|
||||
className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
|
||||
下一步 <ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 'configure' && (
|
||||
<div className="max-w-3xl mx-auto space-y-4">
|
||||
<p className="text-[13px] mb-3" style={{ color: 'var(--text-secondary)' }}>配置发布参数,所有选中的流水线将使用相同的发布策略</p>
|
||||
<div className="rounded-lg border p-4 space-y-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>发布策略</label>
|
||||
<select className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
||||
<option>串行发布(逐个执行,失败即停)</option><option>并行发布(同时执行,互不影响)</option><option>分组发布(按环境分组串行)</option>
|
||||
</select></div>
|
||||
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>失败处理</label>
|
||||
<select className="w-full px-3 py-[7px] rounded-md border text-[13px]" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
||||
<option>立即中止后续发布</option><option>继续发布其他流水线</option><option>标记失败并跳过</option>
|
||||
</select></div>
|
||||
</div>
|
||||
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>K8s 就绪检查</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Deployment 副本就绪</span></label>
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>检查 Service 可用性</span></label>
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>等待 Pod 健康检查通过</span></label>
|
||||
</div></div>
|
||||
<div><label className="block text-[12px] font-medium mb-1.5" style={{ color: 'var(--text-secondary)' }}>发布后通知</label>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" defaultChecked className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>飞书通知</span></label>
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>钉钉通知</span></label>
|
||||
<label className="flex items-center gap-2 text-[13px] cursor-pointer"><input type="checkbox" className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} /><span style={{ color: 'var(--text-primary)' }}>Webhook</span></label>
|
||||
</div></div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="text-[12px] font-medium mb-2" style={{ color: 'var(--text-secondary)' }}>已选择的流水线</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{selectedPipelines.map((p) => (
|
||||
<span key={p.id} className="text-[11px] px-2 py-[3px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
|
||||
<Package className="w-3 h-3" />{p.name}</span>
|
||||
))}
|
||||
</div></div>
|
||||
<div className="flex justify-between">
|
||||
<button onClick={() => setCurrentStep('select')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>上一步</button>
|
||||
<button onClick={() => setCurrentStep('confirm')} className="flex items-center gap-1.5 px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>下一步 <ArrowRight className="w-4 h-4" /></button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 'confirm' && (
|
||||
<div className="max-w-2xl mx-auto text-center py-8">
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ background: 'var(--accent-blue-light)' }}>
|
||||
<AlertCircle className="w-8 h-8" style={{ color: 'var(--accent-blue)' }} />
|
||||
</div>
|
||||
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>确认批量发布</h2>
|
||||
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>即将发布 <span className="font-semibold" style={{ color: 'var(--accent-blue)' }}>{selectedIds.length}</span> 个流水线,串行执行,失败即停</p>
|
||||
<div className="rounded-lg border p-4 mb-6 text-left" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
{selectedPipelines.map((p, idx) => (
|
||||
<div key={p.id} className="flex items-center gap-3 py-2" style={{ borderTop: idx > 0 ? '1px solid var(--border-color-light)' : 'none' }}>
|
||||
<span className="text-[11px] w-5 text-center font-medium" style={{ color: 'var(--text-tertiary)' }}>{idx + 1}</span>
|
||||
<GitBranch className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span className="text-[13px] font-medium flex-1" style={{ color: 'var(--text-primary)' }}>{p.name}</span>
|
||||
<span className="text-[11px] px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{p.branch}</span>
|
||||
<span className="text-[10px] px-1.5 py-[2px] rounded font-medium" style={{ background: p.type === 'native' ? '#ddf4ff' : '#fbefff', color: p.type === 'native' ? '#0969da' : '#8250df' }}>{p.type === 'native' ? '原生' : 'Jenkins'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex justify-center gap-3">
|
||||
<button onClick={() => setCurrentStep('configure')} className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>返回修改</button>
|
||||
<button onClick={startExecution} className="flex items-center gap-1.5 px-5 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
|
||||
<Play className="w-4 h-4" />开始发布
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 'executing' && (
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="px-4 py-2.5 border-b flex items-center justify-between" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Loader2 className="w-4 h-4 animate-spin" style={{ color: 'var(--accent-blue)' }} />
|
||||
<span className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>批量发布执行中</span>
|
||||
</div>
|
||||
<span className="text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{completedCount} / {selectedIds.length} 完成</span>
|
||||
</div>
|
||||
<div className="p-0">
|
||||
<div className="px-4 py-2 border-b" style={{ background: '#0d1117', borderColor: '#21262d' }}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal className="w-3.5 h-3.5" style={{ color: '#7d8590' }} />
|
||||
<span className="text-[11px] font-medium" style={{ color: '#7d8590' }}>执行日志</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-3 font-mono text-[12px] leading-relaxed max-h-[400px] overflow-y-auto" style={{ background: '#0d1117', color: '#e6edf3' }}>
|
||||
{execLogs.map((log, idx) => (
|
||||
<div key={idx} className="py-[1px]">{log.includes('✓') ? <span style={{ color: '#3fb950' }}>{log}</span> : log.includes('->') ? <span style={{ color: '#58a6ff' }}>{log}</span> : log}</div>
|
||||
))}
|
||||
<div className="py-[1px] animate-pulse"><span style={{ color: '#7d8590' }}>_</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentStep === 'done' && (
|
||||
<div className="max-w-2xl mx-auto text-center py-8">
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center mx-auto mb-4" style={{ background: '#dafbe1' }}>
|
||||
<CheckCircle2 className="w-8 h-8" style={{ color: '#1a7f37' }} />
|
||||
</div>
|
||||
<h2 className="text-[16px] font-semibold mb-2" style={{ color: 'var(--text-primary)' }}>批量发布完成</h2>
|
||||
<p className="text-[13px] mb-6" style={{ color: 'var(--text-secondary)' }}>共成功发布 <span className="font-semibold" style={{ color: 'var(--accent-green)' }}>{selectedIds.length}</span> 个流水线</p>
|
||||
<div className="flex justify-center gap-3">
|
||||
<button onClick={() => { setCurrentStep('select'); setSelectedIds([]); setExecLogs([]); setCompletedCount(0); }}
|
||||
className="px-4 py-[7px] text-[13px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color)', color: 'var(--text-secondary)' }}>继续发布</button>
|
||||
<button onClick={() => toast.success('已跳转至发布记录')} className="px-4 py-[7px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>查看记录</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Cpu,
|
||||
Plus,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Tag,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface BuildNode {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
labels: string[];
|
||||
maxConcurrency: number;
|
||||
currentJobs: number;
|
||||
status: 'online' | 'offline' | 'busy';
|
||||
cpu: string;
|
||||
memory: string;
|
||||
disk: string;
|
||||
lastHeartbeat: string;
|
||||
}
|
||||
|
||||
const mockNodes: BuildNode[] = [
|
||||
{ id: '1', name: 'build-host-01', ip: '192.168.10.101', labels: ['docker', 'go', 'java'], maxConcurrency: 4, currentJobs: 2, status: 'busy', cpu: '45%', memory: '6.2GB/16GB', disk: '120GB/500GB', lastHeartbeat: '10秒前' },
|
||||
{ id: '2', name: 'build-host-02', ip: '192.168.10.102', labels: ['docker', 'node', 'python'], maxConcurrency: 3, currentJobs: 0, status: 'online', cpu: '12%', memory: '2.1GB/8GB', disk: '80GB/300GB', lastHeartbeat: '5秒前' },
|
||||
{ id: '3', name: 'build-host-03', ip: '192.168.10.103', labels: ['k8s', 'helm'], maxConcurrency: 2, currentJobs: 0, status: 'offline', cpu: '-', memory: '-', disk: '-', lastHeartbeat: '2小时前' },
|
||||
];
|
||||
|
||||
const statusConfig = {
|
||||
online: { icon: CheckCircle2, color: '#1a7f37', bg: '#dafbe1', label: '在线' },
|
||||
offline: { icon: XCircle, color: '#cf222e', bg: '#ffebe9', label: '离线' },
|
||||
busy: { icon: Loader2, color: '#9a6700', bg: '#fff8c5', label: '忙碌' },
|
||||
};
|
||||
|
||||
export default function BuildNodeManager() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const filtered = mockNodes.filter(n =>
|
||||
n.name.includes(searchQuery) || n.ip.includes(searchQuery)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||
<div
|
||||
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>构建节点</h1>
|
||||
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||
{filtered.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2 px-3 py-[6px] rounded-md border" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
|
||||
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<input value={searchQuery} onChange={(e) => setSearchQuery(e.target.value)} placeholder="搜索节点..." className="bg-transparent outline-none w-32 text-[13px]" style={{ color: 'var(--text-primary)' }} />
|
||||
</div>
|
||||
<button className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }} onClick={() => toast.info('添加节点', { description: '功能开发中...' })}>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
添加节点
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
{[
|
||||
{ label: '在线节点', count: 2, color: '#1a7f37' },
|
||||
{ label: '忙碌节点', count: 1, color: '#9a6700' },
|
||||
{ label: '离线节点', count: 1, color: '#cf222e' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
|
||||
<div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filtered.map((node) => {
|
||||
const status = statusConfig[node.status];
|
||||
const StatusIcon = status.icon;
|
||||
const utilization = node.status === 'offline' ? 0 : (node.currentJobs / node.maxConcurrency) * 100;
|
||||
return (
|
||||
<div key={node.id} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Cpu className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{node.name}</div>
|
||||
<div className="text-[11px] font-mono" style={{ color: 'var(--text-tertiary)' }}>{node.ip}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] px-2 py-[3px] rounded-full font-medium flex items-center gap-1" style={{ background: status.bg, color: status.color }}>
|
||||
<StatusIcon className={`w-3 h-3 ${node.status === 'busy' ? 'animate-spin' : ''}`} />
|
||||
{status.label}
|
||||
</span>
|
||||
<button className="p-1 rounded hover:bg-black/5"><Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
|
||||
<button className="p-1 rounded hover:bg-black/5"><Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{node.status !== 'offline' && (
|
||||
<>
|
||||
{/* Concurrency bar */}
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center justify-between text-[11px] mb-1">
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>并发任务</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{node.currentJobs} / {node.maxConcurrency}</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full overflow-hidden" style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${utilization}%`,
|
||||
background: utilization > 80 ? 'var(--accent-red)' : utilization > 50 ? 'var(--accent-amber)' : 'var(--accent-green)',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource metrics */}
|
||||
<div className="grid grid-cols-4 gap-3 text-[11px]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Activity className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span style={{ color: 'var(--text-secondary)' }}>CPU {node.cpu}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>内存</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{node.memory}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>磁盘</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{node.disk}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span style={{ color: 'var(--text-tertiary)' }}>心跳</span>
|
||||
<span style={{ color: 'var(--text-secondary)' }}>{node.lastHeartbeat}</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Labels */}
|
||||
<div className="flex items-center gap-1 mt-3 flex-wrap">
|
||||
{node.labels.map((label) => (
|
||||
<span key={label} className="text-[10px] px-1.5 py-[2px] rounded font-medium flex items-center gap-1" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>
|
||||
<Tag className="w-2.5 h-2.5" />
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Key,
|
||||
Plus,
|
||||
Search,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Copy,
|
||||
Trash2,
|
||||
Edit2,
|
||||
Shield,
|
||||
GitBranch,
|
||||
Container
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Credential {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'ssh' | 'password' | 'docker' | 'git' | 'kubeconfig';
|
||||
scope: string;
|
||||
createdAt: string;
|
||||
usageCount: number;
|
||||
}
|
||||
|
||||
const mockCredentials: Credential[] = [
|
||||
{ id: '1', name: 'production-ssh-key', type: 'ssh', scope: 'production-group', createdAt: '2024-06-01', usageCount: 12 },
|
||||
{ id: '2', name: 'docker-registry-auth', type: 'docker', scope: '全局', createdAt: '2024-06-05', usageCount: 8 },
|
||||
{ id: '3', name: 'gitee-token', type: 'git', scope: '全局', createdAt: '2024-06-10', usageCount: 5 },
|
||||
{ id: '4', name: 'staging-ssh-key', type: 'ssh', scope: 'staging-group', createdAt: '2024-06-12', usageCount: 3 },
|
||||
{ id: '5', name: 'ack-kubeconfig', type: 'kubeconfig', scope: 'ack-production', createdAt: '2024-06-15', usageCount: 6 },
|
||||
{ id: '6', name: 'db-password', type: 'password', scope: 'production-group', createdAt: '2024-06-18', usageCount: 2 },
|
||||
];
|
||||
|
||||
const typeConfig: Record<string, { icon: typeof Key; label: string; color: string; bg: string }> = {
|
||||
ssh: { icon: Key, label: 'SSH密钥', color: '#0969da', bg: '#ddf4ff' },
|
||||
password: { icon: Shield, label: '用户名密码', color: '#9a6700', bg: '#fff8c5' },
|
||||
docker: { icon: Container, label: 'Docker认证', color: '#1a7f37', bg: '#dafbe1' },
|
||||
git: { icon: GitBranch, label: 'Git凭证', color: '#8250df', bg: '#fbefff' },
|
||||
kubeconfig: { icon: Key, label: 'Kubeconfig', color: '#bc4c00', bg: '#fff1e5' },
|
||||
};
|
||||
|
||||
export default function CredentialManager() {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showSecrets, setShowSecrets] = useState<Record<string, boolean>>({});
|
||||
|
||||
const filtered = mockCredentials.filter(c =>
|
||||
c.name.includes(searchQuery) || c.type.includes(searchQuery)
|
||||
);
|
||||
|
||||
const toggleSecret = (id: string) => {
|
||||
setShowSecrets(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
};
|
||||
|
||||
const copyValue = (name: string) => {
|
||||
toast.success('已复制', { description: `${name} 的值已复制到剪贴板` });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||
<div
|
||||
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>凭证管理</h1>
|
||||
<span
|
||||
className="text-[11px] px-2 py-[2px] rounded-full font-medium"
|
||||
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{filtered.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-[6px] rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="搜索凭证..."
|
||||
className="bg-transparent outline-none w-32 text-[13px]"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
onClick={() => toast.info('新建凭证', { description: '功能开发中...' })}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
新建凭证
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||
{/* Type summary */}
|
||||
<div className="grid grid-cols-5 gap-3 mb-4">
|
||||
{Object.entries(typeConfig).map(([type, config]) => {
|
||||
const count = mockCredentials.filter(c => c.type === type).length;
|
||||
const Icon = config.icon;
|
||||
return (
|
||||
<div key={type} className="rounded-lg border p-3 flex items-center gap-3" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="w-8 h-8 rounded-md flex items-center justify-center" style={{ background: config.bg }}>
|
||||
<Icon className="w-4 h-4" style={{ color: config.color }} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{config.label}</div>
|
||||
<div className="text-[16px] font-bold" style={{ color: 'var(--text-primary)' }}>{count}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Credential list */}
|
||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
{['凭证名称', '类型', '使用范围', '使用次数', '创建时间', '值', '操作'].map((h) => (
|
||||
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((cred) => {
|
||||
const config = typeConfig[cred.type];
|
||||
const Icon = config.icon;
|
||||
const isRevealed = showSecrets[cred.id];
|
||||
return (
|
||||
<tr key={cred.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-6 h-6 rounded flex items-center justify-center" style={{ background: config.bg }}>
|
||||
<Icon className="w-3 h-3" style={{ color: config.color }} />
|
||||
</div>
|
||||
<span className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{cred.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-[11px] px-2 py-[3px] rounded font-medium" style={{ background: config.bg, color: config.color }}>
|
||||
{config.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.scope}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{cred.usageCount}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-tertiary)' }}>{cred.createdAt}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-[11px] px-2 py-[3px] rounded font-mono" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-secondary)' }}>
|
||||
{isRevealed ? 'AKIAIOSFODNN7EXAMPLE' : '••••••••••••••••'}
|
||||
</code>
|
||||
<button onClick={() => toggleSecret(cred.id)} className="p-1 rounded hover:bg-black/5">
|
||||
{isRevealed ? <EyeOff className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} /> : <Eye className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />}
|
||||
</button>
|
||||
<button onClick={() => copyValue(cred.name)} className="p-1 rounded hover:bg-black/5">
|
||||
<Copy className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-0">
|
||||
<button className="p-1 rounded hover:bg-black/5"><Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
|
||||
<button className="p-1 rounded hover:bg-black/5"><Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Server,
|
||||
Plus,
|
||||
Search,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Terminal,
|
||||
CloudCog,
|
||||
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface Host {
|
||||
id: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
port: number;
|
||||
username: string;
|
||||
tags: string[];
|
||||
status: 'online' | 'offline';
|
||||
os: string;
|
||||
}
|
||||
|
||||
interface K8sCluster {
|
||||
id: string;
|
||||
name: string;
|
||||
context: string;
|
||||
version: string;
|
||||
nodes: number;
|
||||
namespaces: string[];
|
||||
status: 'connected' | 'disconnected';
|
||||
}
|
||||
|
||||
const mockHosts: Host[] = [
|
||||
{ id: '1', name: 'prod-web-01', ip: '192.168.1.101', port: 22, username: 'root', tags: ['production', 'web'], status: 'online', os: 'CentOS 8' },
|
||||
{ id: '2', name: 'prod-web-02', ip: '192.168.1.102', port: 22, username: 'root', tags: ['production', 'web'], status: 'online', os: 'CentOS 8' },
|
||||
{ id: '3', name: 'prod-api-01', ip: '192.168.1.111', port: 22, username: 'root', tags: ['production', 'api'], status: 'online', os: 'Ubuntu 22.04' },
|
||||
{ id: '4', name: 'staging-web-01', ip: '192.168.2.101', port: 22, username: 'admin', tags: ['staging', 'web'], status: 'online', os: 'Ubuntu 22.04' },
|
||||
{ id: '5', name: 'staging-api-01', ip: '192.168.2.111', port: 22, username: 'admin', tags: ['staging', 'api'], status: 'offline', os: 'CentOS 7' },
|
||||
];
|
||||
|
||||
const mockClusters: K8sCluster[] = [
|
||||
{ id: '1', name: 'ack-production', context: 'ack-prod', version: 'v1.28.3', nodes: 6, namespaces: ['aiops', 'gateway', 'monitoring'], status: 'connected' },
|
||||
{ id: '2', name: 'ack-staging', context: 'ack-staging', version: 'v1.27.8', nodes: 3, namespaces: ['aiops', 'default'], status: 'connected' },
|
||||
{ id: '3', name: 'tke-test', context: 'tke-test', version: 'v1.26.5', nodes: 2, namespaces: ['default'], status: 'disconnected' },
|
||||
];
|
||||
|
||||
export default function EnvironmentManager() {
|
||||
const [activeTab, setActiveTab] = useState<'hosts' | 'k8s'>('hosts');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const filteredHosts = mockHosts.filter(h =>
|
||||
h.name.includes(searchQuery) || h.ip.includes(searchQuery)
|
||||
);
|
||||
|
||||
const filteredClusters = mockClusters.filter(c =>
|
||||
c.name.includes(searchQuery) || c.context.includes(searchQuery)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||
{/* Header */}
|
||||
<div
|
||||
className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>环境管理</h1>
|
||||
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<button
|
||||
onClick={() => setActiveTab('hosts')}
|
||||
className="px-3 py-[5px] text-[12px] font-medium transition-colors"
|
||||
style={{
|
||||
background: activeTab === 'hosts' ? 'var(--panel-bg-secondary)' : 'transparent',
|
||||
color: activeTab === 'hosts' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
主机组
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('k8s')}
|
||||
className="px-3 py-[5px] text-[12px] font-medium transition-colors"
|
||||
style={{
|
||||
background: activeTab === 'k8s' ? 'var(--panel-bg-secondary)' : 'transparent',
|
||||
color: activeTab === 'k8s' ? 'var(--accent-blue)' : 'var(--text-secondary)',
|
||||
}}
|
||||
>
|
||||
K8s集群
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 px-3 py-[6px] rounded-md border"
|
||||
style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}
|
||||
>
|
||||
<Search className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={activeTab === 'hosts' ? '搜索主机...' : '搜索集群...'}
|
||||
className="bg-transparent outline-none w-32 text-[13px]"
|
||||
style={{ color: 'var(--text-primary)' }}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-1.5 px-3 py-[6px] text-[13px] font-medium rounded-md text-white"
|
||||
style={{ background: 'var(--accent-blue)' }}
|
||||
onClick={() => toast.info(`添加${activeTab === 'hosts' ? '主机' : '集群'}`, { description: '功能开发中...' })}
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
添加{activeTab === 'hosts' ? '主机' : '集群'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||
{activeTab === 'hosts' ? (
|
||||
<div className="space-y-3">
|
||||
{/* Host groups summary */}
|
||||
<div className="grid grid-cols-4 gap-3 mb-4">
|
||||
{[
|
||||
{ label: '生产环境', count: 3, online: 3, color: '#1a7f37' },
|
||||
{ label: '预发环境', count: 2, online: 1, color: '#9a6700' },
|
||||
{ label: '测试环境', count: 0, online: 0, color: '#8250df' },
|
||||
{ label: '总计', count: 5, online: 4, color: '#0969da' },
|
||||
].map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="rounded-lg border p-4"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</span>
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>
|
||||
在线 {stat.online}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Host list */}
|
||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr style={{ background: 'var(--panel-bg-secondary)' }}>
|
||||
{['主机名', 'IP地址', '端口', '用户名', '标签', '操作系统', '状态', '操作'].map((h) => (
|
||||
<th key={h} className="text-left px-4 py-2 text-[11px] font-semibold uppercase tracking-wider" style={{ color: 'var(--text-tertiary)' }}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredHosts.map((host) => (
|
||||
<tr key={host.id} className="border-t hover:bg-black/[0.02] transition-colors" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>{host.name}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px] font-mono" style={{ color: 'var(--text-secondary)' }}>{host.ip}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.port}</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.username}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{host.tags.map((tag) => (
|
||||
<span
|
||||
key={tag}
|
||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
|
||||
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-[12px]" style={{ color: 'var(--text-secondary)' }}>{host.os}</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-1">
|
||||
{host.status === 'online' ? (
|
||||
<CheckCircle2 className="w-3.5 h-3.5" style={{ color: 'var(--accent-green)' }} />
|
||||
) : (
|
||||
<XCircle className="w-3.5 h-3.5" style={{ color: 'var(--accent-red)' }} />
|
||||
)}
|
||||
<span className="text-[12px]" style={{ color: host.status === 'online' ? 'var(--accent-green)' : 'var(--accent-red)' }}>
|
||||
{host.status === 'online' ? '在线' : '离线'}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-0">
|
||||
<button className="p-1 rounded hover:bg-black/5" title="SSH连接" onClick={() => toast.info('SSH连接', { description: `${host.username}@${host.ip}:${host.port}` })}>
|
||||
<Terminal className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
<button className="p-1 rounded hover:bg-black/5" title="编辑">
|
||||
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
<button className="p-1 rounded hover:bg-black/5" title="删除">
|
||||
<Trash2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* K8s Clusters */
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-3 gap-3 mb-4">
|
||||
{[
|
||||
{ label: '已连接', count: 2, color: '#1a7f37' },
|
||||
{ label: '未连接', count: 1, color: '#cf222e' },
|
||||
{ label: '总节点数', count: 11, color: '#0969da' },
|
||||
].map((stat) => (
|
||||
<div key={stat.label} className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>{stat.label}</div>
|
||||
<div className="text-[22px] font-bold" style={{ color: 'var(--text-primary)' }}>{stat.count}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filteredClusters.map((cluster) => (
|
||||
<div
|
||||
key={cluster.id}
|
||||
className="rounded-lg border p-4"
|
||||
style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<CloudCog className="w-5 h-5" style={{ color: 'var(--accent-blue)' }} />
|
||||
<div>
|
||||
<div className="text-[14px] font-semibold" style={{ color: 'var(--text-primary)' }}>{cluster.name}</div>
|
||||
<div className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>Context: {cluster.context}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="text-[11px] px-2 py-[3px] rounded-full font-medium"
|
||||
style={{
|
||||
background: cluster.status === 'connected' ? '#dafbe1' : '#ffebe9',
|
||||
color: cluster.status === 'connected' ? '#1a7f37' : '#cf222e',
|
||||
}}
|
||||
>
|
||||
{cluster.status === 'connected' ? '已连接' : '未连接'}
|
||||
</span>
|
||||
<button className="p-1 rounded hover:bg-black/5">
|
||||
<Edit2 className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4 text-[12px]">
|
||||
<div>
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>版本</div>
|
||||
<div style={{ color: 'var(--text-primary)' }}>{cluster.version}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>节点数</div>
|
||||
<div style={{ color: 'var(--text-primary)' }}>{cluster.nodes}</div>
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>命名空间</div>
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{cluster.namespaces.map((ns) => (
|
||||
<span
|
||||
key={ns}
|
||||
className="text-[10px] px-1.5 py-[2px] rounded font-medium"
|
||||
style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}
|
||||
>
|
||||
{ns}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CloudSync, GitBranch, RefreshCw, CheckCircle2, XCircle,
|
||||
Clock, ChevronRight, TreePine, Diff, Layers,
|
||||
Server, AlertTriangle, FolderGit, Package
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { api } from '@/lib/api';
|
||||
|
||||
interface Application {
|
||||
id: string;
|
||||
name: string;
|
||||
repo: string;
|
||||
branch: string;
|
||||
path: string;
|
||||
cluster: string;
|
||||
namespace: string;
|
||||
status: 'synced' | 'out-of-sync' | 'syncing' | 'error' | 'unknown';
|
||||
syncStatus: 'synced' | 'out-of-sync';
|
||||
health: 'healthy' | 'degraded' | 'progressing' | 'suspended';
|
||||
lastSync: string;
|
||||
resources: number;
|
||||
manifestType: 'helm' | 'kustomize' | 'plain';
|
||||
autoSync: boolean;
|
||||
}
|
||||
|
||||
interface ResourceNode {
|
||||
id: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
namespace: string;
|
||||
status: 'healthy' | 'degraded' | 'progressing' | 'suspended';
|
||||
children?: ResourceNode[];
|
||||
}
|
||||
|
||||
const mockApps: Application[] = [
|
||||
{
|
||||
id: 'app-1', name: 'aiops-backend', repo: 'https://gitee.com/attacker/aiops-gitops.git',
|
||||
branch: 'main', path: 'apps/aiops-backend/overlays/production',
|
||||
cluster: 'production-k8s', namespace: 'aiops', status: 'synced', syncStatus: 'synced',
|
||||
health: 'healthy', lastSync: '2分钟前', resources: 12, manifestType: 'kustomize', autoSync: true,
|
||||
},
|
||||
{
|
||||
id: 'app-2', name: 'aiops-web', repo: 'https://gitee.com/attacker/aiops-gitops.git',
|
||||
branch: 'main', path: 'apps/aiops-web/overlays/production',
|
||||
cluster: 'production-k8s', namespace: 'aiops', status: 'syncing', syncStatus: 'out-of-sync',
|
||||
health: 'progressing', lastSync: '正在同步', resources: 8, manifestType: 'helm', autoSync: true,
|
||||
},
|
||||
{
|
||||
id: 'app-3', name: 'gateway-api', repo: 'https://gitee.com/attacker/aiops-gitops.git',
|
||||
branch: 'release/v2.1', path: 'apps/gateway/overlays/staging',
|
||||
cluster: 'staging-k8s', namespace: 'gateway', status: 'out-of-sync', syncStatus: 'out-of-sync',
|
||||
health: 'degraded', lastSync: '1小时前', resources: 6, manifestType: 'kustomize', autoSync: false,
|
||||
},
|
||||
{
|
||||
id: 'app-4', name: 'data-processor', repo: 'https://gitee.com/attacker/aiops-gitops.git',
|
||||
branch: 'main', path: 'apps/data/overlays/production',
|
||||
cluster: 'production-k8s', namespace: 'data', status: 'synced', syncStatus: 'synced',
|
||||
health: 'healthy', lastSync: '30分钟前', resources: 5, manifestType: 'plain', autoSync: true,
|
||||
},
|
||||
];
|
||||
|
||||
const mockResourceTree: ResourceNode[] = [
|
||||
{
|
||||
id: 'r1', kind: 'Deployment', name: 'aiops-backend', namespace: 'aiops', status: 'healthy',
|
||||
children: [
|
||||
{ id: 'r1-1', kind: 'ReplicaSet', name: 'aiops-backend-7d9f4', namespace: 'aiops', status: 'healthy',
|
||||
children: [
|
||||
{ id: 'r1-1-1', kind: 'Pod', name: 'aiops-backend-7d9f4-abc1', namespace: 'aiops', status: 'healthy' },
|
||||
{ id: 'r1-1-2', kind: 'Pod', name: 'aiops-backend-7d9f4-abc2', namespace: 'aiops', status: 'healthy' },
|
||||
{ id: 'r1-1-3', kind: 'Pod', name: 'aiops-backend-7d9f4-abc3', namespace: 'aiops', status: 'healthy' },
|
||||
]
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'r2', kind: 'Service', name: 'aiops-backend-svc', namespace: 'aiops', status: 'healthy',
|
||||
},
|
||||
{
|
||||
id: 'r3', kind: 'Ingress', name: 'aiops-backend-ingress', namespace: 'aiops', status: 'healthy',
|
||||
},
|
||||
{
|
||||
id: 'r4', kind: 'ConfigMap', name: 'aiops-backend-config', namespace: 'aiops', status: 'healthy',
|
||||
},
|
||||
];
|
||||
|
||||
const statusConfig: Record<string, { color: string; bg: string; label: string; icon: typeof CheckCircle2 }> = {
|
||||
synced: { color: '#1a7f37', bg: '#dafbe1', label: '已同步', icon: CheckCircle2 },
|
||||
'out-of-sync': { color: '#9a6700', bg: '#fff8c5', label: '待同步', icon: Clock },
|
||||
syncing: { color: '#0969da', bg: '#ddf4ff', label: '同步中', icon: RefreshCw },
|
||||
error: { color: '#cf222e', bg: '#ffebe9', label: '错误', icon: XCircle },
|
||||
unknown: { color: '#6e7781', bg: '#f6f8fa', label: '未知', icon: AlertTriangle },
|
||||
};
|
||||
|
||||
const healthConfig: Record<string, { color: string; label: string; bg: string }> = {
|
||||
healthy: { color: '#1a7f37', label: '健康', bg: '#dafbe1' },
|
||||
degraded: { color: '#cf222e', label: '降级', bg: '#ffebe9' },
|
||||
progressing: { color: '#9a6700', label: '进行中', bg: '#fff8c5' },
|
||||
suspended: { color: '#6e7781', label: '暂停', bg: '#f6f8fa' },
|
||||
};
|
||||
|
||||
const manifestConfig: Record<string, { label: string; icon: typeof Package }> = {
|
||||
helm: { label: 'Helm', icon: Package },
|
||||
kustomize: { label: 'Kustomize', icon: Layers },
|
||||
plain: { label: 'Plain YAML', icon: FolderGit },
|
||||
};
|
||||
|
||||
export default function GitOpsDeploy() {
|
||||
const [selectedAppId, setSelectedAppId] = useState<string | null>(null);
|
||||
const [activeView, setActiveView] = useState<'list' | 'tree' | 'diff'>('list');
|
||||
|
||||
const { data: dbApps = [] } = useQuery({ queryKey: ['gitops', 'apps'], queryFn: () => api<any[]>('/api/gitops/apps') });
|
||||
const { data: argoApps = [] } = useQuery({ queryKey: ['gitops', 'argo'], queryFn: () => api<any[]>('/api/gitops/argo/apps') });
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: (appName: string) => api<any>('/api/gitops/argo/sync', { method: 'POST', body: JSON.stringify({ appName }) }),
|
||||
onSuccess: (data) => toast.success(data.message),
|
||||
onError: (err: Error) => toast.error(err.message),
|
||||
});
|
||||
|
||||
const apps: Application[] = dbApps.length > 0
|
||||
? dbApps.map((db: any) => {
|
||||
const argo = (argoApps as any[]).find((a: any) => a.metadata?.name === db.name);
|
||||
return {
|
||||
id: String(db.id),
|
||||
name: db.name,
|
||||
repo: db.repo,
|
||||
branch: db.branch,
|
||||
path: db.path,
|
||||
cluster: db.cluster,
|
||||
namespace: db.namespace,
|
||||
status: argo?.status?.sync?.status === 'Synced' ? 'synced' : argo?.status?.sync?.status === 'OutOfSync' ? 'out-of-sync' : 'unknown',
|
||||
syncStatus: argo?.status?.sync?.status === 'Synced' ? 'synced' : 'out-of-sync',
|
||||
health: (argo?.status?.health?.status?.toLowerCase() || 'unknown') as any,
|
||||
lastSync: '刚刚',
|
||||
resources: 0,
|
||||
manifestType: db.manifestType,
|
||||
autoSync: db.syncPolicy === 'automated',
|
||||
};
|
||||
})
|
||||
: mockApps;
|
||||
|
||||
const selectedApp = apps.find(a => a.id === selectedAppId);
|
||||
|
||||
const renderResourceNode = (node: ResourceNode, depth: number = 0) => {
|
||||
const health = healthConfig[node.status];
|
||||
const hasChildren = node.children && node.children.length > 0;
|
||||
return (
|
||||
<div key={node.id} className="select-none">
|
||||
<div
|
||||
className="flex items-center gap-2 py-1.5 px-3 hover:bg-black/[0.02] rounded-md cursor-pointer"
|
||||
style={{ paddingLeft: `${12 + depth * 20}px` }}
|
||||
>
|
||||
{hasChildren ? <ChevronRight className="w-3.5 h-3.5" style={{ color: 'var(--text-tertiary)' }} /> : <div className="w-3.5" />}
|
||||
<div className="w-2 h-2 rounded-full flex-shrink-0" style={{ background: health.color }} />
|
||||
<span className="text-[11px] font-medium px-1.5 py-[1px] rounded" style={{ background: 'var(--panel-bg-secondary)', color: 'var(--text-tertiary)' }}>{node.kind}</span>
|
||||
<span className="text-[12px] font-medium" style={{ color: 'var(--text-primary)' }}>{node.name}</span>
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{node.namespace}</span>
|
||||
</div>
|
||||
{hasChildren && node.children!.map(child => renderResourceNode(child, depth + 1))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col" style={{ background: 'var(--canvas-bg)' }}>
|
||||
{/* Header */}
|
||||
<div className="px-5 py-3 border-b flex items-center justify-between flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-[15px] font-semibold" style={{ color: 'var(--text-primary)' }}>GitOps 部署</h1>
|
||||
<span className="text-[11px] px-2 py-[2px] rounded-full font-medium" style={{ background: '#ddf4ff', color: 'var(--accent-blue)' }}>Argo CD</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center border rounded-md overflow-hidden" style={{ borderColor: 'var(--border-color-light)' }}>
|
||||
{[
|
||||
{ id: 'list', label: '应用列表', icon: Layers },
|
||||
{ id: 'tree', label: '资源树', icon: TreePine },
|
||||
{ id: 'diff', label: '差异对比', icon: Diff },
|
||||
].map(v => (
|
||||
<button key={v.id} onClick={() => setActiveView(v.id as any)}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium transition-colors"
|
||||
style={{ background: activeView === v.id ? 'var(--panel-bg-secondary)' : 'transparent', color: activeView === v.id ? 'var(--accent-blue)' : 'var(--text-secondary)' }}>
|
||||
<v.icon className="w-3.5 h-3.5" />{v.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button onClick={() => toast.success('刷新应用状态')} className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
<RefreshCw className="w-3.5 h-3.5" />刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex">
|
||||
{/* App list sidebar */}
|
||||
<div className="w-[280px] border-r flex flex-col flex-shrink-0" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="px-3 py-2 text-[11px] font-semibold uppercase" style={{ color: 'var(--text-tertiary)' }}>Applications ({apps.length})</div>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar">
|
||||
{apps.map(app => {
|
||||
const st = statusConfig[app.status];
|
||||
const hl = healthConfig[app.health];
|
||||
const mf = manifestConfig[app.manifestType];
|
||||
const isSelected = selectedAppId === app.id;
|
||||
return (
|
||||
<div key={app.id} onClick={() => setSelectedAppId(app.id)}
|
||||
className="mx-2 mb-1 p-2.5 rounded-md cursor-pointer border transition-all"
|
||||
style={{
|
||||
background: isSelected ? 'var(--accent-blue-light)' : 'transparent',
|
||||
borderColor: isSelected ? 'var(--accent-blue)' : 'transparent',
|
||||
}}>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>{app.name}</span>
|
||||
<span className="text-[10px] px-1.5 py-[1px] rounded font-medium flex items-center gap-1" style={{ background: st.bg, color: st.color }}>
|
||||
<st.icon className="w-3 h-3" />{st.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<GitBranch className="w-3 h-3" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<span className="text-[11px] font-mono truncate" style={{ color: 'var(--text-secondary)' }}>{app.repo.replace('https://', '').replace('.git', '')}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] flex items-center gap-1" style={{ color: 'var(--text-tertiary)' }}><mf.icon className="w-3 h-3" />{mf.label}</span>
|
||||
<span className="text-[10px]" style={{ color: hl.color }}>{hl.label}</span>
|
||||
</div>
|
||||
<span className="text-[10px]" style={{ color: 'var(--text-tertiary)' }}>{app.lastSync}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detail view */}
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar p-5">
|
||||
{!selectedApp ? (
|
||||
<div className="h-full flex flex-col items-center justify-center text-center" style={{ color: 'var(--text-tertiary)' }}>
|
||||
<CloudSync className="w-12 h-12 mb-3 opacity-30" />
|
||||
<p className="text-[14px]">选择一个 Application 查看详情</p>
|
||||
<p className="text-[12px] mt-1">Git 仓库作为部署声明的单一事实来源</p>
|
||||
</div>
|
||||
) : activeView === 'list' ? (
|
||||
<div className="space-y-4">
|
||||
{/* App header */}
|
||||
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-[16px] font-semibold" style={{ color: 'var(--text-primary)' }}>{selectedApp.name}</h2>
|
||||
<span className="text-[10px] px-2 py-[2px] rounded-full font-medium" style={{ background: statusConfig[selectedApp.status].bg, color: statusConfig[selectedApp.status].color }}>
|
||||
{statusConfig[selectedApp.status].label}
|
||||
</span>
|
||||
<span className="text-[10px] px-2 py-[2px] rounded-full font-medium" style={{ background: healthConfig[selectedApp.health].bg || '#f6f8fa', color: healthConfig[selectedApp.health].color }}>
|
||||
{healthConfig[selectedApp.health].label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => syncMutation.mutate(selectedApp.name)}
|
||||
disabled={syncMutation.isPending}
|
||||
className="flex items-center gap-1 px-3 py-[5px] text-[12px] font-medium rounded-md text-white disabled:opacity-50" style={{ background: 'var(--accent-blue)' }}>
|
||||
<RefreshCw className={`w-3.5 h-3.5 ${syncMutation.isPending ? 'animate-spin' : ''}`} />立即同步
|
||||
</button>
|
||||
<button onClick={() => toast.info('回滚', { description: '选择要回滚的版本' })}
|
||||
className="px-3 py-[5px] text-[12px] font-medium rounded-md border" style={{ borderColor: 'var(--border-color-light)', color: 'var(--text-secondary)' }}>
|
||||
回滚
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-4 text-[12px]">
|
||||
<div><div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>Git 仓库</div><div className="font-mono" style={{ color: 'var(--text-primary)' }}>{selectedApp.repo}</div></div>
|
||||
<div><div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>分支 / 路径</div><div style={{ color: 'var(--text-primary)' }}>{selectedApp.branch} / {selectedApp.path}</div></div>
|
||||
<div><div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>目标集群</div><div className="flex items-center gap-1" style={{ color: 'var(--text-primary)' }}><Server className="w-3 h-3" />{selectedApp.cluster}</div></div>
|
||||
<div><div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>命名空间</div><div style={{ color: 'var(--text-primary)' }}>{selectedApp.namespace}</div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sync config */}
|
||||
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>同步配置</h3>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-[12px] cursor-pointer">
|
||||
<input type="checkbox" checked={selectedApp.autoSync} readOnly className="w-4 h-4" style={{ accentColor: 'var(--accent-blue)' }} />
|
||||
<span style={{ color: 'var(--text-primary)' }}>自动同步</span>
|
||||
</label>
|
||||
</div>
|
||||
<div><label className="block text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>同步策略</label>
|
||||
<select className="w-full px-2 py-[5px] text-[12px] rounded-md border" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
||||
<option>自动(Git 变更触发)</option><option>手动(仅点击同步)</option><option>定时同步</option>
|
||||
</select></div>
|
||||
<div><label className="block text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>Prune 资源</label>
|
||||
<select className="w-full px-2 py-[5px] text-[12px] rounded-md border" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)', color: 'var(--text-primary)' }}>
|
||||
<option>自动清理已删除资源</option><option>保留所有资源</option>
|
||||
</select></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Environment promotion */}
|
||||
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>环境晋升</h3>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 rounded-md border p-3 text-center" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>开发环境</div>
|
||||
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>dev</div>
|
||||
<div className="text-[10px] mt-1" style={{ color: '#1a7f37' }}>已同步</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<div className="flex-1 rounded-md border p-3 text-center" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>测试环境</div>
|
||||
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>staging</div>
|
||||
<div className="text-[10px] mt-1" style={{ color: '#9a6700' }}>待同步</div>
|
||||
</div>
|
||||
<ChevronRight className="w-4 h-4" style={{ color: 'var(--text-tertiary)' }} />
|
||||
<div className="flex-1 rounded-md border p-3 text-center" style={{ background: 'var(--panel-bg-secondary)', borderColor: 'var(--border-color-light)' }}>
|
||||
<div className="text-[11px] mb-1" style={{ color: 'var(--text-tertiary)' }}>生产环境</div>
|
||||
<div className="text-[13px] font-medium" style={{ color: 'var(--text-primary)' }}>production</div>
|
||||
<div className="text-[10px] mt-1" style={{ color: '#1a7f37' }}>已同步</div>
|
||||
</div>
|
||||
<button onClick={() => toast.success('晋升', { description: '将 staging 版本晋升到 production' })}
|
||||
className="px-3 py-[7px] text-[12px] font-medium rounded-md text-white" style={{ background: 'var(--accent-blue)' }}>
|
||||
晋升
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : activeView === 'tree' ? (
|
||||
<div className="rounded-lg border overflow-hidden" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<div className="px-4 py-2 border-b flex items-center justify-between" style={{ borderColor: 'var(--border-color)' }}>
|
||||
<span className="text-[13px] font-semibold" style={{ color: 'var(--text-primary)' }}>资源树</span>
|
||||
<span className="text-[11px]" style={{ color: 'var(--text-tertiary)' }}>{mockResourceTree.length} 个顶级资源</span>
|
||||
</div>
|
||||
<div className="py-2">
|
||||
{mockResourceTree.map(node => renderResourceNode(node))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border p-4" style={{ background: 'var(--panel-bg)', borderColor: 'var(--border-color)' }}>
|
||||
<h3 className="text-[13px] font-semibold mb-3" style={{ color: 'var(--text-primary)' }}>Git vs 集群差异</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#dafbe1' }}>
|
||||
<CheckCircle2 className="w-4 h-4" style={{ color: '#1a7f37' }} />
|
||||
<span className="text-[12px]" style={{ color: '#1a7f37' }}>Deployment/aiops-backend: Git 和集群一致</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#fff8c5' }}>
|
||||
<AlertTriangle className="w-4 h-4" style={{ color: '#9a6700' }} />
|
||||
<span className="text-[12px]" style={{ color: '#9a6700' }}>Service/aiops-backend-svc: 集群中端口被手动修改 (80 {'->'} 8080)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 p-2 rounded-md" style={{ background: '#ffebe9' }}>
|
||||
<XCircle className="w-4 h-4" style={{ color: '#cf222e' }} />
|
||||
<span className="text-[12px]" style={{ color: '#cf222e' }}>ConfigMap/aiops-backend-config: Git 中新增,集群中不存在</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { useState } from 'react'
|
||||
import '../App.css'
|
||||
|
||||
export default function Home() {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1>Vite + React</h1>
|
||||
<div className="card">
|
||||
<button onClick={() => setCount((count) => count + 1)}>
|
||||
count is {count}
|
||||
</button>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test HMR
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
Loaded 100 of 115 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user