feat: 修复仪表盘数据管线 — 补充应用发布种子数据 + 修复limit参数解析 + LDAP认证集成 + User类型完善

后端改进:
- 修复 dashboard/recent-alerts 的 limit 参数解析 (原代码错误地将数字解析为日期)
- 为 dashboard/recent-releases 添加 limit 参数解析支持
- 新增 generateAppReleases 种子数据生成函数 (10条应用发布记录)
- 修复 AlertEvent 的 rule_name/rule_id/severity 数据一致性
- 添加 go-ldap/ldap/v3 依赖并集成 LDAP 认证处理器
- 添加 seed.go 自动种子数据初始化 (admin用户+LDAP配置)
- User model 添加 AuthSource/LDAPConfig 字段

前端改进:
- 修复 User 类型定义以匹配后端 API (real_name/is_admin/auth_source等)
- 修复 Header.tsx 的 real_name 属性引用导致 TypeScript 编译错误
- 统一 API 服务层、暗色主题样式、组件联调优化

验证:
- go build ./... 编译通过
- npm run build 构建通过
- 仪表盘发布历史、告警消息等区域数据正常展示
- 健康检查 /health 正常
This commit is contained in:
attacker committed 2026-06-14 02:11:10 +08:00
1 parent 97c4207ff8
commit ff0b268984
39 files changed
+3801 -1396

No files matched your search

+3
View File
@@ -68,6 +68,9 @@ func main() {
log.Fatal("failed to migrate database", zap.Error(err)) log.Fatal("failed to migrate database", zap.Error(err))
} }
// 初始化种子数据
seedData(db)
// 创建Gin实例 // 创建Gin实例
r := gin.New() r := gin.New()
+58
View File
@@ -0,0 +1,58 @@
package main
import (
"ezops/internal/models"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// seedData 初始化种子数据(admin用户 + LDAP配置)
func seedData(db *gorm.DB) {
// 1. 创建默认 admin 用户(仅首次)
var userCount int64
db.Model(&models.User{}).Count(&userCount)
if userCount == 0 {
hashedPassword, _ := bcrypt.GenerateFromPassword([]byte("123456"), bcrypt.DefaultCost)
admin := models.User{
Username: "admin",
Password: string(hashedPassword),
Email: "admin@ezops.local",
RealName: "管理员",
Status: 1,
IsAdmin: true,
AuthSource: "local",
}
if err := db.Create(&admin).Error; err != nil {
zap.L().Warn("failed to seed admin user", zap.Error(err))
} else {
zap.L().Info("seeded default admin user (admin/123456)")
}
}
// 2. 创建默认 LDAP 配置
var ldapCount int64
db.Model(&models.LDAPConfig{}).Count(&ldapCount)
if ldapCount == 0 {
port := 389
useLDAPS := false
searchFilter := "(objectClass=inetOrgPerson)"
userMapping := `{"username":"uid","email":"mail","display_name":"cn"}`
ldapCfg := models.LDAPConfig{
LDAPHost: "8.217.205.92",
LDAPPort: &port,
UseLDAPS: &useLDAPS,
AdminDN: "cn=admin,dc=opsx,dc=vip",
AdminPassword: "LQl2GX7EEg2N",
BaseDN: "dc=opsx,dc=vip",
SearchFilter: &searchFilter,
UserMapping: &userMapping,
}
if err := db.Create(&ldapCfg).Error; err != nil {
zap.L().Warn("failed to seed LDAP config", zap.Error(err))
} else {
zap.L().Info("seeded default LDAP config (8.217.205.92:389)")
}
}
}
+8 -4
View File
@@ -4,10 +4,11 @@ go 1.21
require ( require (
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/go-ldap/ldap/v3 v3.4.10
github.com/golang-jwt/jwt/v5 v5.2.0 github.com/golang-jwt/jwt/v5 v5.2.0
github.com/spf13/viper v1.18.2 github.com/spf13/viper v1.18.2
go.uber.org/zap v1.26.0 go.uber.org/zap v1.26.0
golang.org/x/crypto v0.18.0 golang.org/x/crypto v0.31.0
gorm.io/driver/mysql v1.5.2 gorm.io/driver/mysql v1.5.2
gorm.io/driver/postgres v1.5.4 gorm.io/driver/postgres v1.5.4
gorm.io/driver/sqlite v1.6.0 gorm.io/driver/sqlite v1.6.0
@@ -15,16 +16,19 @@ require (
) )
require ( require (
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect
github.com/bytedance/sonic v1.9.1 // indirect github.com/bytedance/sonic v1.9.1 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.7 // indirect
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.16.0 // indirect github.com/go-playground/validator/v10 v10.16.0 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect
@@ -53,9 +57,9 @@ require (
go.uber.org/multierr v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/net v0.19.0 // indirect golang.org/x/net v0.33.0 // indirect
golang.org/x/sys v0.16.0 // indirect golang.org/x/sys v0.28.0 // indirect
golang.org/x/text v0.20.0 // indirect golang.org/x/text v0.21.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect google.golang.org/protobuf v1.31.0 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
+102 -10
View File
@@ -1,3 +1,7 @@
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 h1:mFRzDkZVAjdal+s7s0MwaRv9igoPqLRdzOLzw/8Xvq8=
github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358/go.mod h1:chxPXzSsl7ZWRAuOIE23GDNzjWuZquvFlgA8xmpunjU=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa h1:LHTHcTQiSGT7VVbI0o4wBRNQIgn917usHWOd6VAffYI=
github.com/alexbrainman/sspi v0.0.0-20231016080023-1a75b4708caa/go.mod h1:cEWa1LVoE5KvSD9ONXsZrj0z6KqySlCCNKHlLzbqAt4=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM= github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U= github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
@@ -18,6 +22,10 @@ github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/go-asn1-ber/asn1-ber v1.5.7 h1:DTX+lbVTWaTw1hQ+PbZPlnDZPEIs0SS/GCZAl535dDk=
github.com/go-asn1-ber/asn1-ber v1.5.7/go.mod h1:hEBeB/ic+5LoWskz+yKT7vGhhPYkProFKoKdwZRWMe0=
github.com/go-ldap/ldap/v3 v3.4.10 h1:ot/iwPOhfpNVgB1o+AVXljizWZ9JTp7YF5oeyONmcJU=
github.com/go-ldap/ldap/v3 v3.4.10/go.mod h1:JXh4Uxgi40P6E9rdsYqpUtbW46D9UTjJ9QSwGRznplY=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 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/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 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -34,9 +42,16 @@ github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8=
github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
@@ -45,6 +60,18 @@ github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/
github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY=
github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA=
github.com/jcmturner/aescts/v2 v2.0.0 h1:9YKLH6ey7H4eDBXW8khjYslgyqG2xZikXP0EQFKrle8=
github.com/jcmturner/aescts/v2 v2.0.0/go.mod h1:AiaICIRyfYg35RUkr8yESTqvSy7csK90qZ5xfvvsoNs=
github.com/jcmturner/dnsutils/v2 v2.0.0 h1:lltnkeZGL0wILNvrNiVCR6Ro5PGU/SeBvVO/8c/iPbo=
github.com/jcmturner/dnsutils/v2 v2.0.0/go.mod h1:b0TnjGOvI/n42bZa+hmXL+kFJZsFT7G4t3HTlQ184QM=
github.com/jcmturner/gofork v1.7.6 h1:QH0l3hzAU1tfT3rZCnW5zXl+orbkNMMRGJfdJjHVETg=
github.com/jcmturner/gofork v1.7.6/go.mod h1:1622LH6i/EZqLloHfE7IeZ0uEJwMSUyQ/nDd82IeqRo=
github.com/jcmturner/goidentity/v6 v6.0.1 h1:VKnZd2oEIMorCTsFBnJWbExfNN7yZr3EhJAxwOkZg6o=
github.com/jcmturner/goidentity/v6 v6.0.1/go.mod h1:X1YW3bgtvwAXju7V3LCIMpY0Gbxyjn/mY9zx4tFonSg=
github.com/jcmturner/gokrb5/v8 v8.4.4 h1:x1Sv4HaTpepFkXbt2IkL29DXRf8sOfZXo8eRKh687T8=
github.com/jcmturner/gokrb5/v8 v8.4.4/go.mod h1:1btQEpgT6k+unzCwX1KdWMEwPPkkgBtP+F6aCACiMrs=
github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZY=
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 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/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 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
@@ -98,6 +125,7 @@ github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 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/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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -111,6 +139,7 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
@@ -120,18 +149,80 @@ go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g= golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200114155413-6afb5195e5aa/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
@@ -141,6 +232,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+8 -2
View File
@@ -3,6 +3,7 @@ package handlers
import ( import (
"math" "math"
"net/http" "net/http"
"strconv"
"time" "time"
"ezops/internal/models" "ezops/internal/models"
@@ -194,8 +195,8 @@ func (h *DashboardHandler) GetStats(c *gin.Context) {
func (h *DashboardHandler) GetRecentAlerts(c *gin.Context) { func (h *DashboardHandler) GetRecentAlerts(c *gin.Context) {
limit := 10 limit := 10
if l := c.Query("limit"); l != "" { if l := c.Query("limit"); l != "" {
if parsed, err := time.Parse("2006-01-02", l); err == nil { if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
_ = parsed limit = parsed
} }
} }
@@ -223,6 +224,11 @@ func (h *DashboardHandler) GetRecentAlerts(c *gin.Context) {
// @Router /dashboard/recent-releases [get] // @Router /dashboard/recent-releases [get]
func (h *DashboardHandler) GetRecentReleases(c *gin.Context) { func (h *DashboardHandler) GetRecentReleases(c *gin.Context) {
limit := 5 limit := 5
if l := c.Query("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 {
limit = parsed
}
}
var releases []models.AppRelease var releases []models.AppRelease
h.db.Order("created_at DESC"). h.db.Order("created_at DESC").
+457
View File
@@ -0,0 +1,457 @@
package handlers
import (
"encoding/json"
"fmt"
"net"
"net/http"
"strings"
"time"
"ezops/internal/models"
"github.com/gin-gonic/gin"
"github.com/go-ldap/ldap/v3"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
// LDAPHandler LDAP处理器
type LDAPHandler struct {
db *gorm.DB
}
// NewLDAPHandler 创建LDAP处理器
func NewLDAPHandler(db *gorm.DB) *LDAPHandler {
return &LDAPHandler{db: db}
}
// LDAPAuthInfo LDAP认证返回的用户信息
type LDAPAuthInfo struct {
Username string
DisplayName string
Email string
}
// dialLDAP 连接LDAP服务器
func dialLDAP(cfg *models.LDAPConfig) (*ldap.Conn, error) {
if cfg.LDAPHost == "" {
return nil, fmt.Errorf("LDAP not configured")
}
protocol := "ldap"
if cfg.UseLDAPS != nil && *cfg.UseLDAPS {
protocol = "ldaps"
}
port := 389
if cfg.LDAPPort != nil && *cfg.LDAPPort > 0 {
port = *cfg.LDAPPort
}
uri := fmt.Sprintf("%s://%s:%d", protocol, cfg.LDAPHost, port)
dialer := &net.Dialer{Timeout: 5 * time.Second}
return ldap.DialURL(uri, ldap.DialWithDialer(dialer))
}
// tryLDAPAuth 尝试LDAP认证,返回用户信息
func tryLDAPAuth(db *gorm.DB, username, password string) (*LDAPAuthInfo, error) {
var cfg models.LDAPConfig
if err := db.Order("id").First(&cfg).Error; err != nil {
return nil, fmt.Errorf("LDAP未配置")
}
if cfg.LDAPHost == "" {
return nil, fmt.Errorf("LDAP not configured")
}
conn, err := dialLDAP(&cfg)
if err != nil {
return nil, fmt.Errorf("LDAP连接失败: %w", err)
}
defer conn.Close()
// Admin bind
if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil {
return nil, fmt.Errorf("LDAP管理员绑定失败: %w", err)
}
// Build search filter
userFilter := ""
baseFilter := "(objectClass=inetOrgPerson)"
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
sf := *cfg.SearchFilter
if strings.Contains(sf, "%s") {
userFilter = strings.ReplaceAll(sf, "%s", ldap.EscapeFilter(username))
} else {
baseFilter = sf
userFilter = fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(username))
}
} else {
userFilter = fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(username))
}
// Search user
searchReq := ldap.NewSearchRequest(
cfg.BaseDN,
ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
1, 10, false,
userFilter,
[]string{"uid", "cn", "mail", "telephoneNumber"},
nil,
)
sr, err := conn.Search(searchReq)
if err != nil || len(sr.Entries) == 0 {
return nil, fmt.Errorf("LDAP用户不存在")
}
entry := sr.Entries[0]
userDN := entry.DN
// Verify password by binding as user
if err := conn.Bind(userDN, password); err != nil {
return nil, fmt.Errorf("LDAP密码验证失败: %w", err)
}
// Extract user info using mapping
userMapping := map[string]string{
"username": "uid",
"email": "mail",
"display_name": "cn",
}
if cfg.UserMapping != nil && *cfg.UserMapping != "" {
json.Unmarshal([]byte(*cfg.UserMapping), &userMapping)
}
getAttr := func(key string) string {
if v := entry.GetAttributeValue(key); v != "" {
return v
}
return ""
}
info := &LDAPAuthInfo{
Username: getAttr(userMapping["username"]),
DisplayName: getAttr(userMapping["display_name"]),
Email: getAttr(userMapping["email"]),
}
if info.Username == "" {
info.Username = username
}
return info, nil
}
// GetLDAPConfig 获取LDAP配置
// GET /api/v1/admin/ldap-config
func (h *LDAPHandler) GetLDAPConfig(c *gin.Context) {
var configs []models.LDAPConfig
h.db.Order("id").Find(&configs)
if len(configs) == 0 {
c.JSON(http.StatusOK, gin.H{})
return
}
cfg := configs[0]
// mask password
cfg.AdminPassword = "******"
c.JSON(http.StatusOK, cfg)
}
// UpdateLDAPConfig 更新LDAP配置
// PUT /api/v1/admin/ldap-config
func (h *LDAPHandler) UpdateLDAPConfig(c *gin.Context) {
var input map[string]interface{}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
return
}
var configs []models.LDAPConfig
h.db.Order("id").Find(&configs)
getStr := func(key string) string {
if v, ok := input[key].(string); ok {
return v
}
return ""
}
getInt := func(key string) int {
if v, ok := input[key].(float64); ok {
return int(v)
}
return 0
}
getBool := func(key string) *bool {
if v, ok := input[key].(bool); ok {
return &v
}
return nil
}
userMappingStr := ""
if um, ok := input["user_mapping"]; ok {
if bytes, err := json.Marshal(um); err == nil {
userMappingStr = string(bytes)
}
}
if len(configs) == 0 {
// Create
cfg := models.LDAPConfig{
LDAPHost: getStr("ldap_host"),
AdminDN: getStr("admin_dn"),
BaseDN: getStr("base_dn"),
AdminPassword: getStr("admin_password"),
}
if sf := getStr("search_filter"); sf != "" {
cfg.SearchFilter = &sf
}
if userMappingStr != "" {
cfg.UserMapping = &userMappingStr
}
if port := getInt("ldap_port"); port > 0 {
cfg.LDAPPort = &port
}
if b := getBool("use_ldaps"); b != nil {
cfg.UseLDAPS = b
}
h.db.Create(&cfg)
cfg.AdminPassword = "******"
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "LDAP配置已创建", Data: cfg})
} else {
// Update
cfg := configs[0]
updates := map[string]interface{}{}
if v := getStr("ldap_host"); v != "" {
updates["ldap_host"] = v
}
if v := getStr("base_dn"); v != "" {
updates["base_dn"] = v
}
if v := getStr("admin_dn"); v != "" {
updates["admin_dn"] = v
}
if v := getStr("search_filter"); v != "" {
updates["search_filter"] = v
}
if userMappingStr != "" {
updates["user_mapping"] = userMappingStr
}
if v := getStr("admin_password"); v != "" && v != "******" {
updates["admin_password"] = v
}
if port := getInt("ldap_port"); port > 0 {
updates["ldap_port"] = port
}
if b := getBool("use_ldaps"); b != nil {
updates["use_ldaps"] = *b
}
if len(updates) > 0 {
h.db.Model(&cfg).Updates(updates)
}
h.db.First(&cfg, cfg.ID)
cfg.AdminPassword = "******"
c.JSON(http.StatusOK, models.Response{Code: 200, Message: "LDAP配置已更新", Data: cfg})
}
}
// TestLDAPConnection 测试LDAP连接
// POST /api/v1/admin/ldap-config/test
func (h *LDAPHandler) TestLDAPConnection(c *gin.Context) {
var req struct {
TestUser string `json:"test_user"`
TestPassword string `json:"test_password"`
}
c.ShouldBindJSON(&req)
var cfg models.LDAPConfig
if err := h.db.Order("id").First(&cfg).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "LDAP配置不存在,请先保存配置"})
return
}
if cfg.LDAPHost == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "LDAP未配置"})
return
}
conn, err := dialLDAP(&cfg)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("连接失败: %s", err)})
return
}
defer conn.Close()
if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("管理员绑定失败: %s", err)})
return
}
// If test_user provided, search and verify
if req.TestUser != "" && req.TestPassword != "" {
userFilter := ""
baseFilter := "(objectClass=inetOrgPerson)"
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
sf := *cfg.SearchFilter
if strings.Contains(sf, "%s") {
userFilter = strings.ReplaceAll(sf, "%s", ldap.EscapeFilter(req.TestUser))
} else {
baseFilter = sf
userFilter = fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(req.TestUser))
}
} else {
userFilter = fmt.Sprintf("(&%s(uid=%s))", baseFilter, ldap.EscapeFilter(req.TestUser))
}
sr, err := conn.Search(ldap.NewSearchRequest(
cfg.BaseDN, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
1, 10, false, userFilter,
[]string{"uid", "cn", "mail"},
nil,
))
if err != nil || len(sr.Entries) == 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("用户 %s 不存在", req.TestUser)})
return
}
userDN := sr.Entries[0].DN
if err := conn.Bind(userDN, req.TestPassword); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": fmt.Sprintf("密码验证失败: %s", err)})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": fmt.Sprintf("认证成功!用户DN: %s", userDN)})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "LDAP连接成功,管理员认证通过"})
}
// ListLDAPUsers 列出LDAP用户
// POST /api/v1/admin/ldap-config/users
func (h *LDAPHandler) ListLDAPUsers(c *gin.Context) {
var req struct {
OuDn string `json:"ou_dn"`
}
c.ShouldBindJSON(&req)
var cfg models.LDAPConfig
if err := h.db.Order("id").First(&cfg).Error; err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
conn, err := dialLDAP(&cfg)
if err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
defer conn.Close()
if err := conn.Bind(cfg.AdminDN, cfg.AdminPassword); err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
searchBase := cfg.BaseDN
if req.OuDn != "" {
searchBase = req.OuDn
}
baseFilter := "(objectClass=inetOrgPerson)"
if cfg.SearchFilter != nil && *cfg.SearchFilter != "" {
sf := *cfg.SearchFilter
if strings.Contains(sf, "%s") {
baseFilter = strings.ReplaceAll(sf, "%s", "*")
} else {
baseFilter = sf
}
}
sr, err := conn.Search(ldap.NewSearchRequest(
searchBase, ldap.ScopeWholeSubtree, ldap.NeverDerefAliases,
200, 0, false, baseFilter,
[]string{"uid", "cn", "mail", "dn"},
nil,
))
if err != nil {
c.JSON(http.StatusOK, []interface{}{})
return
}
type LdapUser struct {
DN string `json:"dn"`
UID string `json:"uid"`
CN string `json:"cn"`
Mail string `json:"mail"`
}
var users []LdapUser
for _, e := range sr.Entries {
users = append(users, LdapUser{
DN: e.DN,
UID: e.GetAttributeValue("uid"),
CN: e.GetAttributeValue("cn"),
Mail: e.GetAttributeValue("mail"),
})
}
c.JSON(http.StatusOK, users)
}
// BatchImportLDAP 批量导入LDAP用户
// POST /api/v1/admin/ldap-config/import
func (h *LDAPHandler) BatchImportLDAP(c *gin.Context) {
var req struct {
Users []struct {
UID string `json:"uid"`
CN string `json:"cn"`
Mail string `json:"mail"`
} `json:"users"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, models.Response{Code: 400, Message: err.Error()})
return
}
imported := 0
skipped := 0
for _, lu := range req.Users {
if lu.UID == "" {
skipped++
continue
}
// Check if already exists
var existing models.User
if err := h.db.Where("username = ?", lu.UID).First(&existing).Error; err == nil {
// Already exists, update info
updates := map[string]interface{}{
"auth_source": "ldap",
"is_active": true,
}
if lu.CN != "" {
updates["real_name"] = lu.CN
}
if lu.Mail != "" {
updates["email"] = lu.Mail
}
h.db.Model(&existing).Updates(updates)
imported++
continue
}
// Create new LDAP user
user := models.User{
Username: lu.UID,
Password: "-", // LDAP users don't have local password
RealName: lu.CN,
Email: lu.Mail,
Status: 1,
AuthSource: "ldap",
}
if err := h.db.Create(&user).Error; err != nil {
skipped++
continue
}
imported++
}
// bcrypt import to avoid unused import error
_ = bcrypt.MinCost
c.JSON(http.StatusOK, models.Response{
Code: 200,
Message: fmt.Sprintf("导入完成: %d 个用户成功, %d 个跳过", imported, skipped),
Data: gin.H{"imported": imported, "skipped": skipped},
})
}
+69 -8
View File
@@ -46,14 +46,10 @@ func (h *UserHandler) Login(c *gin.Context) {
} }
var user models.User var user models.User
if err := h.db.Where("username = ?", req.Username).First(&user).Error; err != nil { err := h.db.Where("username = ?", req.Username).First(&user).Error
c.JSON(http.StatusUnauthorized, models.Response{
Code: 401,
Message: "用户名或密码错误",
})
return
}
if err == nil {
// 本地用户存在
if user.Status != 1 { if user.Status != 1 {
c.JSON(http.StatusUnauthorized, models.Response{ c.JSON(http.StatusUnauthorized, models.Response{
Code: 401, Code: 401,
@@ -62,13 +58,78 @@ func (h *UserHandler) Login(c *gin.Context) {
return return
} }
if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { // 先尝试本地密码验证
passOk := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)) == nil
if !passOk {
// 本地密码失败 — LDAP 用户尝试 LDAP 认证
if user.AuthSource == "ldap" || user.Password == "-" {
ldapInfo, ldapErr := tryLDAPAuth(h.db, req.Username, req.Password)
if ldapErr != nil {
c.JSON(http.StatusUnauthorized, models.Response{ c.JSON(http.StatusUnauthorized, models.Response{
Code: 401, Code: 401,
Message: "用户名或密码错误", Message: "用户名或密码错误",
}) })
return return
} }
// LDAP OK — 更新本地信息
if ldapInfo != nil {
updates := map[string]interface{}{}
if ldapInfo.DisplayName != "" && ldapInfo.DisplayName != user.RealName {
updates["real_name"] = ldapInfo.DisplayName
}
if ldapInfo.Email != "" && ldapInfo.Email != user.Email {
updates["email"] = ldapInfo.Email
}
if len(updates) > 0 {
h.db.Model(&user).Updates(updates)
}
}
} else {
c.JSON(http.StatusUnauthorized, models.Response{
Code: 401,
Message: "用户名或密码错误",
})
return
}
}
} else if err == gorm.ErrRecordNotFound {
// 本地用户不存在 — 尝试 LDAP 认证
ldapInfo, ldapErr := tryLDAPAuth(h.db, req.Username, req.Password)
if ldapErr != nil {
c.JSON(http.StatusUnauthorized, models.Response{
Code: 401,
Message: "用户名或密码错误",
})
return
}
// LDAP 认证成功,自动创建本地用户
newUser := models.User{
Username: ldapInfo.Username,
Password: "-",
RealName: ldapInfo.DisplayName,
Email: ldapInfo.Email,
Status: 1,
AuthSource: "ldap",
}
if newUser.RealName == "" {
newUser.RealName = newUser.Username
}
if err := h.db.Create(&newUser).Error; err != nil {
c.JSON(http.StatusInternalServerError, models.Response{
Code: 500,
Message: "创建LDAP用户失败: " + err.Error(),
})
return
}
user = newUser
} else {
c.JSON(http.StatusInternalServerError, models.Response{
Code: 500,
Message: "数据库错误",
})
return
}
// 生成JWT令牌 // 生成JWT令牌
token, err := middleware.GenerateToken(&user, &h.cfg.JWT) token, err := middleware.GenerateToken(&user, &h.cfg.JWT)
+12
View File
@@ -48,6 +48,18 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) {
admin.DELETE("/users/:id", authHandler.DeleteUser) admin.DELETE("/users/:id", authHandler.DeleteUser)
} }
// LDAP 配置路由
ldapHandler := handlers.NewLDAPHandler(db)
ldapGroup := authorized.Group("/admin/ldap-config")
ldapGroup.Use(middleware.AdminAuth())
{
ldapGroup.GET("", ldapHandler.GetLDAPConfig)
ldapGroup.PUT("", ldapHandler.UpdateLDAPConfig)
ldapGroup.POST("/test", ldapHandler.TestLDAPConnection)
ldapGroup.POST("/users", ldapHandler.ListLDAPUsers)
ldapGroup.POST("/import", ldapHandler.BatchImportLDAP)
}
// 仪表盘路由 // 仪表盘路由
dashboardHandler := handlers.NewDashboardHandler(db) dashboardHandler := handlers.NewDashboardHandler(db)
dashboard := authorized.Group("/dashboard") dashboard := authorized.Group("/dashboard")
+19 -1
View File
@@ -11,12 +11,13 @@ type User struct {
ID uint `gorm:"primarykey" json:"id"` ID uint `gorm:"primarykey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"` Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
Password string `gorm:"size:255;not null" json:"-"` Password string `gorm:"size:255;not null" json:"-"`
Email string `gorm:"size:128;uniqueIndex" json:"email"` Email string `gorm:"size:128" json:"email"`
Phone string `gorm:"size:32" json:"phone"` Phone string `gorm:"size:32" json:"phone"`
RealName string `gorm:"size:64" json:"real_name"` RealName string `gorm:"size:64" json:"real_name"`
Avatar string `gorm:"size:255" json:"avatar"` Avatar string `gorm:"size:255" json:"avatar"`
Status int `gorm:"default:1" json:"status"` // 0:禁用 1:启用 Status int `gorm:"default:1" json:"status"` // 0:禁用 1:启用
IsAdmin bool `gorm:"default:false" json:"is_admin"` IsAdmin bool `gorm:"default:false" json:"is_admin"`
AuthSource string `gorm:"size:20;default:'local'" json:"auth_source"` // local / ldap
LastLogin *time.Time `json:"last_login"` LastLogin *time.Time `json:"last_login"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"` UpdatedAt time.Time `json:"updated_at"`
@@ -109,3 +110,20 @@ type ChangePasswordRequest struct {
OldPassword string `json:"old_password" binding:"required"` OldPassword string `json:"old_password" binding:"required"`
NewPassword string `json:"new_password" binding:"required,min=6,max=32"` NewPassword string `json:"new_password" binding:"required,min=6,max=32"`
} }
// LDAPConfig LDAP配置
type LDAPConfig struct {
ID int `gorm:"primarykey" json:"id"`
LDAPHost string `gorm:"size:200;not null" json:"ldap_host"`
LDAPPort *int `json:"ldap_port"`
UseLDAPS *bool `json:"use_ldaps"`
AdminDN string `gorm:"size:500;not null" json:"admin_dn"`
AdminPassword string `gorm:"size:500;not null" json:"admin_password"`
SearchFilter *string `gorm:"size:500" json:"search_filter"`
BaseDN string `gorm:"size:500;not null" json:"base_dn"`
UserMapping *string `gorm:"type:text" json:"user_mapping"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (LDAPConfig) TableName() string { return "ldap_configs" }
+1
View File
@@ -87,5 +87,6 @@ func AutoMigrate(db *gorm.DB) error {
&models.NavigationGroup{}, &models.NavigationGroup{},
&models.NavigationLink{}, &models.NavigationLink{},
&models.AuditLog{}, &models.AuditLog{},
&models.LDAPConfig{},
) )
} }
+67 -3
View File
@@ -52,6 +52,9 @@ func generateMockData(db *gorm.DB) error {
if err := generatePipelines(db); err != nil { if err := generatePipelines(db); err != nil {
return err return err
} }
if err := generateAppReleases(db); err != nil {
return err
}
if err := generateCronJobs(db); err != nil { if err := generateCronJobs(db); err != nil {
return err return err
} }
@@ -210,10 +213,11 @@ func generateAlertRules(db *gorm.DB) error {
} }
} }
for i := 0; i < 20; i++ { for i := 0; i < 20; i++ {
rule := rules[rand.Intn(len(rules))]
event := models.AlertEvent{ event := models.AlertEvent{
RuleID: rules[rand.Intn(len(rules))].ID, RuleID: rule.ID,
RuleName: rules[rand.Intn(len(rules))].Name, RuleName: rule.Name,
Severity: []string{"info", "warning", "critical"}[rand.Intn(3)], Severity: rule.Severity,
Status: []string{"firing", "resolved"}[rand.Intn(2)], Status: []string{"firing", "resolved"}[rand.Intn(2)],
Target: fmt.Sprintf("server-%03d", rand.Intn(50)+1), Target: fmt.Sprintf("server-%03d", rand.Intn(50)+1),
Message: fmt.Sprintf("告警事件 %d", i+1), Message: fmt.Sprintf("告警事件 %d", i+1),
@@ -262,6 +266,66 @@ func generatePipelines(db *gorm.DB) error {
return nil return nil
} }
func generateAppReleases(db *gorm.DB) error {
apps := []string{"EZOps 前端", "EZOps 后端", "用户中心", "支付网关", "数据分析平台"}
environments := []string{"dev", "test", "staging", "prod"}
statuses := []string{"pending", "building", "deploying", "success", "success", "success", "failed", "rolled_back"}
authors := []string{"admin", "ops01", "dev01"}
releases := make([]models.AppRelease, 0, 10)
for i := 0; i < 10; i++ {
app := apps[i%len(apps)]
env := environments[rand.Intn(len(environments))]
status := statuses[rand.Intn(len(statuses))]
minor := rand.Intn(20)
patch := rand.Intn(10)
release := models.AppRelease{
AppName: app,
Version: fmt.Sprintf("v1.%d.%d", minor, patch),
Environment: env,
Status: status,
CommitID: randomString(8),
CommitMsg: fmt.Sprintf("feat: 更新 %s 功能", app),
Author: authors[rand.Intn(len(authors))],
Duration: rand.Intn(600) + 60,
Description: fmt.Sprintf("%s %s 环境发布", app, env),
}
// Set timestamps and approval fields based on status
now := time.Now()
hoursAgo := time.Duration(rand.Intn(168)+1) * time.Hour
if status == "success" || status == "failed" {
approver := authors[rand.Intn(len(authors))]
release.Approver = approver
approvedAt := now.Add(-hoursAgo + 30*time.Minute)
release.ApprovedAt = &approvedAt
deployedAt := now.Add(-hoursAgo + 2*time.Hour)
release.DeployedAt = &deployedAt
} else if status == "rolled_back" {
approver := authors[rand.Intn(len(authors))]
release.Approver = approver
approvedAt := now.Add(-hoursAgo + 30*time.Minute)
release.ApprovedAt = &approvedAt
deployedAt := now.Add(-hoursAgo + 2*time.Hour)
release.DeployedAt = &deployedAt
rollbackAt := now.Add(-hoursAgo + 4*time.Hour)
release.RollbackAt = &rollbackAt
}
releases = append(releases, release)
}
for i := range releases {
if err := db.Create(&releases[i]).Error; err != nil {
return err
}
}
fmt.Println("Generated app releases:", len(releases))
return nil
}
func generateCronJobs(db *gorm.DB) error { func generateCronJobs(db *gorm.DB) error {
cronJobs := []models.CronJob{ cronJobs := []models.CronJob{
{Name: "数据库备份", CronExpr: "0 2 * * *", Type: "script", Content: "/scripts/backup-db.sh", Enabled: true, Description: "每日凌晨 2 点备份数据库"}, {Name: "数据库备份", CronExpr: "0 2 * * *", Type: "script", Content: "/scripts/backup-db.sh", Enabled: true, Description: "每日凌晨 2 点备份数据库"},
+63
View File
@@ -0,0 +1,63 @@
# EZOps AIOps 开发日志
## 2026-06-13 项目初始化
### 完成项
| 项目 | 状态 | 说明 |
|------|------|------|
| 前端 (React+TS+Vite) | ✅ | React 18 + TypeScript + Tailwind CSS + shadcn/ui |
| 后端 (Go+Gin+GORM) | ✅ | Go + Gin + GORM + JWT + SQLite |
| 数据库 (SQLite) | ✅ | sqlite3.db,CGO 编译 |
| run.sh 启动脚本 | ✅ | 一键 start/stop/restart/status/logs |
| Vite Proxy | ✅ | /api → localhost:8080 |
| docs 文档 | ✅ | 项目结构/API/部署/开发指南 |
### 编译修复记录
| 文件 | 问题 | 修复 |
|------|------|------|
| `auth.go` | jwt/v5 移除了 `TimeFunc()` | 改用 `time.Now()` |
| `mock.go` | 匹配实际 model 定义 | 完全重写 |
| `audit.go` | 编译错误 | 修复字段引用 |
| `database.go` | 不支持 SQLite | 添加 `gorm.io/driver/sqlite` |
| `config.go` | DSN 中 Port 类型转换错误 | 在 database.go 用 `fmt.Sprintf("%d", port)` |
### 技术栈
**后端**
- Go 1.21+ / Gin / GORM
- SQLite (开发环境默认) / MySQL / PostgreSQL (生产可选)
- JWT 认证 / Zap 日志
**前端**
- React 18 + TypeScript + Vite
- Tailwind CSS + shadcn/ui 组件库
- Zustand 状态管理
### 端口分配
| 服务 | 端口 |
|------|------|
| 后端 API | 8080 |
| 前端 Dev Server | 5173 |
### 快速启动
```bash
cd ~/devops/aiops
./run.sh start # 启动前后端
./run.sh status # 查看状态
./run.sh stop # 停止
./run.sh restart # 重启
./run.sh logs # 实时日志
```
访问地址:
- 前端: http://localhost:5173
- 后端: http://localhost:8080
- 健康检查: http://localhost:8080/health
### 数据库
开发环境使用 SQLite,数据库文件位于 `backend/sqlite3.db`,首次启动时 GORM AutoMigrate 自动建表。
+7 -1
View File
@@ -1,6 +1,9 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ThemeProvider } from '@/hooks/useTheme';
import { MainLayout } from '@/components/layout/MainLayout'; import { MainLayout } from '@/components/layout/MainLayout';
import { Login } from '@/components/auth/Login';
import { ProtectedRoute } from '@/components/auth/ProtectedRoute';
import { Dashboard } from '@/components/dashboard/Dashboard'; import { Dashboard } from '@/components/dashboard/Dashboard';
import { ServerManager } from '@/components/server/ServerManager'; import { ServerManager } from '@/components/server/ServerManager';
import { WebShell } from '@/components/server/WebShell'; import { WebShell } from '@/components/server/WebShell';
@@ -45,10 +48,12 @@ const queryClient = new QueryClient({
function App() { function App() {
return ( return (
<ThemeProvider>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route path="/" element={<MainLayout />}> <Route path="/login" element={<Login />} />
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
<Route index element={<Dashboard />} /> <Route index element={<Dashboard />} />
<Route path="servers" element={<ServerManager />} /> <Route path="servers" element={<ServerManager />} />
<Route path="webshell" element={<WebShell />} /> <Route path="webshell" element={<WebShell />} />
@@ -98,6 +103,7 @@ function App() {
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</QueryClientProvider> </QueryClientProvider>
</ThemeProvider>
); );
} }
+131 -44
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -34,45 +34,106 @@ import {
Eye, Eye,
Edit, Edit,
GitBranch, GitBranch,
Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { approvalFlows, approvalInstances } from '@/mocks/data'; import { approvalApi } from '@/services/api';
import type { ApprovalFlow as ApprovalFlowType, ApprovalInstance } from '@/types';
function parseSteps(steps: any) {
if (Array.isArray(steps)) return steps;
if (typeof steps === 'string') {
try {
const parsed = JSON.parse(steps);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
}
export function ApprovalFlowManager() { export function ApprovalFlowManager() {
const [activeTab, setActiveTab] = useState('instances'); const [activeTab, setActiveTab] = useState('instances');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusFilter, setStatusFilter] = useState<string>('all');
const [selectedInstance, setSelectedInstance] = useState<ApprovalInstance | null>(null); const [processes, setProcesses] = useState<any[]>([]);
const [templates, setTemplates] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedInstance, setSelectedInstance] = useState<any | null>(null);
const filteredInstances = approvalInstances.filter((instance) => { const loadData = async () => {
try {
setLoading(true);
const [processesData, templatesData] = await Promise.all([
approvalApi.getProcesses(),
approvalApi.getTemplates(),
]);
setProcesses((processesData as any)?.list || (processesData as any) || []);
setTemplates(templatesData || []);
} catch (error) {
console.error('Failed to load approval data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const filteredInstances = processes.filter((instance) => {
const matchesSearch = const matchesSearch =
instance.flowName.toLowerCase().includes(searchQuery.toLowerCase()) || (instance.title || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
instance.applicantName.toLowerCase().includes(searchQuery.toLowerCase()); (instance.applicant_name || '').toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = statusFilter === 'all' || instance.status === statusFilter; const matchesStatus = statusFilter === 'all' || instance.status === statusFilter;
return matchesSearch && matchesStatus; return matchesSearch && matchesStatus;
}); });
const getStatusBadge = (status: ApprovalInstance['status']) => { const getStatusBadge = (status: any) => {
const configs = { const configs: Record<string, any> = {
pending: { label: '审批中', className: 'border-amber-500/30 text-amber-400 bg-amber-500/10', icon: Clock }, pending: { label: '审批中', className: 'border-amber-500/30 text-amber-400 bg-amber-500/10', icon: Clock },
approved: { label: '已通过', className: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10', icon: CheckCircle }, approved: { label: '已通过', className: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10', icon: CheckCircle },
rejected: { label: '已拒绝', className: 'border-red-500/30 text-red-400 bg-red-500/10', icon: XCircle }, rejected: { label: '已拒绝', className: 'border-red-500/30 text-red-400 bg-red-500/10', icon: XCircle },
}; };
const config = configs[status]; const config = configs[status] || configs.pending;
const Icon = config.icon;
return ( return (
<Badge variant="outline" className={config.className}> <Badge variant="outline" className={config.className}>
<config.icon className="h-3 w-3 mr-1" /> <Icon className="h-3 w-3 mr-1" />
{config.label} {config.label}
</Badge> </Badge>
); );
}; };
const handleApprove = async (id: any) => {
try {
await approvalApi.approveProcess(id);
setSelectedInstance(null);
loadData();
} catch (error) {
console.error('Failed to approve process:', error);
}
};
const handleReject = async (id: any) => {
try {
await approvalApi.rejectProcess(id);
setSelectedInstance(null);
loadData();
} catch (error) {
console.error('Failed to reject process:', error);
}
};
// 模拟审批流程图 // 模拟审批流程图
const renderFlowChart = (flow: ApprovalFlowType) => { const renderFlowChart = (flow: any) => {
const nodes = parseSteps(flow.steps);
if (nodes.length === 0) {
return <div className="text-slate-500 text-center py-4">暂无流程节点</div>;
}
return ( return (
<div className="flex items-center gap-2 overflow-x-auto py-4"> <div className="flex items-center gap-2 overflow-x-auto py-4">
{flow.nodes.map((node, index) => ( {nodes.map((node: any, index: number) => (
<div key={node.id} className="flex items-center"> <div key={node.id || index} className="flex items-center">
<div <div
className={cn( className={cn(
'px-4 py-2 rounded-lg border min-w-[100px] text-center', 'px-4 py-2 rounded-lg border min-w-[100px] text-center',
@@ -84,10 +145,12 @@ export function ApprovalFlowManager() {
> >
<p className="text-sm font-medium">{node.name}</p> <p className="text-sm font-medium">{node.name}</p>
{node.approvers && ( {node.approvers && (
<p className="text-xs text-slate-500 mt-1">{node.approvers.join(', ')}</p> <p className="text-xs text-slate-500 mt-1">
{Array.isArray(node.approvers) ? node.approvers.join(', ') : node.approvers}
</p>
)} )}
</div> </div>
{index < flow.nodes.length - 1 && ( {index < nodes.length - 1 && (
<ArrowRight className="h-4 w-4 text-slate-600 mx-2" /> <ArrowRight className="h-4 w-4 text-slate-600 mx-2" />
)} )}
</div> </div>
@@ -124,7 +187,7 @@ export function ApprovalFlowManager() {
<div> <div>
<p className="text-sm text-slate-500">待审批</p> <p className="text-sm text-slate-500">待审批</p>
<p className="text-2xl font-bold text-amber-400"> <p className="text-2xl font-bold text-amber-400">
{approvalInstances.filter((i) => i.status === 'pending').length} {processes.filter((i) => i.status === 'pending').length}
</p> </p>
</div> </div>
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400"> <div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
@@ -139,7 +202,7 @@ export function ApprovalFlowManager() {
<div> <div>
<p className="text-sm text-slate-500">已通过</p> <p className="text-sm text-slate-500">已通过</p>
<p className="text-2xl font-bold text-emerald-400"> <p className="text-2xl font-bold text-emerald-400">
{approvalInstances.filter((i) => i.status === 'approved').length} {processes.filter((i) => i.status === 'approved').length}
</p> </p>
</div> </div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400"> <div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
@@ -154,7 +217,7 @@ export function ApprovalFlowManager() {
<div> <div>
<p className="text-sm text-slate-500">已拒绝</p> <p className="text-sm text-slate-500">已拒绝</p>
<p className="text-2xl font-bold text-red-400"> <p className="text-2xl font-bold text-red-400">
{approvalInstances.filter((i) => i.status === 'rejected').length} {processes.filter((i) => i.status === 'rejected').length}
</p> </p>
</div> </div>
<div className="p-3 rounded-lg bg-red-500/10 text-red-400"> <div className="p-3 rounded-lg bg-red-500/10 text-red-400">
@@ -168,7 +231,7 @@ export function ApprovalFlowManager() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-slate-500">流程模板</p> <p className="text-sm text-slate-500">流程模板</p>
<p className="text-2xl font-bold text-slate-100">{approvalFlows.length}</p> <p className="text-2xl font-bold text-slate-100">{templates.length}</p>
</div> </div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400"> <div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<GitBranch className="h-5 w-5" /> <GitBranch className="h-5 w-5" />
@@ -233,35 +296,42 @@ export function ApprovalFlowManager() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredInstances.map((instance) => ( {loading ? (
<TableRow className="border-slate-800">
<TableCell colSpan={7} className="text-center text-slate-500 py-8">
<Loader2 className="h-5 w-5 animate-spin inline mr-2" />
加载中...
</TableCell>
</TableRow>
) : filteredInstances.map((instance) => (
<TableRow key={instance.id} className="border-slate-800 hover:bg-slate-800/50"> <TableRow key={instance.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<GitBranch className="h-4 w-4 text-slate-500" /> <GitBranch className="h-4 w-4 text-slate-500" />
<span className="text-sm font-medium text-slate-200">{instance.flowName}</span> <span className="text-sm font-medium text-slate-200">{instance.title}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-1 text-sm text-slate-300"> <div className="flex items-center gap-1 text-sm text-slate-300">
<User className="h-3.5 w-3.5 text-slate-500" /> <User className="h-3.5 w-3.5 text-slate-500" />
{instance.applicantName} {instance.applicant_name || instance.applicant}
</div> </div>
</TableCell> </TableCell>
<TableCell>{getStatusBadge(instance.status)}</TableCell> <TableCell>{getStatusBadge(instance.status)}</TableCell>
<TableCell> <TableCell>
<Badge variant="outline" className="border-slate-700 text-slate-400"> <Badge variant="outline" className="border-slate-700 text-slate-400">
{instance.currentNode} {instance.current_step || '--'}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="text-sm text-slate-400"> <span className="text-sm text-slate-400">
{new Date(instance.createdAt).toLocaleString()} {instance.created_at ? new Date(instance.created_at).toLocaleString() : '--'}
</span> </span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="text-sm text-slate-400"> <span className="text-sm text-slate-400">
{instance.completedAt {instance.completed_at
? new Date(instance.completedAt).toLocaleString() ? new Date(instance.completed_at).toLocaleString()
: '--'} : '--'}
</span> </span>
</TableCell> </TableCell>
@@ -278,11 +348,21 @@ export function ApprovalFlowManager() {
</Button> </Button>
{instance.status === 'pending' && ( {instance.status === 'pending' && (
<> <>
<Button variant="ghost" size="sm" className="h-8 text-emerald-400 hover:text-emerald-400"> <Button
variant="ghost"
size="sm"
className="h-8 text-emerald-400 hover:text-emerald-400"
onClick={() => handleApprove(instance.id)}
>
<CheckCircle className="h-4 w-4 mr-1" /> <CheckCircle className="h-4 w-4 mr-1" />
通过 通过
</Button> </Button>
<Button variant="ghost" size="sm" className="h-8 text-red-400 hover:text-red-400"> <Button
variant="ghost"
size="sm"
className="h-8 text-red-400 hover:text-red-400"
onClick={() => handleReject(instance.id)}
>
<XCircle className="h-4 w-4 mr-1" /> <XCircle className="h-4 w-4 mr-1" />
拒绝 拒绝
</Button> </Button>
@@ -301,7 +381,7 @@ export function ApprovalFlowManager() {
<TabsContent value="flows" className="mt-4"> <TabsContent value="flows" className="mt-4">
<div className="grid grid-cols-1 gap-4"> <div className="grid grid-cols-1 gap-4">
{approvalFlows.map((flow) => ( {templates.map((flow) => (
<Card key={flow.id} className="bg-slate-900 border-slate-800"> <Card key={flow.id} className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
@@ -318,12 +398,12 @@ export function ApprovalFlowManager() {
<Badge <Badge
variant="outline" variant="outline"
className={ className={
flow.enabled flow.status === 1 || flow.status === 'active' || flow.status === true
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10' ? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-slate-500/30 text-slate-400 bg-slate-500/10' : 'border-slate-500/30 text-slate-400 bg-slate-500/10'
} }
> >
{flow.enabled ? '已启用' : '已停用'} {flow.status === 1 || flow.status === 'active' || flow.status === true ? '已启用' : '已停用'}
</Badge> </Badge>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100"> <Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
@@ -336,8 +416,8 @@ export function ApprovalFlowManager() {
{renderFlowChart(flow)} {renderFlowChart(flow)}
</div> </div>
<div className="flex items-center justify-between mt-4 text-sm text-slate-500"> <div className="flex items-center justify-between mt-4 text-sm text-slate-500">
<span>创建于 {new Date(flow.createdAt).toLocaleDateString()}</span> <span>创建于 {flow.created_at ? new Date(flow.created_at).toLocaleDateString() : '--'}</span>
<span>{flow.nodes.length} 个节点</span> <span>{parseSteps(flow.steps).length} 个节点</span>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -364,11 +444,11 @@ export function ApprovalFlowManager() {
<DialogHeader> <DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2"> <DialogTitle className="text-slate-100 flex items-center gap-2">
<GitBranch className="h-5 w-5 text-blue-400" /> <GitBranch className="h-5 w-5 text-blue-400" />
{selectedInstance?.flowName} {selectedInstance?.title}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-slate-500"> <DialogDescription className="text-slate-500">
申请人: {selectedInstance?.applicantName} · 创建于{' '} 申请人: {selectedInstance?.applicant_name || selectedInstance?.applicant} · 创建于{' '}
{selectedInstance && new Date(selectedInstance.createdAt).toLocaleString()} {selectedInstance && selectedInstance.created_at ? new Date(selectedInstance.created_at).toLocaleString() : '--'}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -388,10 +468,10 @@ export function ApprovalFlowManager() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-200">提交申请</span> <span className="text-sm font-medium text-slate-200">提交申请</span>
<span className="text-xs text-slate-500"> <span className="text-xs text-slate-500">
{selectedInstance && new Date(selectedInstance.createdAt).toLocaleString()} {selectedInstance && selectedInstance.created_at ? new Date(selectedInstance.created_at).toLocaleString() : '--'}
</span> </span>
</div> </div>
<p className="text-sm text-slate-400 mt-1">申请人: {selectedInstance?.applicantName}</p> <p className="text-sm text-slate-400 mt-1">申请人: {selectedInstance?.applicant_name || selectedInstance?.applicant}</p>
</div> </div>
</div> </div>
@@ -418,10 +498,10 @@ export function ApprovalFlowManager() {
</div> </div>
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-sm font-medium text-slate-200">{selectedInstance?.currentNode}</span> <span className="text-sm font-medium text-slate-200">{selectedInstance?.current_step || '审批节点'}</span>
<span className="text-xs text-slate-500"> <span className="text-xs text-slate-500">
{selectedInstance?.completedAt {selectedInstance?.completed_at
? new Date(selectedInstance.completedAt).toLocaleString() ? new Date(selectedInstance.completed_at).toLocaleString()
: '进行中'} : '进行中'}
</span> </span>
</div> </div>
@@ -441,11 +521,18 @@ export function ApprovalFlowManager() {
<DialogFooter className="gap-2"> <DialogFooter className="gap-2">
{selectedInstance?.status === 'pending' && ( {selectedInstance?.status === 'pending' && (
<> <>
<Button variant="outline" className="border-red-700 text-red-400 hover:bg-red-500/10"> <Button
variant="outline"
className="border-red-700 text-red-400 hover:bg-red-500/10"
onClick={() => handleReject(selectedInstance.id)}
>
<XCircle className="h-4 w-4 mr-2" /> <XCircle className="h-4 w-4 mr-2" />
拒绝 拒绝
</Button> </Button>
<Button className="bg-emerald-600 hover:bg-emerald-700 text-white"> <Button
className="bg-emerald-600 hover:bg-emerald-700 text-white"
onClick={() => handleApprove(selectedInstance.id)}
>
<CheckCircle className="h-4 w-4 mr-2" /> <CheckCircle className="h-4 w-4 mr-2" />
通过 通过
</Button> </Button>
+86
View File
@@ -0,0 +1,86 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useUserStore } from '@/store';
import { userApi } from '@/services/api';
export function Login() {
const navigate = useNavigate();
const login = useUserStore((s) => s.login);
const [username, setUsername] = useState('admin');
const [password, setPassword] = useState('123456');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = await userApi.login(username, password);
login(result.user, result.token);
navigate('/', { replace: true });
} catch (err: any) {
setError(err.message || '登录失败');
} finally {
setLoading(false);
}
};
return (
<div className="min-h-screen bg-slate-950 flex items-center justify-center px-4">
<div className="w-full max-w-sm">
{/* Logo */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-blue-600 mb-4">
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M9 17v-2a4 4 0 014-4h2m4-2H5a2 2 0 00-2 2v10a2 2 0 002 2h14a2 2 0 002-2V7a2 2 0 00-2-2z M7 7h.01M11 7h.01M15 7h.01" />
</svg>
</div>
<h1 className="text-2xl font-bold text-white">AIOps 智能运维平台</h1>
<p className="text-slate-400 mt-2 text-sm">EZOps Platform</p>
</div>
{/* Form */}
<form onSubmit={handleSubmit} className="bg-slate-900/60 rounded-xl p-6 space-y-4">
{error && (
<div className="bg-red-500/10 text-red-400 text-sm rounded-lg px-4 py-3">
{error}
</div>
)}
<div>
<label className="block text-sm font-medium text-slate-300 mb-1.5">用户名</label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full bg-slate-800 text-white rounded-lg px-4 py-2.5 border border-slate-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 transition-colors"
placeholder="请输入用户名"
autoFocus
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-300 mb-1.5">密码</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-slate-800 text-white rounded-lg px-4 py-2.5 border border-slate-700 focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 transition-colors"
placeholder="请输入密码"
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-medium rounded-lg px-4 py-2.5 transition-colors"
>
{loading ? '登录中...' : '登录'}
</button>
</form>
<p className="text-center text-slate-500 text-xs mt-6">
默认账号 admin / 123456
</p>
</div>
</div>
);
}
@@ -0,0 +1,10 @@
import { Navigate } from 'react-router-dom';
import { useUserStore } from '@/store';
export function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useUserStore((s) => s.isAuthenticated);
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -41,30 +41,66 @@ import {
Edit, Edit,
Copy, Copy,
Download, Download,
Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { cronJobs, scripts } from '@/mocks/data'; import { cronJobApi, scriptApi } from '@/services/api';
import type { CronJob, Script } from '@/types';
function parseParams(params: any) {
if (Array.isArray(params)) return params;
if (typeof params === 'string') {
try {
const parsed = JSON.parse(params);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
}
export function AutomationManager() { export function AutomationManager() {
const [activeTab, setActiveTab] = useState('cronjobs'); const [activeTab, setActiveTab] = useState('cronjobs');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedScript, setSelectedScript] = useState<Script | null>(null); const [cronJobs, setCronJobs] = useState<any[]>([]);
const [scripts, setScripts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedScript, setSelectedScript] = useState<any | null>(null);
const [isExecuteDialogOpen, setIsExecuteDialogOpen] = useState(false); const [isExecuteDialogOpen, setIsExecuteDialogOpen] = useState(false);
const loadData = async () => {
try {
setLoading(true);
const [jobsData, scriptsData] = await Promise.all([
cronJobApi.getJobs(),
scriptApi.getScripts(),
]);
setCronJobs(jobsData.list || []);
setScripts(scriptsData.list || []);
} catch (error) {
console.error('Failed to load automation data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const filteredJobs = cronJobs.filter( const filteredJobs = cronJobs.filter(
(job) => (job) =>
job.name.toLowerCase().includes(searchQuery.toLowerCase()) || (job.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
job.description.toLowerCase().includes(searchQuery.toLowerCase()) (job.description || '').toLowerCase().includes(searchQuery.toLowerCase())
); );
const filteredScripts = scripts.filter( const filteredScripts = scripts.filter(
(script) => (script) =>
script.name.toLowerCase().includes(searchQuery.toLowerCase()) || (script.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
script.description.toLowerCase().includes(searchQuery.toLowerCase()) (script.description || '').toLowerCase().includes(searchQuery.toLowerCase())
); );
const getJobStatusBadge = (status: CronJob['status']) => { const getJobStatusBadge = (status: any) => {
return status === 'running' ? ( return status === 1 ? (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10"> <Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<Play className="h-3 w-3 mr-1" /> <Play className="h-3 w-3 mr-1" />
运行中 运行中
@@ -77,19 +113,28 @@ export function AutomationManager() {
); );
}; };
const getLanguageBadge = (language: Script['language']) => { const getLanguageBadge = (language: any) => {
const colors = { const colors: Record<string, string> = {
bash: 'border-green-500/30 text-green-400 bg-green-500/10', bash: 'border-green-500/30 text-green-400 bg-green-500/10',
python: 'border-blue-500/30 text-blue-400 bg-blue-500/10', python: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
powershell: 'border-cyan-500/30 text-cyan-400 bg-cyan-500/10', powershell: 'border-cyan-500/30 text-cyan-400 bg-cyan-500/10',
}; };
return ( return (
<Badge variant="outline" className={colors[language]}> <Badge variant="outline" className={colors[language] || colors.bash}>
{language.toUpperCase()} {(language || '').toUpperCase()}
</Badge> </Badge>
); );
}; };
const handleExecuteScript = async (id: any) => {
try {
await scriptApi.executeScript(id);
setIsExecuteDialogOpen(false);
} catch (error) {
console.error('Failed to execute script:', error);
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Page Header */} {/* Page Header */}
@@ -131,7 +176,7 @@ export function AutomationManager() {
<div> <div>
<p className="text-sm text-slate-500">运行中</p> <p className="text-sm text-slate-500">运行中</p>
<p className="text-2xl font-bold text-emerald-400"> <p className="text-2xl font-bold text-emerald-400">
{cronJobs.filter((j) => j.status === 'running').length} {cronJobs.filter((j) => j.status === 1).length}
</p> </p>
</div> </div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400"> <div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
@@ -157,8 +202,10 @@ export function AutomationManager() {
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-slate-500">今日执行</p> <p className="text-sm text-slate-500">累计执行</p>
<p className="text-2xl font-bold text-blue-400">156</p> <p className="text-2xl font-bold text-blue-400">
{cronJobs.reduce((sum, j) => sum + (j.success_count || 0) + (j.fail_count || 0), 0)}
</p>
</div> </div>
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400"> <div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
<RotateCw className="h-5 w-5" /> <RotateCw className="h-5 w-5" />
@@ -181,6 +228,12 @@ export function AutomationManager() {
</div> </div>
</div> </div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
</div>
) : null}
{/* Tabs */} {/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="bg-slate-800 border border-slate-700"> <TabsList className="bg-slate-800 border border-slate-700">
@@ -234,24 +287,29 @@ export function AutomationManager() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="text-sm text-slate-400"> <span className="text-sm text-slate-400">
{job.lastRunAt ? new Date(job.lastRunAt).toLocaleString() : '--'} {job.last_run ? new Date(job.last_run).toLocaleString() : '--'}
</span> </span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="text-sm text-slate-400"> <span className="text-sm text-slate-400">
{job.nextRunAt ? new Date(job.nextRunAt).toLocaleString() : '--'} {job.next_run ? new Date(job.next_run).toLocaleString() : '--'}
</span> </span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="text-sm"> <div className="text-sm">
<span className="text-emerald-400">{job.successCount}</span> <span className="text-emerald-400">{job.success_count || 0}</span>
<span className="text-slate-500 mx-1">/</span> <span className="text-slate-500 mx-1">/</span>
<span className="text-red-400">{job.failCount}</span> <span className="text-red-400">{job.fail_count || 0}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400"> <Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400"
onClick={() => cronJobApi.executeJob(job.id)}
>
<Play className="h-4 w-4" /> <Play className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100"> <Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
@@ -290,11 +348,11 @@ export function AutomationManager() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="bg-slate-950 rounded-lg p-3 font-mono text-xs text-slate-400 overflow-x-auto"> <div className="bg-slate-950 rounded-lg p-3 font-mono text-xs text-slate-400 overflow-x-auto">
<pre className="whitespace-pre-wrap">{script.content.slice(0, 200)}...</pre> <pre className="whitespace-pre-wrap">{(script.content || '').slice(0, 200)}...</pre>
</div> </div>
<div className="flex items-center justify-between mt-4"> <div className="flex items-center justify-between mt-4">
<div className="flex gap-2"> <div className="flex gap-2">
{script.params.map((param) => ( {parseParams(script.params).map((param: any) => (
<Badge key={param.name} variant="outline" className="border-slate-700 text-slate-400 text-xs"> <Badge key={param.name} variant="outline" className="border-slate-700 text-slate-400 text-xs">
{param.name} {param.name}
{param.required && <span className="text-red-400 ml-0.5">*</span>} {param.required && <span className="text-red-400 ml-0.5">*</span>}
@@ -367,7 +425,7 @@ export function AutomationManager() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{selectedScript && getLanguageBadge(selectedScript.language)} {selectedScript && getLanguageBadge(selectedScript.language)}
<span className="text-sm text-slate-500"> <span className="text-sm text-slate-500">
更新于 {selectedScript && new Date(selectedScript.updatedAt).toLocaleDateString()} 更新于 {selectedScript && selectedScript.updated_at ? new Date(selectedScript.updated_at).toLocaleDateString() : '--'}
</span> </span>
</div> </div>
@@ -375,11 +433,11 @@ export function AutomationManager() {
<pre className="whitespace-pre">{selectedScript?.content}</pre> <pre className="whitespace-pre">{selectedScript?.content}</pre>
</div> </div>
{selectedScript && selectedScript.params.length > 0 && ( {selectedScript && parseParams(selectedScript.params).length > 0 && (
<div> <div>
<h4 className="text-sm font-medium text-slate-300 mb-2">参数</h4> <h4 className="text-sm font-medium text-slate-300 mb-2">参数</h4>
<div className="space-y-2"> <div className="space-y-2">
{selectedScript.params.map((param) => ( {parseParams(selectedScript.params).map((param: any) => (
<div key={param.name} className="flex items-center gap-2 p-2 rounded-lg bg-slate-800/50"> <div key={param.name} className="flex items-center gap-2 p-2 rounded-lg bg-slate-800/50">
<code className="text-sm text-slate-300">{param.name}</code> <code className="text-sm text-slate-300">{param.name}</code>
<Badge variant="outline" className="border-slate-700 text-slate-400 text-xs"> <Badge variant="outline" className="border-slate-700 text-slate-400 text-xs">
@@ -444,7 +502,7 @@ export function AutomationManager() {
</Select> </Select>
</div> </div>
{selectedScript?.params.map((param) => ( {selectedScript && parseParams(selectedScript.params).map((param: any) => (
<div key={param.name}> <div key={param.name}>
<label className="text-sm font-medium text-slate-300"> <label className="text-sm font-medium text-slate-300">
{param.name} {param.name}
@@ -463,7 +521,7 @@ export function AutomationManager() {
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setIsExecuteDialogOpen(false)}> <Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setIsExecuteDialogOpen(false)}>
取消 取消
</Button> </Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white"> <Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={() => selectedScript && handleExecuteScript(selectedScript.id)}>
<Play className="h-4 w-4 mr-2" /> <Play className="h-4 w-4 mr-2" />
开始执行 开始执行
</Button> </Button>
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -49,123 +49,46 @@ import {
Eye, Eye,
Copy, Copy,
} from 'lucide-react'; } from 'lucide-react';
import { cronJobs } from '@/mocks/data'; import { cronJobApi } from '@/services/api';
interface CronJob {
id: string;
name: string;
description: string;
schedule: string;
command: string;
status: 'enabled' | 'disabled';
lastRun: string;
lastStatus: 'success' | 'failed' | 'running' | 'never';
nextRun: string;
targetServers: string[];
createdAt: string;
createdBy: string;
}
const mockCronJobs: CronJob[] = [
{
id: 'cron-001',
name: '数据库备份',
description: '每日凌晨2点备份数据库',
schedule: '0 2 * * *',
command: '/scripts/backup-db.sh --type=full',
status: 'enabled',
lastRun: '2025-04-14 02:00:00',
lastStatus: 'success',
nextRun: '2025-04-15 02:00:00',
targetServers: ['db-server-01', 'db-server-02'],
createdAt: '2025-01-15',
createdBy: 'admin',
},
{
id: 'cron-002',
name: '日志清理',
description: '每周日凌晨3点清理过期日志',
schedule: '0 3 * * 0',
command: '/scripts/clean-logs.sh --days=30',
status: 'enabled',
lastRun: '2025-04-13 03:00:00',
lastStatus: 'success',
nextRun: '2025-04-20 03:00:00',
targetServers: ['all'],
createdAt: '2025-01-20',
createdBy: 'admin',
},
{
id: 'cron-003',
name: '健康检查',
description: '每5分钟执行健康检查',
schedule: '*/5 * * * *',
command: '/scripts/health-check.sh',
status: 'enabled',
lastRun: '2025-04-14 11:55:00',
lastStatus: 'running',
nextRun: '2025-04-14 12:00:00',
targetServers: ['web-server-01', 'web-server-02', 'app-server-01'],
createdAt: '2025-02-01',
createdBy: 'ops01',
},
{
id: 'cron-004',
name: '数据同步',
description: '每小时同步数据到备份服务器',
schedule: '0 * * * *',
command: '/scripts/sync-data.sh --target=backup',
status: 'disabled',
lastRun: '2025-04-14 10:00:00',
lastStatus: 'failed',
nextRun: '-',
targetServers: ['app-server-01'],
createdAt: '2025-02-15',
createdBy: 'ops02',
},
{
id: 'cron-005',
name: '生成报表',
description: '每周一上午9点生成业务报表',
schedule: '0 9 * * 1',
command: '/scripts/generate-report.sh --type=weekly',
status: 'enabled',
lastRun: '2025-04-07 09:00:00',
lastStatus: 'success',
nextRun: '2025-04-21 09:00:00',
targetServers: ['report-server'],
createdAt: '2025-03-01',
createdBy: 'admin',
},
];
const executionLogs = [
{ id: 1, jobName: '数据库备份', startTime: '2025-04-14 02:00:00', endTime: '2025-04-14 02:15:30', duration: '15m 30s', status: 'success', output: '备份完成: 15.2GB' },
{ id: 2, jobName: '健康检查', startTime: '2025-04-14 11:55:00', endTime: '2025-04-14 11:55:05', duration: '5s', status: 'success', output: '所有服务正常' },
{ id: 3, jobName: '数据同步', startTime: '2025-04-14 10:00:00', endTime: '2025-04-14 10:05:23', duration: '5m 23s', status: 'failed', output: '连接超时' },
{ id: 4, jobName: '日志清理', startTime: '2025-04-13 03:00:00', endTime: '2025-04-13 03:02:15', duration: '2m 15s', status: 'success', output: '清理完成: 2.3GB' },
];
export function CronJobManager() { export function CronJobManager() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [jobs, setJobs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isLogDialogOpen, setIsLogDialogOpen] = useState(false); const [isLogDialogOpen, setIsLogDialogOpen] = useState(false);
const [selectedJob, setSelectedJob] = useState<CronJob | null>(null); const [selectedJob, setSelectedJob] = useState<any | null>(null);
const [newJob, setNewJob] = useState({ const [newJob, setNewJob] = useState({
name: '', name: '',
description: '', description: '',
schedule: '', cron: '',
command: '', command: '',
targetServers: '', targetServers: '',
}); });
const filteredJobs = mockCronJobs.filter( const loadData = async () => {
try {
setLoading(true);
const data = await cronJobApi.getJobs();
setJobs(data.list || []);
} catch (error) {
console.error('Failed to load cron jobs:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const filteredJobs = jobs.filter(
(job) => (job) =>
job.name.toLowerCase().includes(searchQuery.toLowerCase()) || (job.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
job.description.toLowerCase().includes(searchQuery.toLowerCase()) (job.description || '').toLowerCase().includes(searchQuery.toLowerCase())
); );
const getStatusBadge = (status: CronJob['lastStatus']) => { const getStatusBadge = (status: any) => {
switch (status) { switch (status) {
case 'success': case 'success':
return ( return (
@@ -208,9 +131,47 @@ export function CronJobManager() {
return patterns[schedule] || schedule; return patterns[schedule] || schedule;
}; };
const handleCreateJob = () => { const handleCreateJob = async () => {
try {
await cronJobApi.createJob({
name: newJob.name,
description: newJob.description,
cron: newJob.cron,
command: newJob.command,
});
setIsCreateDialogOpen(false); setIsCreateDialogOpen(false);
setNewJob({ name: '', description: '', schedule: '', command: '', targetServers: '' }); setNewJob({ name: '', description: '', cron: '', command: '', targetServers: '' });
loadData();
} catch (error) {
console.error('Failed to create cron job:', error);
}
};
const handleToggleJob = async (id: any) => {
try {
await cronJobApi.toggleJob(id);
loadData();
} catch (error) {
console.error('Failed to toggle job:', error);
}
};
const handleExecuteJob = async (id: any) => {
try {
await cronJobApi.executeJob(id);
loadData();
} catch (error) {
console.error('Failed to execute job:', error);
}
};
const handleDeleteJob = async (id: any) => {
try {
await cronJobApi.deleteJob(id);
loadData();
} catch (error) {
console.error('Failed to delete job:', error);
}
}; };
return ( return (
@@ -238,30 +199,28 @@ export function CronJobManager() {
<Card className="bg-slate-800 border-slate-700"> <Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-slate-400 text-sm">任务总数</p> <p className="text-slate-400 text-sm">任务总数</p>
<p className="text-2xl font-bold text-slate-100">{mockCronJobs.length}</p> <p className="text-2xl font-bold text-slate-100">{jobs.length}</p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-slate-800 border-slate-700"> <Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-slate-400 text-sm">已启用</p> <p className="text-slate-400 text-sm">已启用</p>
<p className="text-2xl font-bold text-emerald-400"> <p className="text-2xl font-bold text-emerald-400">
{mockCronJobs.filter((j) => j.status === 'enabled').length} {jobs.filter((j) => j.status === 1).length}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-slate-800 border-slate-700"> <Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-slate-400 text-sm">执行中</p> <p className="text-slate-400 text-sm">执行中</p>
<p className="text-2xl font-bold text-blue-400"> <p className="text-2xl font-bold text-blue-400">--</p>
{mockCronJobs.filter((j) => j.lastStatus === 'running').length}
</p>
</CardContent> </CardContent>
</Card> </Card>
<Card className="bg-slate-800 border-slate-700"> <Card className="bg-slate-800 border-slate-700">
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-slate-400 text-sm">今日失败</p> <p className="text-slate-400 text-sm">累计失败</p>
<p className="text-2xl font-bold text-red-400"> <p className="text-2xl font-bold text-red-400">
{mockCronJobs.filter((j) => j.lastStatus === 'failed').length} {jobs.reduce((sum, j) => sum + (j.fail_count || 0), 0)}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -280,6 +239,12 @@ export function CronJobManager() {
</div> </div>
</div> </div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
</div>
) : null}
{/* Job List */} {/* Job List */}
<div className="space-y-3"> <div className="space-y-3">
{filteredJobs.map((job) => ( {filteredJobs.map((job) => (
@@ -292,20 +257,20 @@ export function CronJobManager() {
<Badge <Badge
variant="outline" variant="outline"
className={ className={
job.status === 'enabled' job.status === 1
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10' ? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
: 'border-slate-500/30 text-slate-400 bg-slate-500/10' : 'border-slate-500/30 text-slate-400 bg-slate-500/10'
} }
> >
{job.status === 'enabled' ? '已启用' : '已禁用'} {job.status === 1 ? '已启用' : '已禁用'}
</Badge> </Badge>
{getStatusBadge(job.lastStatus)} {getStatusBadge(job.last_status)}
</div> </div>
<p className="text-sm text-slate-400 mt-1">{job.description}</p> <p className="text-sm text-slate-400 mt-1">{job.description}</p>
<div className="flex items-center gap-4 mt-2 text-sm"> <div className="flex items-center gap-4 mt-2 text-sm">
<span className="flex items-center gap-1 text-slate-400"> <span className="flex items-center gap-1 text-slate-400">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
{parseCron(job.schedule)} {parseCron(job.cron)}
</span> </span>
<span className="flex items-center gap-1 text-slate-400"> <span className="flex items-center gap-1 text-slate-400">
<Terminal className="h-3 w-3" /> <Terminal className="h-3 w-3" />
@@ -313,17 +278,30 @@ export function CronJobManager() {
</span> </span>
</div> </div>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500"> <div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span>上次执行: {job.lastRun}</span> <span>上次执行: {job.last_run ? new Date(job.last_run).toLocaleString() : '--'}</span>
<span>下次执行: {job.nextRun}</span> <span>下次执行: {job.next_run ? new Date(job.next_run).toLocaleString() : '--'}</span>
<span>目标: {job.targetServers.join(', ')}</span> <span>成功: {job.success_count || 0} / 失败: {job.fail_count || 0}</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="border-emerald-600 text-emerald-400 hover:bg-emerald-600/20"> <Button
variant="outline"
size="sm"
className="border-emerald-600 text-emerald-400 hover:bg-emerald-600/20"
onClick={() => handleExecuteJob(job.id)}
>
<Play className="h-4 w-4 mr-1" /> <Play className="h-4 w-4 mr-1" />
立即执行 立即执行
</Button> </Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-blue-400"
onClick={() => handleToggleJob(job.id)}
>
{job.status === 1 ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</Button>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
@@ -335,6 +313,7 @@ export function CronJobManager() {
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400" className="h-8 w-8 text-slate-400 hover:text-red-400"
onClick={() => handleDeleteJob(job.id)}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
@@ -375,12 +354,12 @@ export function CronJobManager() {
<Label className="text-slate-300">Cron 表达式</Label> <Label className="text-slate-300">Cron 表达式</Label>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
value={newJob.schedule} value={newJob.cron}
onChange={(e) => setNewJob({ ...newJob, schedule: e.target.value })} onChange={(e) => setNewJob({ ...newJob, cron: e.target.value })}
placeholder="0 2 * * *" placeholder="0 2 * * *"
className="bg-slate-800 border-slate-700 text-slate-100 mt-1" className="bg-slate-800 border-slate-700 text-slate-100 mt-1"
/> />
<Select onValueChange={(v) => setNewJob({ ...newJob, schedule: v })}> <Select onValueChange={(v) => setNewJob({ ...newJob, cron: v })}>
<SelectTrigger className="w-48 bg-slate-800 border-slate-700 text-slate-100 mt-1"> <SelectTrigger className="w-48 bg-slate-800 border-slate-700 text-slate-100 mt-1">
<SelectValue placeholder="快速选择" /> <SelectValue placeholder="快速选择" />
</SelectTrigger> </SelectTrigger>
@@ -433,39 +412,45 @@ export function CronJobManager() {
<DialogDescription className="text-slate-500">定时任务执行记录</DialogDescription> <DialogDescription className="text-slate-500">定时任务执行记录</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="mt-4"> <div className="mt-4">
{jobs.length === 0 ? (
<div className="text-center text-slate-500 py-8">暂无执行记录</div>
) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="border-slate-700"> <TableRow className="border-slate-700">
<TableHead className="text-slate-400">任务</TableHead> <TableHead className="text-slate-400">任务</TableHead>
<TableHead className="text-slate-400">开始时间</TableHead> <TableHead className="text-slate-400">上次执行</TableHead>
<TableHead className="text-slate-400">耗时</TableHead> <TableHead className="text-slate-400">成功次数</TableHead>
<TableHead className="text-slate-400">失败次数</TableHead>
<TableHead className="text-slate-400">状态</TableHead> <TableHead className="text-slate-400">状态</TableHead>
<TableHead className="text-slate-400">输出</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{executionLogs.map((log) => ( {jobs.map((job) => (
<TableRow key={log.id} className="border-slate-700"> <TableRow key={job.id} className="border-slate-700">
<TableCell className="text-slate-200">{log.jobName}</TableCell> <TableCell className="text-slate-200">{job.name}</TableCell>
<TableCell className="text-slate-400">{log.startTime}</TableCell> <TableCell className="text-slate-400">
<TableCell className="text-slate-400">{log.duration}</TableCell> {job.last_run ? new Date(job.last_run).toLocaleString() : '--'}
</TableCell>
<TableCell className="text-emerald-400">{job.success_count || 0}</TableCell>
<TableCell className="text-red-400">{job.fail_count || 0}</TableCell>
<TableCell> <TableCell>
<Badge <Badge
variant="outline" variant="outline"
className={ className={
log.status === 'success' (job.fail_count || 0) > 0
? 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10' ? 'border-red-500/30 text-red-400 bg-red-500/10'
: 'border-red-500/30 text-red-400 bg-red-500/10' : 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10'
} }
> >
{log.status === 'success' ? '成功' : '失败'} {(job.fail_count || 0) > 0 ? '有失败' : '正常'}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-slate-400 text-sm">{log.output}</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -14,19 +14,7 @@ import {
XCircle, XCircle,
RefreshCw RefreshCw
} from 'lucide-react'; } from 'lucide-react';
import { cloudAccounts } from '@/mocks/data'; import { cloudApi } from '@/services/api';
interface CloudAccount {
id: string;
name: string;
provider: string;
accessKey: string;
status: 'active' | 'inactive' | 'error';
regionCount: number;
serverCount: number;
createdAt: string;
lastSync: string;
}
const providerIcons: Record<string, string> = { const providerIcons: Record<string, string> = {
aws: 'AWS', aws: 'AWS',
@@ -45,21 +33,50 @@ const providerColors: Record<string, string> = {
}; };
export function CloudAccountManager() { export function CloudAccountManager() {
const [accounts, setAccounts] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedProvider, setSelectedProvider] = useState<string>('all'); const [selectedProvider, setSelectedProvider] = useState<string>('all');
const filteredAccounts = cloudAccounts.filter(account => { const loadData = async () => {
const matchesSearch = account.name.toLowerCase().includes(searchQuery.toLowerCase()) || try {
account.accessKey.toLowerCase().includes(searchQuery.toLowerCase()); setLoading(true);
const res: any = await cloudApi.getAccounts();
setAccounts(res?.list || []);
} catch (e) {
console.error('加载云账号列表失败:', e);
setAccounts([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleDelete = async (id: string) => {
try {
await cloudApi.deleteAccount(id);
loadData();
} catch (e) {
console.error('删除云账号失败:', e);
}
};
const filteredAccounts = accounts.filter(account => {
const accessKey = account.access_key || account.accessKey || '';
const matchesSearch = (account.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
accessKey.toLowerCase().includes(searchQuery.toLowerCase());
const matchesProvider = selectedProvider === 'all' || account.provider === selectedProvider; const matchesProvider = selectedProvider === 'all' || account.provider === selectedProvider;
return matchesSearch && matchesProvider; return matchesSearch && matchesProvider;
}); });
const stats = { const stats = {
total: cloudAccounts.length, total: accounts.length,
active: cloudAccounts.filter(a => a.status === 'active').length, active: accounts.filter(a => a.status === 'active').length,
inactive: cloudAccounts.filter(a => a.status === 'inactive').length, inactive: accounts.filter(a => a.status === 'inactive').length,
error: cloudAccounts.filter(a => a.status === 'error').length, error: accounts.filter(a => a.status === 'error').length,
}; };
return ( return (
@@ -70,11 +87,17 @@ export function CloudAccountManager() {
<h1 className="text-2xl font-bold text-slate-100">云账号管理</h1> <h1 className="text-2xl font-bold text-slate-100">云账号管理</h1>
<p className="text-slate-400 mt-1">管理多云平台的访问凭证和账号信息</p> <p className="text-slate-400 mt-1">管理多云平台的访问凭证和账号信息</p>
</div> </div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={loadData} className="border-slate-700 text-slate-300">
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
刷新
</Button>
<Button className="bg-blue-600 hover:bg-blue-700"> <Button className="bg-blue-600 hover:bg-blue-700">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
添加账号 添加账号
</Button> </Button>
</div> </div>
</div>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
@@ -158,23 +181,42 @@ export function CloudAccountManager() {
</div> </div>
{/* Account List */} {/* Account List */}
{loading ? (
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-12 text-center text-slate-500">
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-3" />
加载中...
</CardContent>
</Card>
) : filteredAccounts.length === 0 ? (
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-12 text-center text-slate-500">暂无云账号数据</CardContent>
</Card>
) : (
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
{filteredAccounts.map((account) => ( {filteredAccounts.map((account) => {
const accessKey = account.access_key || account.accessKey || '';
const regionCount = account.region_count ?? account.regionCount ?? 0;
const serverCount = account.server_count ?? account.serverCount ?? 0;
const lastSync = account.last_sync || account.lastSync || account.created_at || '-';
return (
<Card key={account.id} className="bg-slate-800 border-slate-700"> <Card key={account.id} className="bg-slate-800 border-slate-700">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className={`w-10 h-10 rounded-lg ${providerColors[account.provider]} flex items-center justify-center text-white font-bold text-xs`}> <div className={`w-10 h-10 rounded-lg ${providerColors[account.provider] || 'bg-blue-500'} flex items-center justify-center text-white font-bold text-xs`}>
{providerIcons[account.provider]?.slice(0, 2) || account.provider.slice(0, 2).toUpperCase()} {providerIcons[account.provider]?.slice(0, 2) || (account.provider || '').slice(0, 2).toUpperCase()}
</div> </div>
<div> <div>
<h3 className="font-medium text-slate-100">{account.name}</h3> <h3 className="font-medium text-slate-100">{account.name}</h3>
<p className="text-sm text-slate-400 mt-0.5">{providerIcons[account.provider]}</p> <p className="text-sm text-slate-400 mt-0.5">{providerIcons[account.provider]}</p>
<div className="flex items-center gap-3 mt-2"> <div className="flex items-center gap-3 mt-2">
{accessKey && (
<Badge variant="secondary" className="bg-slate-700 text-slate-300"> <Badge variant="secondary" className="bg-slate-700 text-slate-300">
<Key className="h-3 w-3 mr-1" /> <Key className="h-3 w-3 mr-1" />
{account.accessKey.slice(0, 8)}... {accessKey.slice(0, 8)}...
</Badge> </Badge>
)}
{account.status === 'active' && ( {account.status === 'active' && (
<Badge className="bg-green-500/20 text-green-400 border-0"> <Badge className="bg-green-500/20 text-green-400 border-0">
<CheckCircle2 className="h-3 w-3 mr-1" /> <CheckCircle2 className="h-3 w-3 mr-1" />
@@ -203,7 +245,12 @@ export function CloudAccountManager() {
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400"> <Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Edit2 className="h-4 w-4" /> <Edit2 className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400"> <Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400"
onClick={() => handleDelete(account.id)}
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -211,21 +258,25 @@ export function CloudAccountManager() {
<div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-slate-700"> <div className="grid grid-cols-3 gap-4 mt-4 pt-4 border-t border-slate-700">
<div> <div>
<p className="text-xs text-slate-400">区域数</p> <p className="text-xs text-slate-400">区域数</p>
<p className="text-lg font-medium text-slate-100">{account.regionCount}</p> <p className="text-lg font-medium text-slate-100">{regionCount}</p>
</div> </div>
<div> <div>
<p className="text-xs text-slate-400">服务器数</p> <p className="text-xs text-slate-400">服务器数</p>
<p className="text-lg font-medium text-slate-100">{account.serverCount}</p> <p className="text-lg font-medium text-slate-100">{serverCount}</p>
</div> </div>
<div> <div>
<p className="text-xs text-slate-400">最后同步</p> <p className="text-xs text-slate-400">最后同步</p>
<p className="text-sm text-slate-300">{account.lastSync}</p> <p className="text-sm text-slate-300">
{lastSync !== '-' ? new Date(lastSync).toLocaleString() : '-'}
</p>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
))} );
})}
</div> </div>
)}
</div> </div>
); );
} }
+73 -25
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
@@ -12,29 +12,46 @@ import {
CheckCircle2, CheckCircle2,
XCircle, XCircle,
Clock, Clock,
ExternalLink ExternalLink,
RefreshCw
} from 'lucide-react'; } from 'lucide-react';
import { domains } from '@/mocks/data'; import { cloudApi } from '@/services/api';
interface Domain {
id: string;
name: string;
registrar: string;
status: 'active' | 'expired' | 'pending';
expireDate: string;
dnsProvider: string;
recordCount: number;
sslEnabled: boolean;
sslExpireDate?: string;
}
export function DomainManager() { export function DomainManager() {
const [domains, setDomains] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedStatus, setSelectedStatus] = useState<string>('all'); const [selectedStatus, setSelectedStatus] = useState<string>('all');
const loadData = async () => {
try {
setLoading(true);
const res: any = await cloudApi.getDomains();
setDomains(res?.list || []);
} catch (e) {
console.error('加载域名列表失败:', e);
setDomains([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleDelete = async (id: string) => {
try {
await cloudApi.deleteDomain(id);
loadData();
} catch (e) {
console.error('删除域名失败:', e);
}
};
const filteredDomains = domains.filter(domain => { const filteredDomains = domains.filter(domain => {
const matchesSearch = domain.name.toLowerCase().includes(searchQuery.toLowerCase()) || const matchesSearch = (domain.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
domain.registrar.toLowerCase().includes(searchQuery.toLowerCase()); (domain.registrar || '').toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = selectedStatus === 'all' || domain.status === selectedStatus; const matchesStatus = selectedStatus === 'all' || domain.status === selectedStatus;
return matchesSearch && matchesStatus; return matchesSearch && matchesStatus;
}); });
@@ -44,10 +61,11 @@ export function DomainManager() {
active: domains.filter(d => d.status === 'active').length, active: domains.filter(d => d.status === 'active').length,
expired: domains.filter(d => d.status === 'expired').length, expired: domains.filter(d => d.status === 'expired').length,
pending: domains.filter(d => d.status === 'pending').length, pending: domains.filter(d => d.status === 'pending').length,
sslEnabled: domains.filter(d => d.sslEnabled).length, sslEnabled: domains.filter(d => d.ssl_enabled || d.sslEnabled).length,
}; };
const getDaysUntilExpiry = (expireDate: string) => { const getDaysUntilExpiry = (expireDate: string) => {
if (!expireDate) return Infinity;
const days = Math.ceil((new Date(expireDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24)); const days = Math.ceil((new Date(expireDate).getTime() - Date.now()) / (1000 * 60 * 60 * 24));
return days; return days;
}; };
@@ -60,11 +78,17 @@ export function DomainManager() {
<h1 className="text-2xl font-bold text-slate-100">域名管理</h1> <h1 className="text-2xl font-bold text-slate-100">域名管理</h1>
<p className="text-slate-400 mt-1">管理域名注册信息和DNS解析记录</p> <p className="text-slate-400 mt-1">管理域名注册信息和DNS解析记录</p>
</div> </div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={loadData} className="border-slate-700 text-slate-300">
<RefreshCw className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
刷新
</Button>
<Button className="bg-blue-600 hover:bg-blue-700"> <Button className="bg-blue-600 hover:bg-blue-700">
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
添加域名 添加域名
</Button> </Button>
</div> </div>
</div>
{/* Stats */} {/* Stats */}
<div className="grid grid-cols-5 gap-4"> <div className="grid grid-cols-5 gap-4">
@@ -159,9 +183,24 @@ export function DomainManager() {
</div> </div>
{/* Domain List */} {/* Domain List */}
{loading ? (
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-12 text-center text-slate-500">
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-3" />
加载中...
</CardContent>
</Card>
) : filteredDomains.length === 0 ? (
<Card className="bg-slate-800 border-slate-700">
<CardContent className="p-12 text-center text-slate-500">暂无域名数据</CardContent>
</Card>
) : (
<div className="space-y-3"> <div className="space-y-3">
{filteredDomains.map((domain) => { {filteredDomains.map((domain) => {
const daysUntilExpiry = getDaysUntilExpiry(domain.expireDate); const sslEnabled = domain.ssl_enabled || domain.sslEnabled;
const expireDate = domain.expire_date || domain.expireDate || '';
const sslExpireDate = domain.ssl_expire_date || domain.sslExpireDate || '';
const daysUntilExpiry = getDaysUntilExpiry(expireDate);
return ( return (
<Card key={domain.id} className="bg-slate-800 border-slate-700"> <Card key={domain.id} className="bg-slate-800 border-slate-700">
<CardContent className="p-4"> <CardContent className="p-4">
@@ -191,7 +230,7 @@ export function DomainManager() {
待审核 待审核
</Badge> </Badge>
)} )}
{domain.sslEnabled && ( {sslEnabled && (
<Badge className="bg-cyan-500/20 text-cyan-400 border-0"> <Badge className="bg-cyan-500/20 text-cyan-400 border-0">
<CheckCircle2 className="h-3 w-3 mr-1" /> <CheckCircle2 className="h-3 w-3 mr-1" />
SSL SSL
@@ -199,9 +238,9 @@ export function DomainManager() {
)} )}
</div> </div>
<div className="flex items-center gap-4 mt-1 text-sm text-slate-400"> <div className="flex items-center gap-4 mt-1 text-sm text-slate-400">
<span>注册商: {domain.registrar}</span> <span>注册商: {domain.registrar || '-'}</span>
<span>DNS: {domain.dnsProvider}</span> <span>DNS: {domain.dns_provider || domain.dnsProvider || '-'}</span>
<span>解析记录: {domain.recordCount}条</span> <span>解析记录: {domain.record_count ?? domain.recordCount ?? 0}条</span>
</div> </div>
</div> </div>
</div> </div>
@@ -209,7 +248,10 @@ export function DomainManager() {
<div className="text-right"> <div className="text-right">
<p className="text-sm text-slate-400">到期时间</p> <p className="text-sm text-slate-400">到期时间</p>
<p className={`text-sm font-medium ${daysUntilExpiry < 30 ? 'text-red-400' : 'text-slate-200'}`}> <p className={`text-sm font-medium ${daysUntilExpiry < 30 ? 'text-red-400' : 'text-slate-200'}`}>
{domain.expireDate} ({daysUntilExpiry}天) {expireDate || '-'}{expireDate && ` (${daysUntilExpiry}天)`}
{sslEnabled && sslExpireDate && (
<span className="text-slate-500"> · SSL: {sslExpireDate}</span>
)}
</p> </p>
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
@@ -219,7 +261,12 @@ export function DomainManager() {
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400"> <Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Edit2 className="h-4 w-4" /> <Edit2 className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400"> <Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400"
onClick={() => handleDelete(domain.id)}
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -230,6 +277,7 @@ export function DomainManager() {
); );
})} })}
</div> </div>
)}
</div> </div>
); );
} }
+212 -182
View File
@@ -3,7 +3,6 @@ import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/com
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area'; import { ScrollArea } from '@/components/ui/scroll-area';
import { Separator } from '@/components/ui/separator';
import { import {
Server, Server,
Cloud, Cloud,
@@ -37,8 +36,37 @@ import {
BarChart, BarChart,
Bar, Bar,
} from 'recharts'; } from 'recharts';
import { dashboardData, servers, k8sClusters, alertEvents, appReleases, domains, generateMetricsData } from '@/mocks/data'; import { dashboardStatsApi, serverApi, k8sApi } from '@/services/api';
import type { DashboardData } from '@/types';
// 云平台颜色映射
const CLOUD_COLORS: Record<string, string> = {
'阿里云': '#ff6a00',
aliyun: '#ff6a00',
AWS: '#ff9900',
aws: '#ff9900',
'腾讯云': '#006eff',
tencent: '#006eff',
'华为云': '#ff0000',
huawei: '#ff0000',
Azure: '#0078d4',
azure: '#0078d4',
};
const FALLBACK_CLOUD_COLORS = ['#ff6a00', '#ff9900', '#006eff', '#ff0000', '#0078d4', '#10b981', '#a855f7', '#ec4899'];
// 根据平均值生成24小时趋势数据(无实时指标时使用)
function generateMetricsData(avgCpu: number, avgMemory: number, avgDisk: number, points = 24) {
const data = [];
for (let i = points - 1; i >= 0; i--) {
const d = new Date(Date.now() - i * 60 * 60 * 1000);
data.push({
time: `${d.getHours().toString().padStart(2, '0')}:00`,
cpu: Number(Math.max(0, avgCpu + (Math.random() - 0.5) * 15).toFixed(1)),
memory: Number(Math.max(0, avgMemory + (Math.random() - 0.5) * 10).toFixed(1)),
disk: Number(Math.max(0, avgDisk + (Math.random() - 0.5) * 5).toFixed(1)),
});
}
return data;
}
// 统计卡片组件 // 统计卡片组件
function StatCard({ function StatCard({
@@ -109,24 +137,18 @@ function StatCard({
} }
// 成本费用卡片 // 成本费用卡片
function CostOverviewCard() { function CostOverviewCard({ costStats }: { costStats: any }) {
const costData = [ const cloudCosts: any[] = costStats?.cloud_costs ?? [];
{ name: '阿里云', value: 28500, color: '#ff6a00' }, const monthTrend: any[] = costStats?.month_trend ?? [];
{ name: 'AWS', value: 42000, color: '#ff9900' },
{ name: '腾讯云', value: 15800, color: '#006eff' },
{ name: '华为云', value: 8900, color: '#ff0000' },
{ name: 'Azure', value: 12500, color: '#0078d4' },
];
const monthlyData = [ const costData = cloudCosts.map((c, i) => ({
{ month: '1月', cost: 85000 }, name: c.cloud,
{ month: '2月', cost: 92000 }, value: c.cost,
{ month: '3月', cost: 88000 }, color: CLOUD_COLORS[c.cloud] || FALLBACK_CLOUD_COLORS[i % FALLBACK_CLOUD_COLORS.length],
{ month: '4月', cost: 95000 }, }));
{ month: '5月', cost: 107700 }, const monthlyData = monthTrend.map((m) => ({ month: m.month, cost: m.cost }));
]; const totalCost =
costStats?.total_cost ?? costData.reduce((sum, item) => sum + item.value, 0);
const totalCost = costData.reduce((sum, item) => sum + item.value, 0);
return ( return (
<Card className="bg-slate-900 border-slate-800"> <Card className="bg-slate-900 border-slate-800">
@@ -137,7 +159,7 @@ function CostOverviewCard() {
<CardDescription className="text-slate-500">本月各云平台费用支出</CardDescription> <CardDescription className="text-slate-500">本月各云平台费用支出</CardDescription>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-2xl font-bold text-slate-100">¥{totalCost.toLocaleString()}</p> <p className="text-2xl font-bold text-slate-100">¥{Number(totalCost).toLocaleString()}</p>
<p className="text-xs text-emerald-400">+12.5% 较上月</p> <p className="text-xs text-emerald-400">+12.5% 较上月</p>
</div> </div>
</div> </div>
@@ -205,7 +227,7 @@ function CostOverviewCard() {
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: item.color }} /> <div className="w-3 h-3 rounded-full" style={{ backgroundColor: item.color }} />
<span className="text-sm text-slate-300">{item.name}</span> <span className="text-sm text-slate-300">{item.name}</span>
</div> </div>
<span className="text-sm font-medium text-slate-100">¥{item.value.toLocaleString()}</span> <span className="text-sm font-medium text-slate-100">¥{Number(item.value).toLocaleString()}</span>
</div> </div>
))} ))}
</div> </div>
@@ -215,8 +237,8 @@ function CostOverviewCard() {
} }
// 告警消息卡片 // 告警消息卡片
function AlertMessagesCard() { function AlertMessagesCard({ alerts }: { alerts: any[] }) {
const recentAlerts = alertEvents.filter(a => a.status === 'firing').slice(0, 8); const recentAlerts = alerts.filter((a) => a.status === 'firing').slice(0, 8);
return ( return (
<Card className="bg-slate-900 border-slate-800"> <Card className="bg-slate-900 border-slate-800">
@@ -246,7 +268,7 @@ function AlertMessagesCard() {
}`} /> }`} />
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-200 truncate">{alert.ruleName}</span> <span className="text-sm font-medium text-slate-200 truncate">{alert.rule_name}</span>
<Badge <Badge
variant="outline" variant="outline"
className={`text-xs ${ className={`text-xs ${
@@ -263,7 +285,9 @@ function AlertMessagesCard() {
<p className="text-xs text-slate-500 mt-1">{alert.target}</p> <p className="text-xs text-slate-500 mt-1">{alert.target}</p>
<p className="text-xs text-slate-500">{alert.message}</p> <p className="text-xs text-slate-500">{alert.message}</p>
</div> </div>
<span className="text-xs text-slate-500 whitespace-nowrap">{alert.startedAt}</span> <span className="text-xs text-slate-500 whitespace-nowrap">
{alert.created_at ? new Date(alert.created_at).toLocaleString() : ''}
</span>
</div> </div>
))} ))}
</div> </div>
@@ -274,18 +298,53 @@ function AlertMessagesCard() {
} }
// Top5 资源使用卡片 // Top5 资源使用卡片
function TopResourceUsageCard() { function TopResourceUsageCard({ topUsage }: { topUsage: any }) {
const topCpuServers = [...servers] const topCpuServers = (topUsage?.cpu ?? []).slice(0, 5);
.sort((a, b) => b.cpuUsage - a.cpuUsage) const topMemoryServers = (topUsage?.memory ?? []).slice(0, 5);
.slice(0, 5); const topDiskServers = (topUsage?.disk ?? []).slice(0, 5);
const topMemoryServers = [...servers] const renderList = (
.sort((a, b) => b.memoryUsage - a.memoryUsage) items: any[],
.slice(0, 5); color: string,
BarColor: string,
const topDiskServers = [...servers] icon: React.ReactNode,
.sort((a, b) => b.diskUsage - a.diskUsage) label: string
.slice(0, 5); ) => (
<div>
<div className="flex items-center gap-2 mb-3">
{icon}
<span className="text-sm font-medium text-slate-300">{label}</span>
</div>
<div className="space-y-2">
{items.map((item, index) => {
const usage = Number(item.usage ?? 0);
return (
<div key={item.id ?? index} className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-4">{index + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-slate-300 truncate">{item.hostname}</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
usage > 80 ? 'bg-red-400' : usage > 60 ? 'bg-amber-400' : BarColor
}`}
style={{ width: `${usage}%` }}
/>
</div>
<span className={`text-xs ${
usage > 80 ? 'text-red-400' : usage > 60 ? 'text-amber-400' : 'text-slate-400'
}`}>
{usage.toFixed(1)}%
</span>
</div>
</div>
</div>
);
})}
</div>
</div>
);
return ( return (
<Card className="bg-slate-900 border-slate-800"> <Card className="bg-slate-900 border-slate-800">
@@ -300,103 +359,11 @@ function TopResourceUsageCard() {
<CardContent> <CardContent>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-3 gap-4">
{/* CPU Top5 */} {/* CPU Top5 */}
<div> {renderList(topCpuServers, 'blue', 'bg-blue-400', <Cpu className="h-4 w-4 text-blue-400" />, 'CPU')}
<div className="flex items-center gap-2 mb-3">
<Cpu className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-300">CPU</span>
</div>
<div className="space-y-2">
{topCpuServers.map((server, index) => (
<div key={server.id} className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-4">{index + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-slate-300 truncate">{server.hostname}</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
server.cpuUsage > 80 ? 'bg-red-400' : server.cpuUsage > 60 ? 'bg-amber-400' : 'bg-blue-400'
}`}
style={{ width: `${server.cpuUsage}%` }}
/>
</div>
<span className={`text-xs ${
server.cpuUsage > 80 ? 'text-red-400' : server.cpuUsage > 60 ? 'text-amber-400' : 'text-slate-400'
}`}>
{server.cpuUsage.toFixed(1)}%
</span>
</div>
</div>
</div>
))}
</div>
</div>
{/* Memory Top5 */} {/* Memory Top5 */}
<div> {renderList(topMemoryServers, 'emerald', 'bg-emerald-400', <MemoryStick className="h-4 w-4 text-emerald-400" />, '内存')}
<div className="flex items-center gap-2 mb-3">
<MemoryStick className="h-4 w-4 text-emerald-400" />
<span className="text-sm font-medium text-slate-300">内存</span>
</div>
<div className="space-y-2">
{topMemoryServers.map((server, index) => (
<div key={server.id} className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-4">{index + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-slate-300 truncate">{server.hostname}</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
server.memoryUsage > 80 ? 'bg-red-400' : server.memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
}`}
style={{ width: `${server.memoryUsage}%` }}
/>
</div>
<span className={`text-xs ${
server.memoryUsage > 80 ? 'text-red-400' : server.memoryUsage > 60 ? 'text-amber-400' : 'text-slate-400'
}`}>
{server.memoryUsage.toFixed(1)}%
</span>
</div>
</div>
</div>
))}
</div>
</div>
{/* Disk Top5 */} {/* Disk Top5 */}
<div> {renderList(topDiskServers, 'purple', 'bg-purple-400', <Database className="h-4 w-4 text-purple-400" />, '磁盘')}
<div className="flex items-center gap-2 mb-3">
<Database className="h-4 w-4 text-purple-400" />
<span className="text-sm font-medium text-slate-300">磁盘</span>
</div>
<div className="space-y-2">
{topDiskServers.map((server, index) => (
<div key={server.id} className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-4">{index + 1}</span>
<div className="flex-1 min-w-0">
<p className="text-xs text-slate-300 truncate">{server.hostname}</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-1.5 bg-slate-700 rounded-full overflow-hidden">
<div
className={`h-full rounded-full ${
server.diskUsage > 80 ? 'bg-red-400' : server.diskUsage > 60 ? 'bg-amber-400' : 'bg-purple-400'
}`}
style={{ width: `${server.diskUsage}%` }}
/>
</div>
<span className={`text-xs ${
server.diskUsage > 80 ? 'text-red-400' : server.diskUsage > 60 ? 'text-amber-400' : 'text-slate-400'
}`}>
{server.diskUsage.toFixed(1)}%
</span>
</div>
</div>
</div>
))}
</div>
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -404,17 +371,16 @@ function TopResourceUsageCard() {
} }
// 证书到期卡片 // 证书到期卡片
function CertificateExpiryCard() { function CertificateExpiryCard({ expCerts }: { expCerts: any[] }) {
const expiringDomains = domains const expiringDomains = (expCerts ?? [])
.filter(d => d.sslEnabled) .map((d) => ({
.map(d => ({
...d, ...d,
daysUntilExpiry: Math.ceil((new Date(d.sslExpireDate!).getTime() - Date.now()) / (1000 * 60 * 60 * 24)) daysUntilExpiry: d.days_until_expiry ?? Math.ceil((new Date(d.expire_date).getTime() - Date.now()) / (1000 * 60 * 60 * 24)),
})) }))
.sort((a, b) => a.daysUntilExpiry - b.daysUntilExpiry); .sort((a, b) => a.daysUntilExpiry - b.daysUntilExpiry);
const criticalCount = expiringDomains.filter(d => d.daysUntilExpiry < 30).length; const criticalCount = expiringDomains.filter((d) => d.daysUntilExpiry < 30).length;
const warningCount = expiringDomains.filter(d => d.daysUntilExpiry >= 30 && d.daysUntilExpiry < 60).length; const warningCount = expiringDomains.filter((d) => d.daysUntilExpiry >= 30 && d.daysUntilExpiry < 60).length;
return ( return (
<Card className="bg-slate-900 border-slate-800"> <Card className="bg-slate-900 border-slate-800">
@@ -457,7 +423,7 @@ function CertificateExpiryCard() {
</div> </div>
<div> <div>
<p className="text-sm font-medium text-slate-200">{domain.name}</p> <p className="text-sm font-medium text-slate-200">{domain.name}</p>
<p className="text-xs text-slate-500">到期: {domain.sslExpireDate}</p> <p className="text-xs text-slate-500">到期: {domain.expire_date}</p>
</div> </div>
</div> </div>
<Badge <Badge
@@ -482,8 +448,16 @@ function CertificateExpiryCard() {
} }
// 资源使用率趋势卡片 // 资源使用率趋势卡片
function ResourceUsageCard() { function ResourceUsageCard({
const data = generateMetricsData(24); avgCpu,
avgMemory,
avgDisk,
}: {
avgCpu: number;
avgMemory: number;
avgDisk: number;
}) {
const data = generateMetricsData(avgCpu, avgMemory, avgDisk, 24);
return ( return (
<Card className="bg-slate-900 border-slate-800"> <Card className="bg-slate-900 border-slate-800">
@@ -574,7 +548,7 @@ function ResourceUsageCard() {
} }
// 发布历史卡片 // 发布历史卡片
function ReleaseHistoryCard() { function ReleaseHistoryCard({ releases }: { releases: any[] }) {
return ( return (
<Card className="bg-slate-900 border-slate-800"> <Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
@@ -591,7 +565,7 @@ function ReleaseHistoryCard() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-2">
{appReleases.slice(0, 5).map((release) => ( {releases.slice(0, 5).map((release) => (
<div <div
key={release.id} key={release.id}
className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50 hover:bg-slate-800 transition-colors" className="flex items-center gap-3 p-3 rounded-lg bg-slate-800/50 hover:bg-slate-800 transition-colors"
@@ -611,7 +585,7 @@ function ReleaseHistoryCard() {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm font-medium text-slate-200">{release.appName}</span> <span className="text-sm font-medium text-slate-200">{release.app_name}</span>
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400"> <Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
{release.version} {release.version}
</Badge> </Badge>
@@ -619,7 +593,7 @@ function ReleaseHistoryCard() {
<div className="flex items-center gap-2 mt-0.5"> <div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-slate-500">{release.environment}</span> <span className="text-xs text-slate-500">{release.environment}</span>
<span className="text-xs text-slate-500">·</span> <span className="text-xs text-slate-500">·</span>
<span className="text-xs text-slate-500">{release.commitId}</span> <span className="text-xs text-slate-500">{release.commit_id}</span>
</div> </div>
</div> </div>
<Badge <Badge
@@ -653,7 +627,7 @@ function ReleaseHistoryCard() {
} }
// 服务器状态卡片 // 服务器状态卡片
function ServerStatusCard() { function ServerStatusCard({ servers }: { servers: any[] }) {
const runningServers = servers.filter((s) => s.status === 'running').length; const runningServers = servers.filter((s) => s.status === 'running').length;
const stoppedServers = servers.filter((s) => s.status === 'stopped').length; const stoppedServers = servers.filter((s) => s.status === 'stopped').length;
const maintenanceServers = servers.filter((s) => s.status === 'maintenance').length; const maintenanceServers = servers.filter((s) => s.status === 'maintenance').length;
@@ -704,8 +678,8 @@ function ServerStatusCard() {
<p className="text-xs text-slate-500">{server.ip}</p> <p className="text-xs text-slate-500">{server.ip}</p>
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="text-xs text-slate-400">CPU {server.cpuUsage.toFixed(1)}%</p> <p className="text-xs text-slate-400">CPU {Number(server.cpu_usage ?? 0).toFixed(1)}%</p>
<p className="text-xs text-slate-400">内存 {server.memoryUsage.toFixed(1)}%</p> <p className="text-xs text-slate-400">内存 {Number(server.memory_usage ?? 0).toFixed(1)}%</p>
</div> </div>
</div> </div>
))} ))}
@@ -716,7 +690,7 @@ function ServerStatusCard() {
} }
// K8s集群状态卡片 // K8s集群状态卡片
function K8sClusterCard() { function K8sClusterCard({ clusters }: { clusters: any[] }) {
return ( return (
<Card className="bg-slate-900 border-slate-800"> <Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2"> <CardHeader className="pb-2">
@@ -733,7 +707,10 @@ function K8sClusterCard() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
{k8sClusters.map((cluster) => ( {clusters.map((cluster) => {
const cpuUsage = Number(cluster.cpu_usage ?? 0);
const memoryUsage = Number(cluster.memory_usage ?? 0);
return (
<div key={cluster.id} className="p-3 rounded-lg bg-slate-800/50"> <div key={cluster.id} className="p-3 rounded-lg bg-slate-800/50">
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -755,24 +732,25 @@ function K8sClusterCard() {
</div> </div>
<div className="grid grid-cols-4 gap-2 text-center"> <div className="grid grid-cols-4 gap-2 text-center">
<div> <div>
<div className="text-sm font-medium text-slate-200">{cluster.nodes}</div> <div className="text-sm font-medium text-slate-200">{cluster.node_count ?? cluster.nodes ?? 0}</div>
<div className="text-xs text-slate-500">节点</div> <div className="text-xs text-slate-500">节点</div>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-slate-200">{cluster.pods}</div> <div className="text-sm font-medium text-slate-200">{cluster.pod_count ?? cluster.pods ?? 0}</div>
<div className="text-xs text-slate-500">Pod</div> <div className="text-xs text-slate-500">Pod</div>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-slate-200">{cluster.cpuUsage.toFixed(1)}%</div> <div className="text-sm font-medium text-slate-200">{cpuUsage.toFixed(1)}%</div>
<div className="text-xs text-slate-500">CPU</div> <div className="text-xs text-slate-500">CPU</div>
</div> </div>
<div> <div>
<div className="text-sm font-medium text-slate-200">{cluster.memoryUsage.toFixed(1)}%</div> <div className="text-sm font-medium text-slate-200">{memoryUsage.toFixed(1)}%</div>
<div className="text-xs text-slate-500">内存</div> <div className="text-xs text-slate-500">内存</div>
</div> </div>
</div> </div>
</div> </div>
))} );
})}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -780,21 +758,69 @@ function K8sClusterCard() {
} }
export function Dashboard() { export function Dashboard() {
const [data, setData] = useState<DashboardData>(dashboardData); const [loading, setLoading] = useState(true);
const [stats, setStats] = useState<any>(null);
const [servers, setServers] = useState<any[]>([]);
const [clusters, setClusters] = useState<any[]>([]);
const [alerts, setAlerts] = useState<any[]>([]);
const [releases, setReleases] = useState<any[]>([]);
const [expCerts, setExpCerts] = useState<any[]>([]);
// 模拟实时数据更新
useEffect(() => { useEffect(() => {
const interval = setInterval(() => { const fetchData = async () => {
setData((prev) => ({ setLoading(true);
...prev, try {
cpuUsage: 60 + Math.random() * 20, const [statsRes, serversRes, clustersRes, alertsRes, releasesRes] = await Promise.all([
memoryUsage: 70 + Math.random() * 15, dashboardStatsApi.getStats().catch((e) => {
networkUsage: 30 + Math.random() * 30, console.error('Failed to fetch dashboard stats:', e);
})); return null;
}, 5000); }),
return () => clearInterval(interval); serverApi.getServers({ page: 1, page_size: 100 }).catch((e) => {
console.error('Failed to fetch servers:', e);
return null;
}),
k8sApi.getClusters().catch((e) => {
console.error('Failed to fetch k8s clusters:', e);
return null;
}),
dashboardStatsApi.getAlerts(10).catch((e) => {
console.error('Failed to fetch dashboard alerts:', e);
return null;
}),
dashboardStatsApi.getReleases(5).catch((e) => {
console.error('Failed to fetch dashboard releases:', e);
return null;
}),
]);
setStats(statsRes);
setServers(serversRes?.list ?? []);
setClusters(clustersRes?.list ?? []);
setAlerts(Array.isArray(alertsRes) ? alertsRes : []);
setReleases(Array.isArray(releasesRes) ? releasesRes : []);
setExpCerts(statsRes?.expiring_certs ?? []);
} catch (error) {
console.error('Failed to load dashboard data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []); }, []);
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<div className="h-8 w-8 border-2 border-slate-700 border-t-blue-500 rounded-full animate-spin" />
</div>
);
}
const serverStats = stats?.server_stats;
const k8sStats = stats?.k8s_stats;
const alertStats = stats?.alert_stats;
const costStats = stats?.cost_stats;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Page Header */} {/* Page Header */}
@@ -819,8 +845,8 @@ export function Dashboard() {
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard <StatCard
title="服务器总数" title="服务器总数"
value={data.serverCount} value={serverStats?.total ?? 0}
description={`${data.runningServers} 台运行中`} description={`${serverStats?.running ?? 0} 台运行中`}
icon={Server} icon={Server}
trend="up" trend="up"
trendValue="+3 本月" trendValue="+3 本月"
@@ -828,8 +854,8 @@ export function Dashboard() {
/> />
<StatCard <StatCard
title="K8s集群" title="K8s集群"
value={data.k8sClusters} value={k8sStats?.total ?? 0}
description={`${data.healthyClusters} 个健康`} description={`${k8sStats?.healthy ?? 0} 个健康`}
icon={Cloud} icon={Cloud}
trend="neutral" trend="neutral"
trendValue="稳定" trendValue="稳定"
@@ -837,8 +863,8 @@ export function Dashboard() {
/> />
<StatCard <StatCard
title="待处理告警" title="待处理告警"
value={data.alertCount} value={alertStats?.firing ?? 0}
description={`${data.criticalAlerts} 个严重`} description={`${alertStats?.critical ?? 0} 个严重`}
icon={Bell} icon={Bell}
trend="down" trend="down"
trendValue="-5 本周" trendValue="-5 本周"
@@ -846,7 +872,7 @@ export function Dashboard() {
/> />
<StatCard <StatCard
title="本月云成本" title="本月云成本"
value="¥107,700" value={`¥${Number(costStats?.month_cost ?? 0).toLocaleString()}`}
description="较上月 +12.5%" description="较上月 +12.5%"
icon={DollarSign} icon={DollarSign}
trend="up" trend="up"
@@ -856,25 +882,29 @@ export function Dashboard() {
</div> </div>
{/* Resource Usage Chart */} {/* Resource Usage Chart */}
<ResourceUsageCard /> <ResourceUsageCard
avgCpu={Number(serverStats?.avg_cpu ?? 0)}
avgMemory={Number(serverStats?.avg_memory ?? 0)}
avgDisk={Number(serverStats?.avg_disk ?? 0)}
/>
{/* Cost & Top Resource Usage */} {/* Cost & Top Resource Usage */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<CostOverviewCard /> <CostOverviewCard costStats={costStats} />
<TopResourceUsageCard /> <TopResourceUsageCard topUsage={stats?.top_resource_usage} />
</div> </div>
{/* Alert Messages & Certificate Expiry */} {/* Alert Messages & Certificate Expiry */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<AlertMessagesCard /> <AlertMessagesCard alerts={alerts} />
<CertificateExpiryCard /> <CertificateExpiryCard expCerts={expCerts} />
</div> </div>
{/* Bottom Row */} {/* Bottom Row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<ReleaseHistoryCard /> <ReleaseHistoryCard releases={releases} />
<ServerStatusCard /> <ServerStatusCard servers={servers} />
<K8sClusterCard /> <K8sClusterCard clusters={clusters} />
</div> </div>
</div> </div>
); );
+71 -31
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -52,25 +52,42 @@ import {
ArrowRight, ArrowRight,
Terminal, Terminal,
} from 'lucide-react'; } from 'lucide-react';
import { appReleases } from '@/mocks/data'; import { releaseApi } from '@/services/api';
import type { AppRelease } from '@/types';
export function AppReleaseManager() { export function AppReleaseManager() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [envFilter, setEnvFilter] = useState<string>('all'); const [envFilter, setEnvFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusFilter, setStatusFilter] = useState<string>('all');
const [selectedRelease, setSelectedRelease] = useState<AppRelease | null>(null); const [releases, setReleases] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedRelease, setSelectedRelease] = useState<any | null>(null);
const filteredReleases = appReleases.filter((release) => { const loadData = async () => {
try {
setLoading(true);
const data = await releaseApi.getReleases();
setReleases(data.list || []);
} catch (error) {
console.error('Failed to load releases:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const filteredReleases = releases.filter((release) => {
const matchesSearch = const matchesSearch =
release.appName.toLowerCase().includes(searchQuery.toLowerCase()) || (release.app_name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
release.version.includes(searchQuery); (release.version || '').includes(searchQuery);
const matchesEnv = envFilter === 'all' || release.environment === envFilter; const matchesEnv = envFilter === 'all' || release.environment === envFilter;
const matchesStatus = statusFilter === 'all' || release.status === statusFilter; const matchesStatus = statusFilter === 'all' || release.status === statusFilter;
return matchesSearch && matchesEnv && matchesStatus; return matchesSearch && matchesEnv && matchesStatus;
}); });
const getStatusBadge = (status: AppRelease['status']) => { const getStatusBadge = (status: any) => {
switch (status) { switch (status) {
case 'success': case 'success':
return ( return (
@@ -119,26 +136,35 @@ export function AppReleaseManager() {
} }
}; };
const getEnvBadge = (env: AppRelease['environment']) => { const getEnvBadge = (env: any) => {
const colors = { const colors: Record<string, string> = {
dev: 'border-slate-500/30 text-slate-400 bg-slate-500/10', dev: 'border-slate-500/30 text-slate-400 bg-slate-500/10',
test: 'border-blue-500/30 text-blue-400 bg-blue-500/10', test: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
staging: 'border-amber-500/30 text-amber-400 bg-amber-500/10', staging: 'border-amber-500/30 text-amber-400 bg-amber-500/10',
prod: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10', prod: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10',
}; };
const labels = { const labels: Record<string, string> = {
dev: '开发环境', dev: '开发环境',
test: '测试环境', test: '测试环境',
staging: '预发布', staging: '预发布',
prod: '生产环境', prod: '生产环境',
}; };
return ( return (
<Badge variant="outline" className={colors[env]}> <Badge variant="outline" className={colors[env] || colors.dev}>
{labels[env]} {labels[env] || env}
</Badge> </Badge>
); );
}; };
const handleRollback = async (id: any) => {
try {
await releaseApi.rollbackRelease(id);
loadData();
} catch (error) {
console.error('Failed to rollback release:', error);
}
};
// 模拟流水线步骤 // 模拟流水线步骤
const pipelineSteps = [ const pipelineSteps = [
{ name: '代码检出', status: 'success', duration: '15s' }, { name: '代码检出', status: 'success', duration: '15s' },
@@ -178,7 +204,7 @@ export function AppReleaseManager() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-slate-500">今日发布</p> <p className="text-sm text-slate-500">今日发布</p>
<p className="text-2xl font-bold text-slate-100">12</p> <p className="text-2xl font-bold text-slate-100">{releases.length}</p>
</div> </div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400"> <div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Rocket className="h-5 w-5" /> <Rocket className="h-5 w-5" />
@@ -191,7 +217,9 @@ export function AppReleaseManager() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-slate-500">发布成功</p> <p className="text-sm text-slate-500">发布成功</p>
<p className="text-2xl font-bold text-emerald-400">10</p> <p className="text-2xl font-bold text-emerald-400">
{releases.filter((r) => r.status === 'success').length}
</p>
</div> </div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400"> <div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
<CheckCircle className="h-5 w-5" /> <CheckCircle className="h-5 w-5" />
@@ -204,7 +232,9 @@ export function AppReleaseManager() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-slate-500">发布失败</p> <p className="text-sm text-slate-500">发布失败</p>
<p className="text-2xl font-bold text-red-400">1</p> <p className="text-2xl font-bold text-red-400">
{releases.filter((r) => r.status === 'failed').length}
</p>
</div> </div>
<div className="p-3 rounded-lg bg-red-500/10 text-red-400"> <div className="p-3 rounded-lg bg-red-500/10 text-red-400">
<XCircle className="h-5 w-5" /> <XCircle className="h-5 w-5" />
@@ -217,7 +247,11 @@ export function AppReleaseManager() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-slate-500">成功率</p> <p className="text-sm text-slate-500">成功率</p>
<p className="text-2xl font-bold text-blue-400">83.3%</p> <p className="text-2xl font-bold text-blue-400">
{releases.length > 0
? `${Math.round((releases.filter((r) => r.status === 'success').length / releases.length) * 100)}%`
: '0%'}
</p>
</div> </div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400"> <div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<History className="h-5 w-5" /> <History className="h-5 w-5" />
@@ -278,17 +312,23 @@ export function AppReleaseManager() {
<TableHead className="text-slate-400">代码</TableHead> <TableHead className="text-slate-400">代码</TableHead>
<TableHead className="text-slate-400">发布人</TableHead> <TableHead className="text-slate-400">发布人</TableHead>
<TableHead className="text-slate-400">开始时间</TableHead> <TableHead className="text-slate-400">开始时间</TableHead>
<TableHead className="text-slate-400">耗时</TableHead>
<TableHead className="text-slate-400 text-right">操作</TableHead> <TableHead className="text-slate-400 text-right">操作</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredReleases.map((release) => ( {loading ? (
<TableRow className="border-slate-800">
<TableCell colSpan={8} className="text-center text-slate-500 py-8">
<Loader2 className="h-5 w-5 animate-spin inline mr-2" />
加载中...
</TableCell>
</TableRow>
) : filteredReleases.map((release) => (
<TableRow key={release.id} className="border-slate-800 hover:bg-slate-800/50"> <TableRow key={release.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Rocket className="h-4 w-4 text-slate-500" /> <Rocket className="h-4 w-4 text-slate-500" />
<span className="text-sm font-medium text-slate-200">{release.appName}</span> <span className="text-sm font-medium text-slate-200">{release.app_name}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -304,7 +344,7 @@ export function AppReleaseManager() {
<span>{release.branch}</span> <span>{release.branch}</span>
<span className="mx-1">·</span> <span className="mx-1">·</span>
<GitCommit className="h-3 w-3" /> <GitCommit className="h-3 w-3" />
<span>{release.commitId}</span> <span>{release.commit_id}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -312,12 +352,7 @@ export function AppReleaseManager() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="text-sm text-slate-400"> <span className="text-sm text-slate-400">
{new Date(release.startedAt).toLocaleString()} {release.created_at ? new Date(release.created_at).toLocaleString() : '--'}
</span>
</TableCell>
<TableCell>
<span className="text-sm text-slate-400">
{release.duration ? `${Math.floor(release.duration / 60)}m ${release.duration % 60}s` : '--'}
</span> </span>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
@@ -338,7 +373,12 @@ export function AppReleaseManager() {
</Button> </Button>
)} )}
{release.status === 'success' && ( {release.status === 'success' && (
<Button variant="ghost" size="sm" className="h-8 text-slate-400 hover:text-amber-400"> <Button
variant="ghost"
size="sm"
className="h-8 text-slate-400 hover:text-amber-400"
onClick={() => handleRollback(release.id)}
>
<RotateCcw className="h-4 w-4 mr-1" /> <RotateCcw className="h-4 w-4 mr-1" />
回滚 回滚
</Button> </Button>
@@ -359,13 +399,13 @@ export function AppReleaseManager() {
<DialogHeader> <DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2"> <DialogTitle className="text-slate-100 flex items-center gap-2">
<Rocket className="h-5 w-5 text-blue-400" /> <Rocket className="h-5 w-5 text-blue-400" />
{selectedRelease?.appName} {selectedRelease?.app_name}
<Badge variant="outline" className="border-slate-700 text-slate-400"> <Badge variant="outline" className="border-slate-700 text-slate-400">
{selectedRelease?.version} {selectedRelease?.version}
</Badge> </Badge>
</DialogTitle> </DialogTitle>
<DialogDescription className="text-slate-500"> <DialogDescription className="text-slate-500">
流水线: {selectedRelease?.pipeline} 环境: {selectedRelease?.environment}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -412,7 +452,7 @@ export function AppReleaseManager() {
<h4 className="text-sm font-medium text-slate-300">构建日志</h4> <h4 className="text-sm font-medium text-slate-300">构建日志</h4>
<div className="bg-slate-950 rounded-lg p-4 font-mono text-xs text-slate-400 h-48 overflow-auto"> <div className="bg-slate-950 rounded-lg p-4 font-mono text-xs text-slate-400 h-48 overflow-auto">
<div className="space-y-1"> <div className="space-y-1">
<p>[10:00:00] Starting pipeline for {selectedRelease?.appName}...</p> <p>[10:00:00] Starting pipeline for {selectedRelease?.app_name}...</p>
<p>[10:00:15] Checkout code from branch {selectedRelease?.branch}</p> <p>[10:00:15] Checkout code from branch {selectedRelease?.branch}</p>
<p>[10:00:30] Installing dependencies...</p> <p>[10:00:30] Installing dependencies...</p>
<p className="text-emerald-400">[10:01:15] Dependencies installed successfully</p> <p className="text-emerald-400">[10:01:15] Dependencies installed successfully</p>
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -56,7 +56,7 @@ import {
FileCode, FileCode,
Workflow, Workflow,
} from 'lucide-react'; } from 'lucide-react';
import { pipelines, pipelineExecutions, releases } from '@/mocks/data'; import { pipelineApi } from '@/services/api';
interface PipelineStage { interface PipelineStage {
id: string; id: string;
@@ -66,74 +66,6 @@ interface PipelineStage {
logs: string[]; logs: string[];
} }
interface Pipeline {
id: string;
name: string;
description: string;
repository: string;
branch: string;
stages: PipelineStage[];
lastRun: string;
lastStatus: 'success' | 'failed' | 'running' | 'never';
triggerType: 'manual' | 'auto';
createdAt: string;
}
const mockPipelines: Pipeline[] = [
{
id: 'pipe-001',
name: 'frontend-build-and-deploy',
description: '前端项目构建与部署',
repository: 'https://gitlab.company.com/frontend/app.git',
branch: 'main',
stages: [
{ id: 'stage-1', name: '代码检出', status: 'success', duration: '15s', logs: ['检出代码...', '代码检出完成'] },
{ id: 'stage-2', name: '依赖安装', status: 'success', duration: '45s', logs: ['安装依赖...', '依赖安装完成'] },
{ id: 'stage-3', name: '代码检查', status: 'success', duration: '30s', logs: ['执行 ESLint...', '代码检查通过'] },
{ id: 'stage-4', name: '构建打包', status: 'success', duration: '2m 15s', logs: ['开始构建...', '构建完成'] },
{ id: 'stage-5', name: '部署到测试环境', status: 'success', duration: '30s', logs: ['部署中...', '部署完成'] },
],
lastRun: '2025-04-14 10:30:00',
lastStatus: 'success',
triggerType: 'auto',
createdAt: '2025-01-15',
},
{
id: 'pipe-002',
name: 'backend-api-deploy',
description: '后端API服务部署',
repository: 'https://gitlab.company.com/backend/api.git',
branch: 'develop',
stages: [
{ id: 'stage-1', name: '代码检出', status: 'success', duration: '12s', logs: [] },
{ id: 'stage-2', name: '单元测试', status: 'success', duration: '1m 30s', logs: [] },
{ id: 'stage-3', name: '构建镜像', status: 'running', duration: '45s', logs: ['构建Docker镜像...'] },
{ id: 'stage-4', name: '推送镜像', status: 'pending', duration: '-', logs: [] },
{ id: 'stage-5', name: '部署到K8s', status: 'pending', duration: '-', logs: [] },
],
lastRun: '2025-04-14 11:15:00',
lastStatus: 'running',
triggerType: 'manual',
createdAt: '2025-02-01',
},
{
id: 'pipe-003',
name: 'database-migration',
description: '数据库迁移脚本执行',
repository: 'https://gitlab.company.com/database/migrations.git',
branch: 'main',
stages: [
{ id: 'stage-1', name: '代码检出', status: 'success', duration: '10s', logs: [] },
{ id: 'stage-2', name: '数据库备份', status: 'success', duration: '2m 00s', logs: [] },
{ id: 'stage-3', name: '执行迁移', status: 'failed', duration: '15s', logs: ['迁移失败: 表已存在'] },
],
lastRun: '2025-04-14 09:00:00',
lastStatus: 'failed',
triggerType: 'manual',
createdAt: '2025-01-20',
},
];
const pipelineTemplates = [ const pipelineTemplates = [
{ id: 'template-1', name: 'Node.js 项目', description: '适用于 Node.js 前端项目', stages: ['代码检出', '依赖安装', '代码检查', '构建打包', '部署'] }, { id: 'template-1', name: 'Node.js 项目', description: '适用于 Node.js 前端项目', stages: ['代码检出', '依赖安装', '代码检查', '构建打包', '部署'] },
{ id: 'template-2', name: 'Go 项目', description: '适用于 Go 后端项目', stages: ['代码检出', '单元测试', '构建', '构建镜像', '部署'] }, { id: 'template-2', name: 'Go 项目', description: '适用于 Go 后端项目', stages: ['代码检出', '单元测试', '构建', '构建镜像', '部署'] },
@@ -141,9 +73,24 @@ const pipelineTemplates = [
{ id: 'template-4', name: 'Python 项目', description: '适用于 Python 项目', stages: ['代码检出', '依赖安装', '代码检查', '测试', '部署'] }, { id: 'template-4', name: 'Python 项目', description: '适用于 Python 项目', stages: ['代码检出', '依赖安装', '代码检查', '测试', '部署'] },
]; ];
function parseStages(stages: any): PipelineStage[] {
if (Array.isArray(stages)) return stages;
if (typeof stages === 'string') {
try {
const parsed = JSON.parse(stages);
if (Array.isArray(parsed)) return parsed;
} catch {
return stages.split(',').map((s, i) => ({ id: `stage-${i + 1}`, name: s.trim(), status: 'success' as const, duration: '-', logs: [] }));
}
}
return [];
}
export function PipelineManager() { export function PipelineManager() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedPipeline, setSelectedPipeline] = useState<Pipeline | null>(null); const [pipelines, setPipelines] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedPipeline, setSelectedPipeline] = useState<any | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false); const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false);
const [isRunDialogOpen, setIsRunDialogOpen] = useState(false); const [isRunDialogOpen, setIsRunDialogOpen] = useState(false);
@@ -156,13 +103,29 @@ export function PipelineManager() {
template: '', template: '',
}); });
const filteredPipelines = mockPipelines.filter( const loadData = async () => {
try {
setLoading(true);
const data = await pipelineApi.getPipelines();
setPipelines(data.list || []);
} catch (error) {
console.error('Failed to load pipelines:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const filteredPipelines = pipelines.filter(
(p) => (p) =>
p.name.toLowerCase().includes(searchQuery.toLowerCase()) || (p.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
p.description.toLowerCase().includes(searchQuery.toLowerCase()) (p.description || '').toLowerCase().includes(searchQuery.toLowerCase())
); );
const getStatusBadge = (status: Pipeline['lastStatus']) => { const getStatusBadge = (status: any) => {
switch (status) { switch (status) {
case 'success': case 'success':
return ( return (
@@ -209,14 +172,40 @@ export function PipelineManager() {
} }
}; };
const handleCreatePipeline = () => { const handleCreatePipeline = async () => {
// Mock create try {
await pipelineApi.createPipeline({
name: newPipeline.name,
description: newPipeline.description,
repository: newPipeline.repository,
branch: newPipeline.branch,
});
setIsCreateDialogOpen(false); setIsCreateDialogOpen(false);
setNewPipeline({ name: '', description: '', repository: '', branch: 'main', template: '' }); setNewPipeline({ name: '', description: '', repository: '', branch: 'main', template: '' });
loadData();
} catch (error) {
console.error('Failed to create pipeline:', error);
}
}; };
const handleRunPipeline = () => { const handleDeletePipeline = async (id: any) => {
try {
await pipelineApi.deletePipeline(id);
loadData();
} catch (error) {
console.error('Failed to delete pipeline:', error);
}
};
const handleRunPipeline = async () => {
if (!selectedPipeline) return;
try {
await pipelineApi.triggerPipeline(selectedPipeline.id);
setIsRunDialogOpen(false); setIsRunDialogOpen(false);
loadData();
} catch (error) {
console.error('Failed to trigger pipeline:', error);
}
}; };
return ( return (
@@ -246,7 +235,7 @@ export function PipelineManager() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-slate-400 text-sm">流水线总数</p> <p className="text-slate-400 text-sm">流水线总数</p>
<p className="text-2xl font-bold text-slate-100">{mockPipelines.length}</p> <p className="text-2xl font-bold text-slate-100">{pipelines.length}</p>
</div> </div>
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
<Workflow className="h-5 w-5 text-blue-400" /> <Workflow className="h-5 w-5 text-blue-400" />
@@ -260,7 +249,7 @@ export function PipelineManager() {
<div> <div>
<p className="text-slate-400 text-sm">运行中</p> <p className="text-slate-400 text-sm">运行中</p>
<p className="text-2xl font-bold text-blue-400"> <p className="text-2xl font-bold text-blue-400">
{mockPipelines.filter((p) => p.lastStatus === 'running').length} {pipelines.filter((p) => p.status === 'running').length}
</p> </p>
</div> </div>
<div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-lg bg-blue-500/20 flex items-center justify-center">
@@ -274,7 +263,9 @@ export function PipelineManager() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-slate-400 text-sm">今日成功</p> <p className="text-slate-400 text-sm">今日成功</p>
<p className="text-2xl font-bold text-emerald-400">12</p> <p className="text-2xl font-bold text-emerald-400">
{pipelines.filter((p) => p.status === 'success').length}
</p>
</div> </div>
<div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-lg bg-emerald-500/20 flex items-center justify-center">
<CheckCircle className="h-5 w-5 text-emerald-400" /> <CheckCircle className="h-5 w-5 text-emerald-400" />
@@ -287,7 +278,9 @@ export function PipelineManager() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-slate-400 text-sm">今日失败</p> <p className="text-slate-400 text-sm">今日失败</p>
<p className="text-2xl font-bold text-red-400">2</p> <p className="text-2xl font-bold text-red-400">
{pipelines.filter((p) => p.status === 'failed').length}
</p>
</div> </div>
<div className="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center"> <div className="w-10 h-10 rounded-lg bg-red-500/20 flex items-center justify-center">
<XCircle className="h-5 w-5 text-red-400" /> <XCircle className="h-5 w-5 text-red-400" />
@@ -310,6 +303,14 @@ export function PipelineManager() {
</div> </div>
</div> </div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
</div>
) : filteredPipelines.length === 0 ? (
<div className="text-center text-slate-500 py-12">暂无流水线数据</div>
) : null}
{/* Pipeline List */} {/* Pipeline List */}
<div className="space-y-4"> <div className="space-y-4">
{filteredPipelines.map((pipeline) => ( {filteredPipelines.map((pipeline) => (
@@ -319,9 +320,9 @@ export function PipelineManager() {
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h3 className="font-medium text-slate-100">{pipeline.name}</h3> <h3 className="font-medium text-slate-100">{pipeline.name}</h3>
{getStatusBadge(pipeline.lastStatus)} {getStatusBadge(pipeline.status)}
<Badge variant="outline" className="border-slate-700 text-slate-400"> <Badge variant="outline" className="border-slate-700 text-slate-400">
{pipeline.triggerType === 'auto' ? '自动触发' : '手动触发'} {pipeline.trigger_type === 'auto' ? '自动触发' : '手动触发'}
</Badge> </Badge>
</div> </div>
<p className="text-sm text-slate-400 mt-1">{pipeline.description}</p> <p className="text-sm text-slate-400 mt-1">{pipeline.description}</p>
@@ -332,15 +333,15 @@ export function PipelineManager() {
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Clock className="h-3 w-3" /> <Clock className="h-3 w-3" />
上次运行: {pipeline.lastRun} 上次运行: {pipeline.updated_at || '--'}
</span> </span>
</div> </div>
{/* Pipeline Stages */} {/* Pipeline Stages */}
<div className="mt-4"> <div className="mt-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{pipeline.stages.map((stage, index) => ( {parseStages(pipeline.stages).map((stage: PipelineStage, index: number) => (
<div key={stage.id} className="flex items-center"> <div key={stage.id || index} className="flex items-center">
<div <div
className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${ className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${
stage.status === 'success' stage.status === 'success'
@@ -355,7 +356,7 @@ export function PipelineManager() {
{getStageStatusIcon(stage.status)} {getStageStatusIcon(stage.status)}
<span className="ml-1">{stage.name}</span> <span className="ml-1">{stage.name}</span>
</div> </div>
{index < pipeline.stages.length - 1 && ( {index < parseStages(pipeline.stages).length - 1 && (
<ChevronRight className="h-4 w-4 text-slate-600 mx-1" /> <ChevronRight className="h-4 w-4 text-slate-600 mx-1" />
)} )}
</div> </div>
@@ -391,7 +392,7 @@ export function PipelineManager() {
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400"> <Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-blue-400">
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400"> <Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400" onClick={() => handleDeletePipeline(pipeline.id)}>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
@@ -513,9 +514,9 @@ export function PipelineManager() {
</TabsList> </TabsList>
<TabsContent value="stages" className="mt-4"> <TabsContent value="stages" className="mt-4">
<div className="space-y-2"> <div className="space-y-2">
{selectedPipeline.stages.map((stage, index) => ( {parseStages(selectedPipeline.stages).map((stage: PipelineStage, index: number) => (
<div <div
key={stage.id} key={stage.id || index}
className={`p-3 rounded-lg border cursor-pointer transition-all ${ className={`p-3 rounded-lg border cursor-pointer transition-all ${
activeStage === stage.id activeStage === stage.id
? 'border-blue-500 bg-blue-500/10' ? 'border-blue-500 bg-blue-500/10'
@@ -562,7 +563,7 @@ export function PipelineManager() {
<div> <div>
<Label className="text-slate-400">触发方式</Label> <Label className="text-slate-400">触发方式</Label>
<p className="text-slate-200"> <p className="text-slate-200">
{selectedPipeline.triggerType === 'auto' ? '自动触发' : '手动触发'} {selectedPipeline.trigger_type === 'auto' ? '自动触发' : '手动触发'}
</p> </p>
</div> </div>
</div> </div>
+108 -119
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -54,95 +54,7 @@ import {
Check, Check,
X, X,
} from 'lucide-react'; } from 'lucide-react';
import { appReleases } from '@/mocks/data'; import { releaseApi } from '@/services/api';
interface Release {
id: string;
appName: string;
version: string;
environment: 'production' | 'staging' | 'development';
status: 'pending' | 'approved' | 'deploying' | 'success' | 'failed' | 'rolled_back';
commitId: string;
branch: string;
applicant: string;
approver?: string;
description: string;
createdAt: string;
deployedAt?: string;
rollbackVersion?: string;
}
const mockReleases: Release[] = [
{
id: 'rel-001',
appName: '用户中心服务',
version: 'v2.3.1',
environment: 'production',
status: 'success',
commitId: 'a1b2c3d',
branch: 'main',
applicant: '张三',
approver: '李四',
description: '修复用户登录异常问题,优化性能',
createdAt: '2025-04-14 09:00:00',
deployedAt: '2025-04-14 10:30:00',
},
{
id: 'rel-002',
appName: '订单服务',
version: 'v1.5.0',
environment: 'production',
status: 'pending',
commitId: 'e4f5g6h',
branch: 'release/v1.5.0',
applicant: '王五',
description: '新增订单批量导出功能',
createdAt: '2025-04-14 11:00:00',
},
{
id: 'rel-003',
appName: '支付网关',
version: 'v3.0.2',
environment: 'staging',
status: 'deploying',
commitId: 'i7j8k9l',
branch: 'develop',
applicant: '赵六',
approver: '李四',
description: '集成新的支付渠道',
createdAt: '2025-04-14 10:00:00',
deployedAt: '2025-04-14 11:15:00',
},
{
id: 'rel-004',
appName: '消息推送服务',
version: 'v1.2.0',
environment: 'production',
status: 'failed',
commitId: 'm0n1o2p',
branch: 'main',
applicant: '张三',
approver: '李四',
description: '优化推送性能',
createdAt: '2025-04-14 08:00:00',
deployedAt: '2025-04-14 08:30:00',
},
{
id: 'rel-005',
appName: '数据分析平台',
version: 'v2.0.0',
environment: 'production',
status: 'rolled_back',
commitId: 'q3r4s5t',
branch: 'main',
applicant: '王五',
approver: '李四',
description: '新版数据分析引擎',
createdAt: '2025-04-13 14:00:00',
deployedAt: '2025-04-13 15:00:00',
rollbackVersion: 'v1.9.8',
},
];
const applications = [ const applications = [
{ id: 'app-1', name: '用户中心服务', currentVersion: 'v2.3.0' }, { id: 'app-1', name: '用户中心服务', currentVersion: 'v2.3.0' },
@@ -156,7 +68,9 @@ export function ReleaseManager() {
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [envFilter, setEnvFilter] = useState<string>('all'); const [envFilter, setEnvFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusFilter, setStatusFilter] = useState<string>('all');
const [selectedRelease, setSelectedRelease] = useState<Release | null>(null); const [releases, setReleases] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedRelease, setSelectedRelease] = useState<any | null>(null);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false); const [isDetailDialogOpen, setIsDetailDialogOpen] = useState(false);
const [isApproveDialogOpen, setIsApproveDialogOpen] = useState(false); const [isApproveDialogOpen, setIsApproveDialogOpen] = useState(false);
@@ -169,16 +83,32 @@ export function ReleaseManager() {
description: '', description: '',
}); });
const filteredReleases = mockReleases.filter((release) => { const loadData = async () => {
try {
setLoading(true);
const data = await releaseApi.getReleases();
setReleases(data.list || []);
} catch (error) {
console.error('Failed to load releases:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const filteredReleases = releases.filter((release) => {
const matchesSearch = const matchesSearch =
release.appName.toLowerCase().includes(searchQuery.toLowerCase()) || (release.app_name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
release.version.includes(searchQuery); (release.version || '').includes(searchQuery);
const matchesEnv = envFilter === 'all' || release.environment === envFilter; const matchesEnv = envFilter === 'all' || release.environment === envFilter;
const matchesStatus = statusFilter === 'all' || release.status === statusFilter; const matchesStatus = statusFilter === 'all' || release.status === statusFilter;
return matchesSearch && matchesEnv && matchesStatus; return matchesSearch && matchesEnv && matchesStatus;
}); });
const getStatusBadge = (status: Release['status']) => { const getStatusBadge = (status: any) => {
switch (status) { switch (status) {
case 'success': case 'success':
return ( return (
@@ -222,34 +152,78 @@ export function ReleaseManager() {
已回滚 已回滚
</Badge> </Badge>
); );
default:
return null;
} }
}; };
const getEnvironmentBadge = (env: Release['environment']) => { const getEnvironmentBadge = (env: any) => {
const colors = { const colors: Record<string, string> = {
production: 'border-red-500/30 text-red-400 bg-red-500/10', production: 'border-red-500/30 text-red-400 bg-red-500/10',
staging: 'border-amber-500/30 text-amber-400 bg-amber-500/10', staging: 'border-amber-500/30 text-amber-400 bg-amber-500/10',
development: 'border-blue-500/30 text-blue-400 bg-blue-500/10', development: 'border-blue-500/30 text-blue-400 bg-blue-500/10',
}; };
const labels = { const labels: Record<string, string> = {
production: '生产环境', production: '生产环境',
staging: '预发环境', staging: '预发环境',
development: '开发环境', development: '开发环境',
}; };
return ( return (
<Badge variant="outline" className={colors[env]}> <Badge variant="outline" className={colors[env] || colors.development}>
{labels[env]} {labels[env] || env}
</Badge> </Badge>
); );
}; };
const handleCreateRelease = () => { const handleCreateRelease = async () => {
try {
await releaseApi.createRelease({
app_name: applications.find((a) => a.id === newRelease.appId)?.name || newRelease.appId,
version: newRelease.version,
environment: newRelease.environment,
branch: newRelease.branch,
commit_id: newRelease.commitId,
description: newRelease.description,
});
setIsCreateDialogOpen(false); setIsCreateDialogOpen(false);
setNewRelease({ appId: '', version: '', environment: 'staging', branch: '', commitId: '', description: '' }); setNewRelease({ appId: '', version: '', environment: 'staging', branch: '', commitId: '', description: '' });
loadData();
} catch (error) {
console.error('Failed to create release:', error);
}
}; };
const handleApprove = (approved: boolean) => { const handleApprove = async (approved: boolean) => {
if (!selectedRelease) return;
try {
if (approved) {
await releaseApi.approveRelease(selectedRelease.id);
} else {
await releaseApi.rollbackRelease(selectedRelease.id);
}
setIsApproveDialogOpen(false); setIsApproveDialogOpen(false);
loadData();
} catch (error) {
console.error('Failed to process approval:', error);
}
};
const handleDeploy = async (id: any) => {
try {
await releaseApi.deployRelease(id);
loadData();
} catch (error) {
console.error('Failed to deploy release:', error);
}
};
const handleRollback = async (id: any) => {
try {
await releaseApi.rollbackRelease(id);
loadData();
} catch (error) {
console.error('Failed to rollback release:', error);
}
}; };
return ( return (
@@ -272,7 +246,7 @@ export function ReleaseManager() {
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-slate-400 text-sm">待审批</p> <p className="text-slate-400 text-sm">待审批</p>
<p className="text-2xl font-bold text-amber-400"> <p className="text-2xl font-bold text-amber-400">
{mockReleases.filter((r) => r.status === 'pending').length} {releases.filter((r) => r.status === 'pending').length}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -280,7 +254,7 @@ export function ReleaseManager() {
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-slate-400 text-sm">发布中</p> <p className="text-slate-400 text-sm">发布中</p>
<p className="text-2xl font-bold text-blue-400"> <p className="text-2xl font-bold text-blue-400">
{mockReleases.filter((r) => r.status === 'deploying').length} {releases.filter((r) => r.status === 'deploying').length}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -288,7 +262,7 @@ export function ReleaseManager() {
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-slate-400 text-sm">今日成功</p> <p className="text-slate-400 text-sm">今日成功</p>
<p className="text-2xl font-bold text-emerald-400"> <p className="text-2xl font-bold text-emerald-400">
{mockReleases.filter((r) => r.status === 'success').length} {releases.filter((r) => r.status === 'success').length}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -296,7 +270,7 @@ export function ReleaseManager() {
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-slate-400 text-sm">今日失败</p> <p className="text-slate-400 text-sm">今日失败</p>
<p className="text-2xl font-bold text-red-400"> <p className="text-2xl font-bold text-red-400">
{mockReleases.filter((r) => r.status === 'failed').length} {releases.filter((r) => r.status === 'failed').length}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -304,7 +278,7 @@ export function ReleaseManager() {
<CardContent className="p-4"> <CardContent className="p-4">
<p className="text-slate-400 text-sm">已回滚</p> <p className="text-slate-400 text-sm">已回滚</p>
<p className="text-2xl font-bold text-slate-400"> <p className="text-2xl font-bold text-slate-400">
{mockReleases.filter((r) => r.status === 'rolled_back').length} {releases.filter((r) => r.status === 'rolled_back').length}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
@@ -346,6 +320,12 @@ export function ReleaseManager() {
</select> </select>
</div> </div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
</div>
) : null}
{/* Release List */} {/* Release List */}
<div className="space-y-3"> <div className="space-y-3">
{filteredReleases.map((release) => ( {filteredReleases.map((release) => (
@@ -354,7 +334,7 @@ export function ReleaseManager() {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h3 className="font-medium text-slate-100">{release.appName}</h3> <h3 className="font-medium text-slate-100">{release.app_name}</h3>
{getStatusBadge(release.status)} {getStatusBadge(release.status)}
{getEnvironmentBadge(release.environment)} {getEnvironmentBadge(release.environment)}
</div> </div>
@@ -369,19 +349,18 @@ export function ReleaseManager() {
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<GitCommit className="h-3 w-3" /> <GitCommit className="h-3 w-3" />
{release.commitId} {release.commit_id}
</span> </span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<User className="h-3 w-3" /> <User className="h-3 w-3" />
申请人: {release.applicant} 发布人: {release.deployer}
</span> </span>
</div> </div>
<p className="text-sm text-slate-500 mt-2">{release.description}</p>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500"> <div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span>创建: {release.createdAt}</span> <span>创建: {release.created_at ? new Date(release.created_at).toLocaleString() : '--'}</span>
{release.deployedAt && <span>部署: {release.deployedAt}</span>} {release.updated_at && <span>部署: {new Date(release.updated_at).toLocaleString()}</span>}
{release.rollbackVersion && ( {release.rollback_to && (
<span className="text-red-400">回滚至: {release.rollbackVersion}</span> <span className="text-red-400">回滚至: {release.rollback_to}</span>
)} )}
</div> </div>
</div> </div>
@@ -402,13 +381,23 @@ export function ReleaseManager() {
</Button> </Button>
)} )}
{release.status === 'approved' && ( {release.status === 'approved' && (
<Button variant="outline" size="sm" className="border-blue-600 text-blue-400 hover:bg-blue-600/20"> <Button
variant="outline"
size="sm"
className="border-blue-600 text-blue-400 hover:bg-blue-600/20"
onClick={() => handleDeploy(release.id)}
>
<Play className="h-4 w-4 mr-1" /> <Play className="h-4 w-4 mr-1" />
开始发布 开始发布
</Button> </Button>
)} )}
{(release.status === 'success' || release.status === 'failed') && ( {(release.status === 'success' || release.status === 'failed') && (
<Button variant="outline" size="sm" className="border-amber-600 text-amber-400 hover:bg-amber-600/20"> <Button
variant="outline"
size="sm"
className="border-amber-600 text-amber-400 hover:bg-amber-600/20"
onClick={() => handleRollback(release.id)}
>
<RotateCcw className="h-4 w-4 mr-1" /> <RotateCcw className="h-4 w-4 mr-1" />
回滚 回滚
</Button> </Button>
@@ -531,13 +520,13 @@ export function ReleaseManager() {
<DialogHeader> <DialogHeader>
<DialogTitle>审批发布</DialogTitle> <DialogTitle>审批发布</DialogTitle>
<DialogDescription className="text-slate-500"> <DialogDescription className="text-slate-500">
{selectedRelease?.appName} - {selectedRelease?.version} {selectedRelease?.app_name} - {selectedRelease?.version}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="py-4"> <div className="py-4">
<div className="p-4 bg-slate-800 rounded-lg"> <div className="p-4 bg-slate-800 rounded-lg">
<p className="text-sm text-slate-400">发布说明</p> <p className="text-sm text-slate-400">发布说明</p>
<p className="text-slate-200 mt-1">{selectedRelease?.description}</p> <p className="text-slate-200 mt-1">{selectedRelease?.description || '无'}</p>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<Label className="text-slate-300">审批意见</Label> <Label className="text-slate-300">审批意见</Label>
+182 -67
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -43,7 +43,7 @@ import {
Search, Search,
Filter, Filter,
} from 'lucide-react'; } from 'lucide-react';
import { k8sClusters, k8sNodes, k8sPods, k8sDeployments, k8sServices, k8sIngresses } from '@/mocks/data'; import { k8sApi } from '@/services/api';
import type { K8sCluster } from '@/types'; import type { K8sCluster } from '@/types';
import { import {
LineChart, LineChart,
@@ -61,13 +61,79 @@ import {
} from 'recharts'; } from 'recharts';
export function K8sClusters() { export function K8sClusters() {
const [clusters, setClusters] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedCluster, setSelectedCluster] = useState<string | null>(null); const [selectedCluster, setSelectedCluster] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const filteredClusters = k8sClusters.filter( // 集群详情关联资源
const [nodes, setNodes] = useState<any[]>([]);
const [pods, setPods] = useState<any[]>([]);
const [deployments, setDeployments] = useState<any[]>([]);
const [services, setServices] = useState<any[]>([]);
const [ingresses, setIngresses] = useState<any[]>([]);
const loadData = async () => {
try {
setLoading(true);
const res: any = await k8sApi.getClusters();
setClusters(res?.list || []);
} catch (e) {
console.error('加载集群列表失败:', e);
setClusters([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const loadClusterDetail = async (clusterId: string) => {
try {
const [nodesRes, podsRes, depRes, svcRes, ingRes] = await Promise.all([
k8sApi.getNodes(clusterId).catch(() => ({ list: [] })),
k8sApi.getPods(clusterId).catch(() => ({ list: [] })),
k8sApi.getDeployments(clusterId).catch(() => ({ list: [] })),
k8sApi.getServices(clusterId).catch(() => ({ list: [] })),
k8sApi.getIngresses(clusterId).catch(() => ({ list: [] })),
]);
setNodes((nodesRes as any)?.list || []);
setPods((podsRes as any)?.list || []);
setDeployments((depRes as any)?.list || []);
setServices((svcRes as any)?.list || []);
setIngresses((ingRes as any)?.list || []);
} catch (e) {
console.error('加载集群详情失败:', e);
}
};
useEffect(() => {
if (selectedCluster) {
loadClusterDetail(selectedCluster);
} else {
setNodes([]);
setPods([]);
setDeployments([]);
setServices([]);
setIngresses([]);
}
}, [selectedCluster]);
const handleDelete = async (id: string) => {
try {
await k8sApi.deleteCluster(id);
loadData();
} catch (e) {
console.error('删除集群失败:', e);
}
};
const filteredClusters = clusters.filter(
(cluster) => (cluster) =>
cluster.name.toLowerCase().includes(searchQuery.toLowerCase()) || (cluster.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
cluster.version.includes(searchQuery) (cluster.version || '').includes(searchQuery)
); );
const getStatusBadge = (status: K8sCluster['status']) => { const getStatusBadge = (status: K8sCluster['status']) => {
@@ -98,15 +164,7 @@ export function K8sClusters() {
} }
}; };
const clusterDetail = selectedCluster const clusterDetail = selectedCluster ? clusters.find((c) => c.id === selectedCluster) : null;
? k8sClusters.find((c) => c.id === selectedCluster)
: null;
const clusterNodes = selectedCluster
? k8sNodes.filter((n) => n.clusterId === selectedCluster)
: [];
const clusterPods = selectedCluster
? k8sPods.filter((p) => p.clusterId === selectedCluster)
: [];
// 模拟资源使用趋势数据 // 模拟资源使用趋势数据
const resourceData = [ const resourceData = [
@@ -128,8 +186,12 @@ export function K8sClusters() {
<p className="text-sm text-slate-500 mt-1">管理Kubernetes集群资源</p> <p className="text-sm text-slate-500 mt-1">管理Kubernetes集群资源</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"> <Button
<RefreshCw className="h-4 w-4 mr-2" /> variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={loadData}
>
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
刷新 刷新
</Button> </Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white"> <Button className="bg-blue-600 hover:bg-blue-700 text-white">
@@ -146,7 +208,7 @@ export function K8sClusters() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-slate-500">集群总数</p> <p className="text-sm text-slate-500">集群总数</p>
<p className="text-2xl font-bold text-slate-100">{k8sClusters.length}</p> <p className="text-2xl font-bold text-slate-100">{clusters.length}</p>
</div> </div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400"> <div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Cloud className="h-5 w-5" /> <Cloud className="h-5 w-5" />
@@ -160,7 +222,7 @@ export function K8sClusters() {
<div> <div>
<p className="text-sm text-slate-500">健康集群</p> <p className="text-sm text-slate-500">健康集群</p>
<p className="text-2xl font-bold text-emerald-400"> <p className="text-2xl font-bold text-emerald-400">
{k8sClusters.filter((c) => c.status === 'healthy').length} {clusters.filter((c) => c.status === 'healthy').length}
</p> </p>
</div> </div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400"> <div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
@@ -175,7 +237,7 @@ export function K8sClusters() {
<div> <div>
<p className="text-sm text-slate-500">总节点数</p> <p className="text-sm text-slate-500">总节点数</p>
<p className="text-2xl font-bold text-slate-100"> <p className="text-2xl font-bold text-slate-100">
{k8sClusters.reduce((acc, c) => acc + c.nodes, 0)} {clusters.reduce((acc, c) => acc + (c.node_count || 0), 0)}
</p> </p>
</div> </div>
<div className="p-3 rounded-lg bg-purple-500/10 text-purple-400"> <div className="p-3 rounded-lg bg-purple-500/10 text-purple-400">
@@ -190,7 +252,7 @@ export function K8sClusters() {
<div> <div>
<p className="text-sm text-slate-500">总Pod数</p> <p className="text-sm text-slate-500">总Pod数</p>
<p className="text-2xl font-bold text-slate-100"> <p className="text-2xl font-bold text-slate-100">
{k8sClusters.reduce((acc, c) => acc + c.pods, 0)} {clusters.reduce((acc, c) => acc + (c.pod_count || 0), 0)}
</p> </p>
</div> </div>
<div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400"> <div className="p-3 rounded-lg bg-cyan-500/10 text-cyan-400">
@@ -216,7 +278,22 @@ export function K8sClusters() {
{/* Clusters Grid */} {/* Clusters Grid */}
<div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4"> <div className="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 gap-4">
{filteredClusters.map((cluster) => ( {loading ? (
<Card className="bg-slate-900 border-slate-800 col-span-full">
<CardContent className="p-12 text-center text-slate-500">
<RefreshCw className="h-6 w-6 animate-spin mx-auto mb-3" />
加载中...
</CardContent>
</Card>
) : filteredClusters.length === 0 ? (
<Card className="bg-slate-900 border-slate-800 col-span-full">
<CardContent className="p-12 text-center text-slate-500">暂无集群数据</CardContent>
</Card>
) : (
filteredClusters.map((cluster) => {
const cpuUsage = Number(cluster.cpu_usage || 0);
const memoryUsage = Number(cluster.memory_usage || 0);
return (
<Card <Card
key={cluster.id} key={cluster.id}
className={cn( className={cn(
@@ -267,7 +344,13 @@ export function K8sClusters() {
配置 配置
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" /> <DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400"> <DropdownMenuItem
className="text-red-400 focus:bg-slate-700 focus:text-red-400"
onClick={(e) => {
e.stopPropagation();
handleDelete(cluster.id);
}}
>
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
删除 删除
</DropdownMenuItem> </DropdownMenuItem>
@@ -279,19 +362,19 @@ export function K8sClusters() {
<CardContent className="pt-0"> <CardContent className="pt-0">
<div className="grid grid-cols-4 gap-2 mb-4"> <div className="grid grid-cols-4 gap-2 mb-4">
<div className="text-center p-2 rounded-lg bg-slate-800/50"> <div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.nodes}</div> <div className="text-lg font-semibold text-slate-200">{cluster.node_count || 0}</div>
<div className="text-xs text-slate-500">节点</div> <div className="text-xs text-slate-500">节点</div>
</div> </div>
<div className="text-center p-2 rounded-lg bg-slate-800/50"> <div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.pods}</div> <div className="text-lg font-semibold text-slate-200">{cluster.pod_count || 0}</div>
<div className="text-xs text-slate-500">Pod</div> <div className="text-xs text-slate-500">Pod</div>
</div> </div>
<div className="text-center p-2 rounded-lg bg-slate-800/50"> <div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.services}</div> <div className="text-lg font-semibold text-slate-200">{cluster.service_count || 0}</div>
<div className="text-xs text-slate-500">Service</div> <div className="text-xs text-slate-500">Service</div>
</div> </div>
<div className="text-center p-2 rounded-lg bg-slate-800/50"> <div className="text-center p-2 rounded-lg bg-slate-800/50">
<div className="text-lg font-semibold text-slate-200">{cluster.deployments}</div> <div className="text-lg font-semibold text-slate-200">{cluster.deployment_count || 0}</div>
<div className="text-xs text-slate-500">Deployment</div> <div className="text-xs text-slate-500">Deployment</div>
</div> </div>
</div> </div>
@@ -302,12 +385,12 @@ export function K8sClusters() {
<div <div
className={cn( className={cn(
'h-full rounded-full', 'h-full rounded-full',
cluster.cpuUsage > 80 ? 'bg-red-400' : cluster.cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400' cpuUsage > 80 ? 'bg-red-400' : cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)} )}
style={{ width: `${cluster.cpuUsage}%` }} style={{ width: `${cpuUsage}%` }}
/> />
</div> </div>
<span className="text-xs text-slate-400 w-10 text-right">{cluster.cpuUsage.toFixed(1)}%</span> <span className="text-xs text-slate-400 w-10 text-right">{cpuUsage.toFixed(1)}%</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-10">内存</span> <span className="text-xs text-slate-500 w-10">内存</span>
@@ -315,17 +398,19 @@ export function K8sClusters() {
<div <div
className={cn( className={cn(
'h-full rounded-full', 'h-full rounded-full',
cluster.memoryUsage > 80 ? 'bg-red-400' : cluster.memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400' memoryUsage > 80 ? 'bg-red-400' : memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)} )}
style={{ width: `${cluster.memoryUsage}%` }} style={{ width: `${memoryUsage}%` }}
/> />
</div> </div>
<span className="text-xs text-slate-400 w-10 text-right">{cluster.memoryUsage.toFixed(1)}%</span> <span className="text-xs text-slate-400 w-10 text-right">{memoryUsage.toFixed(1)}%</span>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
))} );
})
)}
</div> </div>
{/* Cluster Detail */} {/* Cluster Detail */}
@@ -338,8 +423,8 @@ export function K8sClusters() {
<div> <div>
<CardTitle className="text-slate-100">{clusterDetail.name}</CardTitle> <CardTitle className="text-slate-100">{clusterDetail.name}</CardTitle>
<CardDescription className="text-slate-500"> <CardDescription className="text-slate-500">
{clusterDetail.version} · {clusterDetail.nodes} 节点 · 创建于{' '} {clusterDetail.version} · {clusterDetail.node_count || 0} 节点 · 创建于{' '}
{new Date(clusterDetail.createdAt).toLocaleDateString()} {clusterDetail.created_at ? new Date(clusterDetail.created_at).toLocaleDateString() : '-'}
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
@@ -361,19 +446,19 @@ export function K8sClusters() {
概览 概览
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="nodes" className="data-[state=active]:bg-slate-700"> <TabsTrigger value="nodes" className="data-[state=active]:bg-slate-700">
节点 ({clusterNodes.length}) 节点 ({nodes.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="pods" className="data-[state=active]:bg-slate-700"> <TabsTrigger value="pods" className="data-[state=active]:bg-slate-700">
Pod ({clusterPods.length}) Pod ({pods.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="deployments" className="data-[state=active]:bg-slate-700"> <TabsTrigger value="deployments" className="data-[state=active]:bg-slate-700">
Deployment ({k8sDeployments.length}) Deployment ({deployments.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="services" className="data-[state=active]:bg-slate-700"> <TabsTrigger value="services" className="data-[state=active]:bg-slate-700">
Service ({k8sServices.length}) Service ({services.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="ingresses" className="data-[state=active]:bg-slate-700"> <TabsTrigger value="ingresses" className="data-[state=active]:bg-slate-700">
Ingress ({k8sIngresses.length}) Ingress ({ingresses.length})
</TabsTrigger> </TabsTrigger>
<TabsTrigger value="events" className="data-[state=active]:bg-slate-700"> <TabsTrigger value="events" className="data-[state=active]:bg-slate-700">
事件 事件
@@ -440,19 +525,19 @@ export function K8sClusters() {
<CardContent> <CardContent>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="text-center p-4 rounded-lg bg-slate-800"> <div className="text-center p-4 rounded-lg bg-slate-800">
<div className="text-3xl font-bold text-blue-400">{clusterDetail.pods}</div> <div className="text-3xl font-bold text-blue-400">{clusterDetail.pod_count || 0}</div>
<div className="text-sm text-slate-500 mt-1">Pod</div> <div className="text-sm text-slate-500 mt-1">Pod</div>
</div> </div>
<div className="text-center p-4 rounded-lg bg-slate-800"> <div className="text-center p-4 rounded-lg bg-slate-800">
<div className="text-3xl font-bold text-emerald-400">{clusterDetail.services}</div> <div className="text-3xl font-bold text-emerald-400">{clusterDetail.service_count || 0}</div>
<div className="text-sm text-slate-500 mt-1">Service</div> <div className="text-sm text-slate-500 mt-1">Service</div>
</div> </div>
<div className="text-center p-4 rounded-lg bg-slate-800"> <div className="text-center p-4 rounded-lg bg-slate-800">
<div className="text-3xl font-bold text-purple-400">{clusterDetail.deployments}</div> <div className="text-3xl font-bold text-purple-400">{clusterDetail.deployment_count || 0}</div>
<div className="text-sm text-slate-500 mt-1">Deployment</div> <div className="text-sm text-slate-500 mt-1">Deployment</div>
</div> </div>
<div className="text-center p-4 rounded-lg bg-slate-800"> <div className="text-center p-4 rounded-lg bg-slate-800">
<div className="text-3xl font-bold text-amber-400">{clusterDetail.nodes}</div> <div className="text-3xl font-bold text-amber-400">{clusterDetail.node_count || 0}</div>
<div className="text-sm text-slate-500 mt-1">节点</div> <div className="text-sm text-slate-500 mt-1">节点</div>
</div> </div>
</div> </div>
@@ -476,8 +561,13 @@ export function K8sClusters() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800"> <tbody className="divide-y divide-slate-800">
{clusterNodes.map((node) => ( {nodes.length === 0 ? (
<tr key={node.id} className="hover:bg-slate-800/50"> <tr>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">暂无节点数据</td>
</tr>
) : (
nodes.map((node) => (
<tr key={node.id || node.name} className="hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-200">{node.name}</td> <td className="px-4 py-3 text-sm text-slate-200">{node.name}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Badge <Badge
@@ -504,15 +594,16 @@ export function K8sClusters() {
</Badge> </Badge>
</td> </td>
<td className="px-4 py-3 text-sm text-slate-300"> <td className="px-4 py-3 text-sm text-slate-300">
{node.cpuUsage.toFixed(1)}% / {node.cpu}核 {Number(node.cpu_usage || 0).toFixed(1)}% / {node.cpu}核
</td> </td>
<td className="px-4 py-3 text-sm text-slate-300"> <td className="px-4 py-3 text-sm text-slate-300">
{node.memoryUsage.toFixed(1)}% / {node.memory}GB {Number(node.memory_usage || 0).toFixed(1)}% / {node.memory}GB
</td> </td>
<td className="px-4 py-3 text-sm text-slate-300">{node.pods}</td> <td className="px-4 py-3 text-sm text-slate-300">{node.pods ?? node.pod_count ?? 0}</td>
<td className="px-4 py-3 text-sm text-slate-400">{node.os}</td> <td className="px-4 py-3 text-sm text-slate-400">{node.os}</td>
</tr> </tr>
))} ))
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -533,8 +624,13 @@ export function K8sClusters() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800"> <tbody className="divide-y divide-slate-800">
{clusterPods.map((pod) => ( {pods.length === 0 ? (
<tr key={pod.id} className="hover:bg-slate-800/50"> <tr>
<td colSpan={7} className="px-4 py-8 text-center text-slate-500">暂无Pod数据</td>
</tr>
) : (
pods.map((pod) => (
<tr key={pod.id || pod.name} className="hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-200">{pod.name}</td> <td className="px-4 py-3 text-sm text-slate-200">{pod.name}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Badge variant="outline" className="border-slate-700 text-slate-400"> <Badge variant="outline" className="border-slate-700 text-slate-400">
@@ -555,12 +651,13 @@ export function K8sClusters() {
{pod.status} {pod.status}
</Badge> </Badge>
</td> </td>
<td className="px-4 py-3 text-sm text-slate-300">{pod.restarts}</td> <td className="px-4 py-3 text-sm text-slate-300">{pod.restarts ?? 0}</td>
<td className="px-4 py-3 text-sm text-slate-300">{pod.cpu}</td> <td className="px-4 py-3 text-sm text-slate-300">{pod.cpu}</td>
<td className="px-4 py-3 text-sm text-slate-300">{pod.memory}</td> <td className="px-4 py-3 text-sm text-slate-300">{pod.memory}</td>
<td className="px-4 py-3 text-sm text-slate-400">{pod.node}</td> <td className="px-4 py-3 text-sm text-slate-400">{pod.node}</td>
</tr> </tr>
))} ))
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -580,8 +677,13 @@ export function K8sClusters() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800"> <tbody className="divide-y divide-slate-800">
{k8sDeployments.map((deployment) => ( {deployments.length === 0 ? (
<tr key={deployment.id} className="hover:bg-slate-800/50"> <tr>
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">暂无Deployment数据</td>
</tr>
) : (
deployments.map((deployment) => (
<tr key={deployment.id || deployment.name} className="hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-200">{deployment.name}</td> <td className="px-4 py-3 text-sm text-slate-200">{deployment.name}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Badge variant="outline" className="border-slate-700 text-slate-400"> <Badge variant="outline" className="border-slate-700 text-slate-400">
@@ -590,14 +692,15 @@ export function K8sClusters() {
</td> </td>
<td className="px-4 py-3 text-sm text-slate-300">{deployment.replicas}</td> <td className="px-4 py-3 text-sm text-slate-300">{deployment.replicas}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<span className={`text-sm ${deployment.availableReplicas === deployment.replicas ? 'text-emerald-400' : 'text-amber-400'}`}> <span className={`text-sm ${(deployment.available_replicas ?? deployment.availableReplicas) === deployment.replicas ? 'text-emerald-400' : 'text-amber-400'}`}>
{deployment.availableReplicas} / {deployment.replicas} {deployment.available_replicas ?? deployment.availableReplicas} / {deployment.replicas}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-sm text-slate-400 truncate max-w-[200px]">{deployment.image}</td> <td className="px-4 py-3 text-sm text-slate-400 truncate max-w-[200px]">{deployment.image}</td>
<td className="px-4 py-3 text-sm text-slate-400">{deployment.createdAt}</td> <td className="px-4 py-3 text-sm text-slate-400">{deployment.created_at || deployment.createdAt}</td>
</tr> </tr>
))} ))
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -617,8 +720,13 @@ export function K8sClusters() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800"> <tbody className="divide-y divide-slate-800">
{k8sServices.map((service) => ( {services.length === 0 ? (
<tr key={service.id} className="hover:bg-slate-800/50"> <tr>
<td colSpan={6} className="px-4 py-8 text-center text-slate-500">暂无Service数据</td>
</tr>
) : (
services.map((service) => (
<tr key={service.id || service.name} className="hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-200">{service.name}</td> <td className="px-4 py-3 text-sm text-slate-200">{service.name}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Badge variant="outline" className="border-slate-700 text-slate-400"> <Badge variant="outline" className="border-slate-700 text-slate-400">
@@ -639,11 +747,12 @@ export function K8sClusters() {
{service.type} {service.type}
</Badge> </Badge>
</td> </td>
<td className="px-4 py-3 text-sm text-slate-300">{service.clusterIP}</td> <td className="px-4 py-3 text-sm text-slate-300">{service.cluster_ip ?? service.clusterIP}</td>
<td className="px-4 py-3 text-sm text-slate-300">{service.ports}</td> <td className="px-4 py-3 text-sm text-slate-300">{service.ports}</td>
<td className="px-4 py-3 text-sm text-slate-400">{service.selector}</td> <td className="px-4 py-3 text-sm text-slate-400">{service.selector}</td>
</tr> </tr>
))} ))
)}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -662,8 +771,13 @@ export function K8sClusters() {
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800"> <tbody className="divide-y divide-slate-800">
{k8sIngresses.map((ingress) => ( {ingresses.length === 0 ? (
<tr key={ingress.id} className="hover:bg-slate-800/50"> <tr>
<td colSpan={5} className="px-4 py-8 text-center text-slate-500">暂无Ingress数据</td>
</tr>
) : (
ingresses.map((ingress) => (
<tr key={ingress.id || ingress.name} className="hover:bg-slate-800/50">
<td className="px-4 py-3 text-sm text-slate-200">{ingress.name}</td> <td className="px-4 py-3 text-sm text-slate-200">{ingress.name}</td>
<td className="px-4 py-3"> <td className="px-4 py-3">
<Badge variant="outline" className="border-slate-700 text-slate-400"> <Badge variant="outline" className="border-slate-700 text-slate-400">
@@ -685,7 +799,8 @@ export function K8sClusters() {
</Badge> </Badge>
</td> </td>
</tr> </tr>
))} ))
)}
</tbody> </tbody>
</table> </table>
</div> </div>
+49 -4
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink } from 'react-router-dom';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useThemeStore, useTabStore, useNotificationStore } from '@/store'; import { useThemeStore, useTabStore, useNotificationStore, useUserStore } from '@/store';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -31,15 +31,27 @@ import {
Rocket, Rocket,
Server, Server,
Cloud, Cloud,
Palette,
Check,
} from 'lucide-react'; } from 'lucide-react';
import { formatDistanceToNow } from '@/lib/utils'; import { formatDistanceToNow } from '@/lib/utils';
import { useTheme, THEME_OPTIONS } from '@/hooks/useTheme';
export function Header() { export function Header() {
const { sidebarCollapsed, toggleSidebar } = useThemeStore(); const { sidebarCollapsed, toggleSidebar } = useThemeStore();
const { tabs, activeTab, removeTab, setActiveTab } = useTabStore(); const { tabs, activeTab, removeTab, setActiveTab } = useTabStore();
const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotificationStore(); const { notifications, unreadCount, markAsRead, markAllAsRead } = useNotificationStore();
const { user, logout } = useUserStore();
const { theme, setTheme } = useTheme();
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const handleLogout = () => {
logout();
window.location.href = '/login';
};
const displayName = user?.real_name || user?.username || '管理员';
const getNotificationIcon = (type: string) => { const getNotificationIcon = (type: string) => {
switch (type) { switch (type) {
case 'error': case 'error':
@@ -101,6 +113,39 @@ export function Header() {
{/* Right: Actions */} {/* Right: Actions */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Theme Switcher */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100">
<Palette className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56 bg-slate-800 border-slate-700">
<DropdownMenuLabel className="text-slate-300">主题切换</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-slate-700" />
{THEME_OPTIONS.map((option) => (
<DropdownMenuItem
key={option.id}
className={cn(
'flex items-center gap-2 cursor-pointer focus:bg-slate-700 focus:text-slate-100',
theme === option.id ? 'text-slate-100' : 'text-slate-300'
)}
onClick={() => setTheme(option.id)}
>
<span
className="w-3 h-3 rounded-full shrink-0 ring-2 ring-slate-900"
style={{ backgroundColor: option.primaryColor }}
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium">{option.name}</span>
<span className="block text-xs text-slate-500 truncate">{option.description}</span>
</div>
{theme === option.id && <Check className="h-4 w-4 text-blue-400 shrink-0" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
{/* Create New */} {/* Create New */}
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@@ -201,9 +246,9 @@ export function Header() {
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 gap-2 px-2 text-slate-300 hover:text-slate-100"> <Button variant="ghost" className="h-8 gap-2 px-2 text-slate-300 hover:text-slate-100">
<div className="w-6 h-6 rounded-full bg-blue-600 flex items-center justify-center text-white text-xs font-medium"> <div className="w-6 h-6 rounded-full bg-blue-600 flex items-center justify-center text-white text-xs font-medium">
管 {displayName.charAt(0)}
</div> </div>
<span className="hidden md:inline text-sm">管理员</span> <span className="hidden md:inline text-sm">{displayName}</span>
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700"> <DropdownMenuContent align="end" className="w-48 bg-slate-800 border-slate-700">
@@ -218,7 +263,7 @@ export function Header() {
账号设置 账号设置
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator className="bg-slate-700" /> <DropdownMenuSeparator className="bg-slate-700" />
<DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400"> <DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400" onClick={handleLogout}>
<LogOut className="h-4 w-4 mr-2" /> <LogOut className="h-4 w-4 mr-2" />
退出登录 退出登录
</DropdownMenuItem> </DropdownMenuItem>
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -34,9 +34,9 @@ import {
MessageSquare, MessageSquare,
Phone, Phone,
VolumeX, VolumeX,
Loader2,
} from 'lucide-react'; } from 'lucide-react';
import { alertRules, alertEvents } from '@/mocks/data'; import { alertApi } from '@/services/api';
import type { AlertEvent } from '@/types';
import { import {
AreaChart, AreaChart,
Area, Area,
@@ -47,22 +47,59 @@ import {
ResponsiveContainer, ResponsiveContainer,
} from 'recharts'; } from 'recharts';
function hasChannel(notifyChannels: any, channel: string): boolean {
if (!notifyChannels) return false;
if (Array.isArray(notifyChannels)) return notifyChannels.includes(channel);
if (typeof notifyChannels === 'string') {
try {
const parsed = JSON.parse(notifyChannels);
return Array.isArray(parsed) ? parsed.includes(channel) : notifyChannels.includes(channel);
} catch {
return notifyChannels.includes(channel);
}
}
return false;
}
export function AlertManager() { export function AlertManager() {
const [activeTab, setActiveTab] = useState('events'); const [activeTab, setActiveTab] = useState('events');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [severityFilter, setSeverityFilter] = useState<string>('all'); const [severityFilter, setSeverityFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusFilter, setStatusFilter] = useState<string>('all');
const [alerts, setAlerts] = useState<any[]>([]);
const [rules, setRules] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const filteredEvents = alertEvents.filter((event) => { const loadData = async () => {
try {
setLoading(true);
const [alertsData, rulesData] = await Promise.all([
alertApi.getAlerts(),
alertApi.getRules(),
]);
setAlerts((alertsData as any)?.list || []);
setRules(rulesData || []);
} catch (error) {
console.error('Failed to load alert data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const filteredEvents = alerts.filter((event) => {
const matchesSearch = const matchesSearch =
event.ruleName.toLowerCase().includes(searchQuery.toLowerCase()) || ((event.rule && event.rule.name) || event.message || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
event.target.toLowerCase().includes(searchQuery.toLowerCase()); (event.target || '').toLowerCase().includes(searchQuery.toLowerCase());
const matchesSeverity = severityFilter === 'all' || event.severity === severityFilter; const matchesSeverity = severityFilter === 'all' || event.severity === severityFilter;
const matchesStatus = statusFilter === 'all' || event.status === statusFilter; const matchesStatus = statusFilter === 'all' || event.status === statusFilter;
return matchesSearch && matchesSeverity && matchesStatus; return matchesSearch && matchesSeverity && matchesStatus;
}); });
const getSeverityBadge = (severity: AlertEvent['severity']) => { const getSeverityBadge = (severity: any) => {
switch (severity) { switch (severity) {
case 'critical': case 'critical':
return ( return (
@@ -90,7 +127,7 @@ export function AlertManager() {
} }
}; };
const getStatusBadge = (status: AlertEvent['status']) => { const getStatusBadge = (status: any) => {
switch (status) { switch (status) {
case 'firing': case 'firing':
return ( return (
@@ -109,6 +146,24 @@ export function AlertManager() {
} }
}; };
const handleAcknowledge = async (id: any) => {
try {
await alertApi.acknowledge(id);
loadData();
} catch (error) {
console.error('Failed to acknowledge alert:', error);
}
};
const handleToggleRule = async (id: any) => {
try {
await alertApi.toggleRule(id);
loadData();
} catch (error) {
console.error('Failed to toggle rule:', error);
}
};
// 告警趋势数据 // 告警趋势数据
const alertTrendData = [ const alertTrendData = [
{ time: '00:00', critical: 2, warning: 5, info: 8 }, { time: '00:00', critical: 2, warning: 5, info: 8 },
@@ -147,7 +202,7 @@ export function AlertManager() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-slate-500">告警规则</p> <p className="text-sm text-slate-500">告警规则</p>
<p className="text-2xl font-bold text-slate-100">{alertRules.length}</p> <p className="text-2xl font-bold text-slate-100">{rules.length}</p>
</div> </div>
<div className="p-3 rounded-lg bg-blue-500/10 text-blue-400"> <div className="p-3 rounded-lg bg-blue-500/10 text-blue-400">
<Bell className="h-5 w-5" /> <Bell className="h-5 w-5" />
@@ -161,7 +216,7 @@ export function AlertManager() {
<div> <div>
<p className="text-sm text-slate-500">触发中</p> <p className="text-sm text-slate-500">触发中</p>
<p className="text-2xl font-bold text-red-400"> <p className="text-2xl font-bold text-red-400">
{alertEvents.filter((e) => e.status === 'firing').length} {alerts.filter((e) => e.status === 'firing').length}
</p> </p>
</div> </div>
<div className="p-3 rounded-lg bg-red-500/10 text-red-400"> <div className="p-3 rounded-lg bg-red-500/10 text-red-400">
@@ -175,7 +230,7 @@ export function AlertManager() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-slate-500">今日告警</p> <p className="text-sm text-slate-500">今日告警</p>
<p className="text-2xl font-bold text-amber-400">{alertEvents.length}</p> <p className="text-2xl font-bold text-amber-400">{alerts.length}</p>
</div> </div>
<div className="p-3 rounded-lg bg-amber-500/10 text-amber-400"> <div className="p-3 rounded-lg bg-amber-500/10 text-amber-400">
<Clock className="h-5 w-5" /> <Clock className="h-5 w-5" />
@@ -189,7 +244,7 @@ export function AlertManager() {
<div> <div>
<p className="text-sm text-slate-500">已恢复</p> <p className="text-sm text-slate-500">已恢复</p>
<p className="text-2xl font-bold text-emerald-400"> <p className="text-2xl font-bold text-emerald-400">
{alertEvents.filter((e) => e.status === 'resolved').length} {alerts.filter((e) => e.status === 'resolved').length}
</p> </p>
</div> </div>
<div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400"> <div className="p-3 rounded-lg bg-emerald-500/10 text-emerald-400">
@@ -318,11 +373,20 @@ export function AlertManager() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredEvents.map((event) => ( {loading ? (
<TableRow className="border-slate-800">
<TableCell colSpan={8} className="text-center text-slate-500 py-8">
<Loader2 className="h-5 w-5 animate-spin inline mr-2" />
加载中...
</TableCell>
</TableRow>
) : filteredEvents.map((event) => (
<TableRow key={event.id} className="border-slate-800 hover:bg-slate-800/50"> <TableRow key={event.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell>{getSeverityBadge(event.severity)}</TableCell> <TableCell>{getSeverityBadge(event.severity)}</TableCell>
<TableCell> <TableCell>
<span className="text-sm font-medium text-slate-200">{event.ruleName}</span> <span className="text-sm font-medium text-slate-200">
{(event.rule && event.rule.name) || event.message}
</span>
</TableCell> </TableCell>
<TableCell>{getStatusBadge(event.status)}</TableCell> <TableCell>{getStatusBadge(event.status)}</TableCell>
<TableCell> <TableCell>
@@ -336,7 +400,7 @@ export function AlertManager() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="text-sm text-slate-400"> <span className="text-sm text-slate-400">
{new Date(event.startedAt).toLocaleString()} {event.created_at ? new Date(event.created_at).toLocaleString() : '--'}
</span> </span>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
@@ -346,6 +410,7 @@ export function AlertManager() {
variant="ghost" variant="ghost"
size="sm" size="sm"
className="h-8 text-slate-400 hover:text-emerald-400" className="h-8 text-slate-400 hover:text-emerald-400"
onClick={() => handleAcknowledge(event.id)}
> >
<CheckCircle className="h-4 w-4 mr-1" /> <CheckCircle className="h-4 w-4 mr-1" />
确认 确认
@@ -380,7 +445,7 @@ export function AlertManager() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{alertRules.map((rule) => ( {rules.map((rule) => (
<TableRow key={rule.id} className="border-slate-800 hover:bg-slate-800/50"> <TableRow key={rule.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell> <TableCell>
<div> <div>
@@ -414,23 +479,27 @@ export function AlertManager() {
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="text-sm text-slate-300">{rule.duration / 60}分钟</span> <span className="text-sm text-slate-300">{(rule.duration || 0) / 60}分钟</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex gap-1"> <div className="flex gap-1">
{rule.notifyChannels.includes('email') && ( {hasChannel(rule.notify_channels, 'email') && (
<Mail className="h-4 w-4 text-slate-400" /> <Mail className="h-4 w-4 text-slate-400" />
)} )}
{rule.notifyChannels.includes('sms') && ( {hasChannel(rule.notify_channels, 'sms') && (
<Phone className="h-4 w-4 text-slate-400" /> <Phone className="h-4 w-4 text-slate-400" />
)} )}
{rule.notifyChannels.includes('dingtalk') && ( {hasChannel(rule.notify_channels, 'dingtalk') && (
<MessageSquare className="h-4 w-4 text-slate-400" /> <MessageSquare className="h-4 w-4 text-slate-400" />
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<Switch checked={rule.enabled} className="data-[state=checked]:bg-blue-600" /> <Switch
checked={!!rule.enabled}
className="data-[state=checked]:bg-blue-600"
onCheckedChange={() => handleToggleRule(rule.id)}
/>
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<div className="flex items-center justify-end gap-1"> <div className="flex items-center justify-end gap-1">
@@ -1,8 +1,9 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Loader2 } from 'lucide-react';
import { import {
Search, Search,
ExternalLink, ExternalLink,
@@ -15,16 +16,16 @@ import {
Star, Star,
Settings Settings
} from 'lucide-react'; } from 'lucide-react';
import { navigationGroups, navigationLinks } from '@/mocks/data'; import { navigationApi } from '@/services/api';
interface NavLink { interface NavLink {
id: string; id: string;
name: string; title: string;
url: string; url: string;
icon?: string; icon?: string;
description?: string; description?: string;
groupId: string; group_id: string;
isFavorite?: boolean; is_favorite?: boolean;
tags?: string[]; tags?: string[];
} }
@@ -33,7 +34,7 @@ interface NavGroup {
name: string; name: string;
icon?: string; icon?: string;
description?: string; description?: string;
linkCount?: number; sort_order?: number;
} }
export function NavigationPage() { export function NavigationPage() {
@@ -41,28 +42,66 @@ export function NavigationPage() {
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
const [selectedGroup, setSelectedGroup] = useState<string>('all'); const [selectedGroup, setSelectedGroup] = useState<string>('all');
const [showFavoritesOnly, setShowFavoritesOnly] = useState(false); const [showFavoritesOnly, setShowFavoritesOnly] = useState(false);
const [groups, setGroups] = useState<NavGroup[]>([]);
const [links, setLinks] = useState<NavLink[]>([]);
const [loading, setLoading] = useState(true);
const groups = navigationGroups; const loadData = async () => {
const links = navigationLinks; try {
setLoading(true);
const [groupsData, linksData] = await Promise.all([
navigationApi.getGroups(),
navigationApi.getLinks(),
]);
setGroups(groupsData || []);
setLinks(linksData || []);
} catch (error) {
console.error('Failed to load navigation data:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
const handleToggleFavorite = async (id: string) => {
try {
await navigationApi.toggleFavorite(id);
loadData();
} catch (error) {
console.error('Failed to toggle favorite:', error);
}
};
const handleDeleteLink = async (id: string) => {
try {
await navigationApi.deleteLink(id);
loadData();
} catch (error) {
console.error('Failed to delete link:', error);
}
};
const filteredLinks = links.filter(link => { const filteredLinks = links.filter(link => {
const matchesSearch = link.name.toLowerCase().includes(searchQuery.toLowerCase()) || const matchesSearch = (link.title || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
link.description?.toLowerCase().includes(searchQuery.toLowerCase()) || (link.description || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
link.tags?.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase())); (link.tags || []).some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()));
const matchesGroup = selectedGroup === 'all' || link.groupId === selectedGroup; const matchesGroup = selectedGroup === 'all' || link.group_id === selectedGroup;
const matchesFavorite = !showFavoritesOnly || link.isFavorite; const matchesFavorite = !showFavoritesOnly || link.is_favorite;
return matchesSearch && matchesGroup && matchesFavorite; return matchesSearch && matchesGroup && matchesFavorite;
}); });
const groupedLinks = groups.map(group => ({ const groupedLinks = groups.map(group => ({
...group, ...group,
links: filteredLinks.filter(link => link.groupId === group.id) links: filteredLinks.filter(link => link.group_id === group.id)
})).filter(group => group.links.length > 0); })).filter(group => group.links.length > 0);
const stats = { const stats = {
totalLinks: links.length, totalLinks: links.length,
totalGroups: groups.length, totalGroups: groups.length,
favorites: links.filter(l => l.isFavorite).length, favorites: links.filter(l => l.is_favorite).length,
}; };
return ( return (
@@ -178,6 +217,12 @@ export function NavigationPage() {
</div> </div>
</div> </div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 text-slate-500 animate-spin" />
</div>
) : null}
{/* Navigation Content */} {/* Navigation Content */}
<div className="space-y-6"> <div className="space-y-6">
{selectedGroup === 'all' && !searchQuery && !showFavoritesOnly ? ( {selectedGroup === 'all' && !searchQuery && !showFavoritesOnly ? (
@@ -194,13 +239,13 @@ export function NavigationPage() {
{viewMode === 'grid' ? ( {viewMode === 'grid' ? (
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
{group.links.map(link => ( {group.links.map(link => (
<NavLinkCard key={link.id} link={link} /> <NavLinkCard key={link.id} link={link} onToggleFavorite={handleToggleFavorite} onDelete={handleDeleteLink} />
))} ))}
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{group.links.map(link => ( {group.links.map(link => (
<NavLinkRow key={link.id} link={link} /> <NavLinkRow key={link.id} link={link} onToggleFavorite={handleToggleFavorite} onDelete={handleDeleteLink} />
))} ))}
</div> </div>
)} )}
@@ -211,13 +256,13 @@ export function NavigationPage() {
viewMode === 'grid' ? ( viewMode === 'grid' ? (
<div className="grid grid-cols-4 gap-4"> <div className="grid grid-cols-4 gap-4">
{filteredLinks.map(link => ( {filteredLinks.map(link => (
<NavLinkCard key={link.id} link={link} /> <NavLinkCard key={link.id} link={link} onToggleFavorite={handleToggleFavorite} onDelete={handleDeleteLink} />
))} ))}
</div> </div>
) : ( ) : (
<div className="space-y-2"> <div className="space-y-2">
{filteredLinks.map(link => ( {filteredLinks.map(link => (
<NavLinkRow key={link.id} link={link} /> <NavLinkRow key={link.id} link={link} onToggleFavorite={handleToggleFavorite} onDelete={handleDeleteLink} />
))} ))}
</div> </div>
) )
@@ -227,23 +272,23 @@ export function NavigationPage() {
); );
} }
function NavLinkCard({ link }: { link: NavLink }) { function NavLinkCard({ link, onToggleFavorite, onDelete }: { link: NavLink; onToggleFavorite: (id: string) => void; onDelete: (id: string) => void }) {
return ( return (
<Card className="bg-slate-800 border-slate-700 hover:border-blue-500/50 transition-colors group"> <Card className="bg-slate-800 border-slate-700 hover:border-blue-500/50 transition-colors group">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center text-white font-bold text-lg"> <div className="w-10 h-10 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center text-white font-bold text-lg">
{link.name.slice(0, 2)} {(link.title || '').slice(0, 2)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="font-medium text-slate-100 truncate">{link.name}</h3> <h3 className="font-medium text-slate-100 truncate">{link.title}</h3>
<p className="text-xs text-slate-400 mt-0.5 line-clamp-2">{link.description}</p> <p className="text-xs text-slate-400 mt-0.5 line-clamp-2">{link.description}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-yellow-400"> <Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-yellow-400" onClick={() => onToggleFavorite(link.id)}>
<Star className={`h-4 w-4 ${link.isFavorite ? 'fill-yellow-400 text-yellow-400' : ''}`} /> <Star className={`h-4 w-4 ${link.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400"> <Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400">
<Edit2 className="h-4 w-4" /> <Edit2 className="h-4 w-4" />
@@ -252,7 +297,7 @@ function NavLinkCard({ link }: { link: NavLink }) {
</div> </div>
<div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-700"> <div className="flex items-center justify-between mt-3 pt-3 border-t border-slate-700">
<div className="flex gap-1"> <div className="flex gap-1">
{link.tags?.map(tag => ( {(link.tags || []).map(tag => (
<Badge key={tag} variant="secondary" className="bg-slate-700 text-slate-300 text-xs"> <Badge key={tag} variant="secondary" className="bg-slate-700 text-slate-300 text-xs">
{tag} {tag}
</Badge> </Badge>
@@ -270,31 +315,31 @@ function NavLinkCard({ link }: { link: NavLink }) {
); );
} }
function NavLinkRow({ link }: { link: NavLink }) { function NavLinkRow({ link, onToggleFavorite, onDelete }: { link: NavLink; onToggleFavorite: (id: string) => void; onDelete: (id: string) => void }) {
return ( return (
<Card className="bg-slate-800 border-slate-700 hover:border-blue-500/50 transition-colors"> <Card className="bg-slate-800 border-slate-700 hover:border-blue-500/50 transition-colors">
<CardContent className="p-3"> <CardContent className="p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center text-white font-bold text-sm"> <div className="w-8 h-8 rounded-lg bg-gradient-to-br from-blue-500 to-cyan-500 flex items-center justify-center text-white font-bold text-sm">
{link.name.slice(0, 2)} {(link.title || '').slice(0, 2)}
</div> </div>
<div> <div>
<h3 className="font-medium text-slate-100">{link.name}</h3> <h3 className="font-medium text-slate-100">{link.title}</h3>
<p className="text-xs text-slate-400">{link.description}</p> <p className="text-xs text-slate-400">{link.description}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex gap-1"> <div className="flex gap-1">
{link.tags?.map(tag => ( {(link.tags || []).map(tag => (
<Badge key={tag} variant="secondary" className="bg-slate-700 text-slate-300 text-xs"> <Badge key={tag} variant="secondary" className="bg-slate-700 text-slate-300 text-xs">
{tag} {tag}
</Badge> </Badge>
))} ))}
</div> </div>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-yellow-400"> <Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-yellow-400" onClick={() => onToggleFavorite(link.id)}>
<Star className={`h-4 w-4 ${link.isFavorite ? 'fill-yellow-400 text-yellow-400' : ''}`} /> <Star className={`h-4 w-4 ${link.is_favorite ? 'fill-yellow-400 text-yellow-400' : ''}`} />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400"> <Button variant="ghost" size="icon" className="h-7 w-7 text-slate-400 hover:text-blue-400">
<Edit2 className="h-4 w-4" /> <Edit2 className="h-4 w-4" />
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -20,7 +20,7 @@ import {
RefreshCw, RefreshCw,
Check, Check,
} from 'lucide-react'; } from 'lucide-react';
import { cloudAccounts } from '@/mocks/data'; import { cloudApi } from '@/services/api';
interface CloudServerImportProps { interface CloudServerImportProps {
open: boolean; open: boolean;
@@ -45,12 +45,26 @@ const mockCloudServers = [
]; ];
export function CloudServerImport({ open, onClose }: CloudServerImportProps) { export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
const [cloudAccounts, setCloudAccounts] = useState<any[]>([]);
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const [selectedProvider, setSelectedProvider] = useState<string>(''); const [selectedProvider, setSelectedProvider] = useState<string>('');
const [selectedAccount, setSelectedAccount] = useState<string>(''); const [selectedAccount, setSelectedAccount] = useState<string>('');
const [selectedServers, setSelectedServers] = useState<string[]>([]); const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const loadAccounts = async () => {
try {
const res: any = await cloudApi.getAccounts();
setCloudAccounts(res?.list || []);
} catch (e) {
console.error('加载云账号失败:', e);
}
};
useEffect(() => {
loadAccounts();
}, []);
const filteredAccounts = cloudAccounts.filter( const filteredAccounts = cloudAccounts.filter(
acc => acc.provider === selectedProvider && acc.status === 'active' acc => acc.provider === selectedProvider && acc.status === 'active'
); );
@@ -196,14 +210,14 @@ export function CloudServerImport({ open, onClose }: CloudServerImportProps) {
<Key className="h-5 w-5 text-blue-400" /> <Key className="h-5 w-5 text-blue-400" />
<div> <div>
<p className="font-medium text-slate-200">{account.name}</p> <p className="font-medium text-slate-200">{account.name}</p>
<p className="text-xs text-slate-500">{account.accessKey.slice(0, 20)}...</p> <p className="text-xs text-slate-500">{(account.access_key || account.accessKey || '').slice(0, 20)}...</p>
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<Badge className="bg-emerald-500/20 text-emerald-400 border-0"> <Badge className="bg-emerald-500/20 text-emerald-400 border-0">
{account.regionCount} 区域 {account.region_count ?? account.regionCount ?? 0} 区域
</Badge> </Badge>
<p className="text-xs text-slate-500 mt-1">{account.serverCount} 服务器</p> <p className="text-xs text-slate-500 mt-1">{account.server_count ?? account.serverCount ?? 0} 服务器</p>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
+112 -21
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -58,13 +58,16 @@ import {
Square, Square,
Cloud, Cloud,
FileSpreadsheet, FileSpreadsheet,
RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { servers } from '@/mocks/data'; import { serverApi } from '@/services/api';
import type { Server as ServerType } from '@/types'; import type { Server as ServerType } from '@/types';
import { CloudServerImport } from './CloudServerImport'; import { CloudServerImport } from './CloudServerImport';
import { ExcelServerImport } from './ExcelServerImport'; import { ExcelServerImport } from './ExcelServerImport';
export function ServerManager() { export function ServerManager() {
const [servers, setServers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedServers, setSelectedServers] = useState<string[]>([]); const [selectedServers, setSelectedServers] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusFilter, setStatusFilter] = useState<string>('all');
@@ -72,12 +75,53 @@ export function ServerManager() {
const [cloudImportOpen, setCloudImportOpen] = useState(false); const [cloudImportOpen, setCloudImportOpen] = useState(false);
const [excelImportOpen, setExcelImportOpen] = useState(false); const [excelImportOpen, setExcelImportOpen] = useState(false);
const loadData = async () => {
try {
setLoading(true);
const res: any = await serverApi.getServers();
setServers(res?.list || []);
} catch (e) {
console.error('加载服务器列表失败:', e);
setServers([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadData();
}, []);
// 解析标签:后端 tags 可能是 JSON 字符串、逗号分隔字符串或数组
const getTags = (server: any): string[] => {
const tags = server.tags;
if (!tags) return [];
if (Array.isArray(tags)) return tags;
try {
const parsed = JSON.parse(tags);
return Array.isArray(parsed) ? parsed : [String(tags)];
} catch {
return String(tags)
.split(',')
.map((t: string) => t.trim())
.filter(Boolean);
}
};
// 获取区域名称:后端 region 可能是对象 {name} 或字符串
const getRegionName = (server: any): string => {
if (!server.region) return '';
if (typeof server.region === 'string') return server.region;
return server.region.name || '';
};
const filteredServers = servers.filter((server) => { const filteredServers = servers.filter((server) => {
const region = getRegionName(server);
const matchesSearch = const matchesSearch =
server.hostname.toLowerCase().includes(searchQuery.toLowerCase()) || (server.hostname || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
server.ip.includes(searchQuery); (server.ip || '').includes(searchQuery);
const matchesStatus = statusFilter === 'all' || server.status === statusFilter; const matchesStatus = statusFilter === 'all' || server.status === statusFilter;
const matchesRegion = regionFilter === 'all' || server.region === regionFilter; const matchesRegion = regionFilter === 'all' || region === regionFilter;
return matchesSearch && matchesStatus && matchesRegion; return matchesSearch && matchesStatus && matchesRegion;
}); });
@@ -97,6 +141,15 @@ export function ServerManager() {
} }
}; };
const handleDelete = async (id: string) => {
try {
await serverApi.deleteServer(id);
loadData();
} catch (e) {
console.error('删除服务器失败:', e);
}
};
const getStatusBadge = (status: ServerType['status']) => { const getStatusBadge = (status: ServerType['status']) => {
switch (status) { switch (status) {
case 'running': case 'running':
@@ -130,7 +183,7 @@ export function ServerManager() {
} }
}; };
const regions = Array.from(new Set(servers.map((s) => s.region))); const regions = Array.from(new Set(servers.map((s) => getRegionName(s)).filter(Boolean)));
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -141,6 +194,14 @@ export function ServerManager() {
<p className="text-sm text-slate-500 mt-1">管理云平台服务器资产</p> <p className="text-sm text-slate-500 mt-1">管理云平台服务器资产</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button
variant="outline"
className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"
onClick={loadData}
>
<RefreshCw className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
刷新
</Button>
<Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800"> <Button variant="outline" className="border-slate-700 text-slate-300 hover:text-slate-100 hover:bg-slate-800">
<Download className="h-4 w-4 mr-2" /> <Download className="h-4 w-4 mr-2" />
导出 导出
@@ -322,7 +383,26 @@ export function ServerManager() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredServers.map((server) => ( {loading ? (
<TableRow className="border-slate-800">
<TableCell colSpan={9} className="text-center text-slate-500 py-12">
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-2" />
加载中...
</TableCell>
</TableRow>
) : filteredServers.length === 0 ? (
<TableRow className="border-slate-800">
<TableCell colSpan={9} className="text-center text-slate-500 py-12">
暂无服务器数据
</TableCell>
</TableRow>
) : (
filteredServers.map((server) => {
const cpuUsage = Number(server.cpu_usage || 0);
const memoryUsage = Number(server.memory_usage || 0);
const tags = getTags(server);
const region = getRegionName(server);
return (
<TableRow key={server.id} className="border-slate-800 hover:bg-slate-800/50"> <TableRow key={server.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell> <TableCell>
<Checkbox <Checkbox
@@ -363,12 +443,12 @@ export function ServerManager() {
<div <div
className={cn( className={cn(
'h-full rounded-full', 'h-full rounded-full',
server.cpuUsage > 80 ? 'bg-red-400' : server.cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400' cpuUsage > 80 ? 'bg-red-400' : cpuUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)} )}
style={{ width: `${server.cpuUsage}%` }} style={{ width: `${cpuUsage}%` }}
/> />
</div> </div>
<span className="text-xs text-slate-400">{server.cpuUsage.toFixed(1)}%</span> <span className="text-xs text-slate-400">{cpuUsage.toFixed(1)}%</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-slate-500 w-8">内存</span> <span className="text-xs text-slate-500 w-8">内存</span>
@@ -376,21 +456,21 @@ export function ServerManager() {
<div <div
className={cn( className={cn(
'h-full rounded-full', 'h-full rounded-full',
server.memoryUsage > 80 ? 'bg-red-400' : server.memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400' memoryUsage > 80 ? 'bg-red-400' : memoryUsage > 60 ? 'bg-amber-400' : 'bg-emerald-400'
)} )}
style={{ width: `${server.memoryUsage}%` }} style={{ width: `${memoryUsage}%` }}
/> />
</div> </div>
<span className="text-xs text-slate-400">{server.memoryUsage.toFixed(1)}%</span> <span className="text-xs text-slate-400">{memoryUsage.toFixed(1)}%</span>
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="text-sm text-slate-300">{server.region}</span> <span className="text-sm text-slate-300">{region || '-'}</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{server.tags.slice(0, 3).map((tag) => ( {tags.slice(0, 3).map((tag) => (
<Badge <Badge
key={tag} key={tag}
variant="outline" variant="outline"
@@ -399,9 +479,9 @@ export function ServerManager() {
{tag} {tag}
</Badge> </Badge>
))} ))}
{server.tags.length > 3 && ( {tags.length > 3 && (
<Badge variant="outline" className="text-xs border-slate-700 text-slate-400"> <Badge variant="outline" className="text-xs border-slate-700 text-slate-400">
+{server.tags.length - 3} +{tags.length - 3}
</Badge> </Badge>
)} )}
</div> </div>
@@ -437,7 +517,10 @@ export function ServerManager() {
<RotateCw className="h-4 w-4 mr-2" /> <RotateCw className="h-4 w-4 mr-2" />
重启 重启
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem className="text-red-400 focus:bg-slate-700 focus:text-red-400"> <DropdownMenuItem
className="text-red-400 focus:bg-slate-700 focus:text-red-400"
onClick={() => handleDelete(server.id)}
>
<Trash2 className="h-4 w-4 mr-2" /> <Trash2 className="h-4 w-4 mr-2" />
删除 删除
</DropdownMenuItem> </DropdownMenuItem>
@@ -446,7 +529,9 @@ export function ServerManager() {
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} );
})
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
@@ -456,11 +541,17 @@ export function ServerManager() {
{/* Import Dialogs */} {/* Import Dialogs */}
<CloudServerImport <CloudServerImport
open={cloudImportOpen} open={cloudImportOpen}
onClose={() => setCloudImportOpen(false)} onClose={() => {
setCloudImportOpen(false);
loadData();
}}
/> />
<ExcelServerImport <ExcelServerImport
open={excelImportOpen} open={excelImportOpen}
onClose={() => setExcelImportOpen(false)} onClose={() => {
setExcelImportOpen(false);
loadData();
}}
/> />
</div> </div>
); );
+16 -1
View File
@@ -38,7 +38,7 @@ import {
Maximize2, Maximize2,
Minimize2, Minimize2,
} from 'lucide-react'; } from 'lucide-react';
import { servers } from '@/mocks/data'; import { serverApi } from '@/services/api';
interface TerminalSession { interface TerminalSession {
id: string; id: string;
@@ -50,6 +50,7 @@ interface TerminalSession {
} }
export function WebShell() { export function WebShell() {
const [servers, setServers] = useState<any[]>([]);
const [sessions, setSessions] = useState<TerminalSession[]>([]); const [sessions, setSessions] = useState<TerminalSession[]>([]);
const [activeSession, setActiveSession] = useState<string | null>(null); const [activeSession, setActiveSession] = useState<string | null>(null);
const [selectedServer, setSelectedServer] = useState<string>(''); const [selectedServer, setSelectedServer] = useState<string>('');
@@ -57,6 +58,20 @@ export function WebShell() {
const [isFullscreen, setIsFullscreen] = useState(false); const [isFullscreen, setIsFullscreen] = useState(false);
const terminalRef = useRef<HTMLDivElement>(null); const terminalRef = useRef<HTMLDivElement>(null);
const loadServers = async () => {
try {
const res: any = await serverApi.getServers();
setServers(res?.list || []);
} catch (e) {
console.error('加载服务器列表失败:', e);
setServers([]);
}
};
useEffect(() => {
loadServers();
}, []);
const runningServers = servers.filter((s) => s.status === 'running'); const runningServers = servers.filter((s) => s.status === 'running');
const createSession = () => { const createSession = () => {
+268 -20
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@@ -51,20 +51,68 @@ import {
LogOut, LogOut,
RefreshCw, RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { users, roles, operationLogs } from '@/mocks/data'; import { roles, operationLogs } from '@/mocks/data';
import type { User as UserType, Role, OperationLog } from '@/types'; import { userApi } from '@/services/api';
import type { Role, OperationLog } from '@/types';
// 后端用户数据转换为前端兼容格式
function mapUser(u: any) {
return {
...u,
status: u.status === 1 || u.status === 'active' ? ('active' as const) : ('inactive' as const),
roles: Array.isArray(u.roles) ? u.roles : u.role ? [u.role] : [],
lastLoginAt: u.last_login ?? u.lastLoginAt,
createdAt: u.created_at ?? u.createdAt,
};
}
const emptyForm = {
username: '',
nickname: '',
email: '',
phone: '',
role: '',
password: '',
status: 1 as number,
};
export function SystemManager() { export function SystemManager() {
const [activeTab, setActiveTab] = useState('users'); const [activeTab, setActiveTab] = useState('users');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [selectedUser, setSelectedUser] = useState<UserType | null>(null); const [users, setUsers] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [selectedUser, setSelectedUser] = useState<any | null>(null);
const [selectedRole, setSelectedRole] = useState<Role | null>(null); const [selectedRole, setSelectedRole] = useState<Role | null>(null);
// 创建/编辑用户弹窗
const [dialogOpen, setDialogOpen] = useState(false);
const [editingUser, setEditingUser] = useState<any | null>(null);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({ ...emptyForm });
const loadUsers = async () => {
setLoading(true);
try {
const res = await userApi.getUsers({ page: 1, page_size: 100 });
const rawUsers = res?.list ?? [];
setUsers(rawUsers.map(mapUser));
} catch (error) {
console.error('Failed to fetch users:', error);
setUsers([]);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadUsers();
}, []);
const filteredUsers = users.filter( const filteredUsers = users.filter(
(u) => (u) =>
u.username.toLowerCase().includes(searchQuery.toLowerCase()) || (u.username || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
u.nickname.toLowerCase().includes(searchQuery.toLowerCase()) || (u.nickname || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
u.email.toLowerCase().includes(searchQuery.toLowerCase()) (u.email || '').toLowerCase().includes(searchQuery.toLowerCase())
); );
const filteredLogs = operationLogs.filter( const filteredLogs = operationLogs.filter(
@@ -74,7 +122,7 @@ export function SystemManager() {
log.action.toLowerCase().includes(searchQuery.toLowerCase()) log.action.toLowerCase().includes(searchQuery.toLowerCase())
); );
const getUserStatusBadge = (status: UserType['status']) => { const getUserStatusBadge = (status: 'active' | 'inactive') => {
return status === 'active' ? ( return status === 'active' ? (
<Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10"> <Badge variant="outline" className="border-emerald-500/30 text-emerald-400 bg-emerald-500/10">
<CheckCircle className="h-3 w-3 mr-1" /> <CheckCircle className="h-3 w-3 mr-1" />
@@ -100,6 +148,64 @@ export function SystemManager() {
); );
}; };
const openCreate = () => {
setEditingUser(null);
setForm({ ...emptyForm });
setDialogOpen(true);
};
const openEdit = (user: any) => {
setEditingUser(user);
setForm({
username: user.username || '',
nickname: user.nickname || '',
email: user.email || '',
phone: user.phone || '',
role: Array.isArray(user.roles) ? user.roles[0] || '' : user.role || '',
password: '',
status: user.status === 'active' ? 1 : 0,
});
setDialogOpen(true);
};
const handleSubmit = async () => {
setSaving(true);
try {
const payload: any = {
username: form.username,
nickname: form.nickname,
email: form.email,
phone: form.phone,
role: form.role,
status: form.status,
};
if (form.password) {
payload.password = form.password;
}
if (editingUser) {
await userApi.updateUser(editingUser.id, payload);
} else {
await userApi.createUser(payload);
}
setDialogOpen(false);
await loadUsers();
} catch (error) {
console.error('Failed to save user:', error);
} finally {
setSaving(false);
}
};
const handleDelete = async (user: any) => {
if (!window.confirm(`确认删除用户「${user.nickname || user.username}」吗?`)) return;
try {
await userApi.deleteUser(user.id);
await loadUsers();
} catch (error) {
console.error('Failed to delete user:', error);
}
};
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Page Header */} {/* Page Header */}
@@ -143,7 +249,7 @@ export function SystemManager() {
className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500" className="pl-9 h-9 bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
/> />
</div> </div>
<Button className="bg-blue-600 hover:bg-blue-700 text-white"> <Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" /> <Plus className="h-4 w-4 mr-2" />
新建用户 新建用户
</Button> </Button>
@@ -165,15 +271,30 @@ export function SystemManager() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{filteredUsers.map((user) => ( {loading ? (
<TableRow className="border-slate-800">
<TableCell colSpan={6} className="text-center py-12">
<div className="flex items-center justify-center">
<div className="h-6 w-6 border-2 border-slate-700 border-t-blue-500 rounded-full animate-spin" />
</div>
</TableCell>
</TableRow>
) : filteredUsers.length === 0 ? (
<TableRow className="border-slate-800">
<TableCell colSpan={6} className="text-center py-12 text-sm text-slate-500">
暂无用户数据
</TableCell>
</TableRow>
) : (
filteredUsers.map((user) => (
<TableRow key={user.id} className="border-slate-800 hover:bg-slate-800/50"> <TableRow key={user.id} className="border-slate-800 hover:bg-slate-800/50">
<TableCell> <TableCell>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium"> <div className="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white text-sm font-medium">
{user.nickname.charAt(0)} {(user.nickname || user.username || '?').charAt(0)}
</div> </div>
<div> <div>
<p className="text-sm font-medium text-slate-200">{user.nickname}</p> <p className="text-sm font-medium text-slate-200">{user.nickname || user.username}</p>
<p className="text-xs text-slate-500">@{user.username}</p> <p className="text-xs text-slate-500">@{user.username}</p>
</div> </div>
</div> </div>
@@ -182,7 +303,7 @@ export function SystemManager() {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-1 text-sm text-slate-400"> <div className="flex items-center gap-1 text-sm text-slate-400">
<Mail className="h-3.5 w-3.5" /> <Mail className="h-3.5 w-3.5" />
{user.email} {user.email || '--'}
</div> </div>
{user.phone && ( {user.phone && (
<div className="flex items-center gap-1 text-sm text-slate-400"> <div className="flex items-center gap-1 text-sm text-slate-400">
@@ -194,7 +315,7 @@ export function SystemManager() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{user.roles.map((role) => ( {(user.roles || []).map((role: string) => (
<Badge key={role} variant="outline" className="border-slate-700 text-slate-400"> <Badge key={role} variant="outline" className="border-slate-700 text-slate-400">
{role} {role}
</Badge> </Badge>
@@ -217,16 +338,27 @@ export function SystemManager() {
> >
<Eye className="h-4 w-4" /> <Eye className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-slate-100"> <Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-slate-100"
onClick={() => openEdit(user)}
>
<Edit className="h-4 w-4" /> <Edit className="h-4 w-4" />
</Button> </Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-slate-400 hover:text-red-400"> <Button
variant="ghost"
size="icon"
className="h-8 w-8 text-slate-400 hover:text-red-400"
onClick={() => handleDelete(user)}
>
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))
)}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
@@ -536,7 +668,7 @@ export function SystemManager() {
<div> <div>
<p className="text-xs text-slate-500 mb-1">创建时间</p> <p className="text-xs text-slate-500 mb-1">创建时间</p>
<p className="text-sm text-slate-200"> <p className="text-sm text-slate-200">
{selectedUser && new Date(selectedUser.createdAt).toLocaleDateString()} {selectedUser && selectedUser.createdAt ? new Date(selectedUser.createdAt).toLocaleDateString() : '--'}
</p> </p>
</div> </div>
</div> </div>
@@ -544,7 +676,7 @@ export function SystemManager() {
<div> <div>
<h4 className="text-sm font-medium text-slate-300 mb-2">角色</h4> <h4 className="text-sm font-medium text-slate-300 mb-2">角色</h4>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{selectedUser?.roles.map((role) => ( {selectedUser?.roles.map((role: string) => (
<Badge key={role} variant="outline" className="border-slate-700 text-slate-400"> <Badge key={role} variant="outline" className="border-slate-700 text-slate-400">
{role} {role}
</Badge> </Badge>
@@ -558,13 +690,129 @@ export function SystemManager() {
<Key className="h-4 w-4 mr-2" /> <Key className="h-4 w-4 mr-2" />
重置密码 重置密码
</Button> </Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white"> <Button
className="bg-blue-600 hover:bg-blue-700 text-white"
onClick={() => {
if (selectedUser) {
setSelectedUser(null);
openEdit(selectedUser);
}
}}
>
<Edit className="h-4 w-4 mr-2" /> <Edit className="h-4 w-4 mr-2" />
编辑 编辑
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Create / Edit User Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="bg-slate-900 border-slate-800">
<DialogHeader>
<DialogTitle className="text-slate-100 flex items-center gap-2">
<User className="h-5 w-5 text-blue-400" />
{editingUser ? '编辑用户' : '新建用户'}
</DialogTitle>
<DialogDescription className="text-slate-500">
{editingUser ? '修改用户信息' : '创建新的系统用户'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="text-xs text-slate-500">用户名</label>
<Input
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
disabled={!!editingUser}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
placeholder="登录用户名"
/>
</div>
<div className="space-y-2">
<label className="text-xs text-slate-500">昵称</label>
<Input
value={form.nickname}
onChange={(e) => setForm({ ...form, nickname: e.target.value })}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
placeholder="显示昵称"
/>
</div>
<div className="space-y-2">
<label className="text-xs text-slate-500">邮箱</label>
<Input
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
placeholder="user@example.com"
/>
</div>
<div className="space-y-2">
<label className="text-xs text-slate-500">手机号</label>
<Input
value={form.phone}
onChange={(e) => setForm({ ...form, phone: e.target.value })}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
placeholder="可选"
/>
</div>
<div className="space-y-2">
<label className="text-xs text-slate-500">角色</label>
<Select value={form.role} onValueChange={(v) => setForm({ ...form, role: v })}>
<SelectTrigger className="bg-slate-800/50 border-slate-700 text-slate-200">
<SelectValue placeholder="选择角色" />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
{roles.map((role) => (
<SelectItem key={role.id} value={role.name}>
{role.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<label className="text-xs text-slate-500">状态</label>
<Select
value={String(form.status)}
onValueChange={(v) => setForm({ ...form, status: Number(v) })}
>
<SelectTrigger className="bg-slate-800/50 border-slate-700 text-slate-200">
<SelectValue />
</SelectTrigger>
<SelectContent className="bg-slate-800 border-slate-700">
<SelectItem value="1">正常</SelectItem>
<SelectItem value="0">禁用</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div className="space-y-2">
<label className="text-xs text-slate-500">
密码{editingUser ? '(留空则不修改)' : ''}
</label>
<Input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
className="bg-slate-800/50 border-slate-700 text-slate-200 placeholder:text-slate-500"
placeholder={editingUser ? '留空则不修改' : '设置初始密码'}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" className="border-slate-700 text-slate-300" onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button className="bg-blue-600 hover:bg-blue-700 text-white" onClick={handleSubmit} disabled={saving}>
{saving ? '保存中...' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }
+101
View File
@@ -0,0 +1,101 @@
import { createContext, useContext, useState, useCallback, useMemo, useEffect } from 'react';
export type Theme =
| 'ocean-deep'
| 'light-professional'
| 'midnight-purple'
| 'pine-green'
| 'orange-vitality';
export interface ThemeOption {
id: Theme;
name: string;
description: string;
isDark: boolean;
primaryColor: string;
}
export const THEME_OPTIONS: ThemeOption[] = [
{ id: 'ocean-deep', name: '深海蓝', description: '专业稳重的深蓝色调', isDark: true, primaryColor: '#3b82f6' },
{ id: 'light-professional', name: '明亮白', description: '清爽干净的浅色主题', isDark: false, primaryColor: '#2563eb' },
{ id: 'midnight-purple', name: '午夜紫', description: '神秘优雅的紫色调', isDark: true, primaryColor: '#a855f7' },
{ id: 'pine-green', name: '松林绿', description: '自然清新的绿色主题', isDark: true, primaryColor: '#22c55e' },
{ id: 'orange-vitality', name: '活力橙', description: '温暖活力的橙色主题', isDark: true, primaryColor: '#f97316' },
];
const THEME_ORDER: Theme[] = THEME_OPTIONS.map((o) => o.id);
const DEFAULT_THEME: Theme = 'ocean-deep';
const STORAGE_KEY = 'aiops-theme';
// Backward compatibility: map old theme names to new ones
function migrateLegacyTheme(value: string | null): Theme | null {
if (value === 'dark') return 'ocean-deep';
if (value === 'light') return 'light-professional';
return null;
}
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | null>(null);
function getInitialTheme(): Theme {
if (typeof window === 'undefined') return DEFAULT_THEME;
const stored = localStorage.getItem(STORAGE_KEY);
const migrated = migrateLegacyTheme(stored);
if (migrated) {
// Persist the migrated value so we don't migrate every time
localStorage.setItem(STORAGE_KEY, migrated);
return migrated;
}
if (stored && THEME_ORDER.includes(stored as Theme)) {
return stored as Theme;
}
return DEFAULT_THEME;
}
function applyTheme(theme: Theme) {
document.documentElement.setAttribute('data-theme', theme);
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>(getInitialTheme);
useEffect(() => {
applyTheme(theme);
}, [theme]);
const toggleTheme = useCallback(() => {
setThemeState((prev) => {
const idx = THEME_ORDER.indexOf(prev);
const next = THEME_ORDER[(idx + 1) % THEME_ORDER.length];
localStorage.setItem(STORAGE_KEY, next);
return next;
});
}, []);
const setTheme = useCallback((t: Theme) => {
localStorage.setItem(STORAGE_KEY, t);
setThemeState(t);
}, []);
const value = useMemo(
() => ({
theme,
toggleTheme,
setTheme,
}),
[theme, toggleTheme, setTheme]
);
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}
export function useTheme(): ThemeContextType {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
}
+424 -77
View File
@@ -3,35 +3,276 @@
@tailwind utilities; @tailwind utilities;
@layer base { @layer base {
:root { /* ===== Theme 1: Ocean Deep (深海蓝) — Default ===== */
--background: 222 47% 4%; :root,
[data-theme="ocean-deep"] {
--background: 220 13% 5%;
--foreground: 210 40% 98%; --foreground: 210 40% 98%;
--card: 222 47% 6%; --card: 220 13% 7%;
--card-foreground: 210 40% 98%; --card-foreground: 210 40% 98%;
--popover: 222 47% 6%; --popover: 220 13% 7%;
--popover-foreground: 210 40% 98%; --popover-foreground: 210 40% 98%;
--primary: 217 91% 60%; --primary: 217 91% 60%;
--primary-foreground: 210 40% 98%; --primary-foreground: 0 0% 100%;
--secondary: 217 33% 17%; --secondary: 220 13% 14%;
--secondary-foreground: 210 40% 98%; --secondary-foreground: 210 40% 98%;
--muted: 217 33% 17%; --muted: 220 13% 14%;
--muted-foreground: 215 20% 65%; --muted-foreground: 215 20% 55%;
--accent: 217 33% 17%; --accent: 220 13% 14%;
--accent-foreground: 210 40% 98%; --accent-foreground: 210 40% 98%;
--destructive: 0 84% 60%; --destructive: 0 62% 50%;
--destructive-foreground: 210 40% 98%; --destructive-foreground: 0 0% 100%;
--border: 217 33% 15%; --border: 220 13% 18%;
--input: 217 33% 15%; --input: 220 13% 14%;
--ring: 217 91% 60%; --ring: 217 91% 60%;
--radius: 0.5rem; --radius: 0.625rem;
--sidebar-background: 222 47% 6%; --sidebar-background: 220 13% 7%;
--sidebar-foreground: 210 40% 98%; --sidebar-foreground: 210 40% 90%;
--sidebar-primary: 217 91% 60%; --sidebar-primary: 217 91% 60%;
--sidebar-primary-foreground: 210 40% 98%; --sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 217 33% 17%; --sidebar-accent: 220 13% 14%;
--sidebar-accent-foreground: 210 40% 98%; --sidebar-accent-foreground: 210 40% 98%;
--sidebar-border: 217 33% 15%; --sidebar-border: 220 13% 18%;
--sidebar-ring: 217 91% 60%; --sidebar-ring: 217 91% 60%;
--dg-bg-primary: #0d1117;
--dg-bg-secondary: #161b22;
--dg-bg-tertiary: #21262d;
--dg-bg-hover: #30363d;
--dg-border: #30363d;
--dg-border-light: #21262d;
--dg-text-primary: #f0f6fc;
--dg-text-secondary: #8b949e;
--dg-text-muted: #6e7681;
--dg-blue: #58a6ff;
--dg-green: #3fb950;
--dg-red: #f85149;
--dg-orange: #f0883e;
--dg-purple: #8957e5;
--dg-cyan: #39c5cf;
--dg-yellow: #d29922;
--dg-card-hover-border: rgba(88, 166, 255, 0.5);
--dg-gradient-start: #2563eb;
--dg-gradient-end: #9333ea;
--dg-gradient-text-start: #60a5fa;
--dg-gradient-text-end: #c084fc;
--dg-input-focus-ring: rgba(96, 165, 250, 0.2);
}
/* ===== Theme 2: Light Professional (明亮白) ===== */
[data-theme="light-professional"],
[data-theme="light"] {
--background: 0 0% 100%;
--foreground: 220 14% 10%;
--card: 0 0% 100%;
--card-foreground: 220 14% 10%;
--popover: 0 0% 100%;
--popover-foreground: 220 14% 10%;
--primary: 217 91% 60%;
--primary-foreground: 0 0% 100%;
--secondary: 220 14% 96%;
--secondary-foreground: 220 14% 10%;
--muted: 220 14% 96%;
--muted-foreground: 220 10% 46%;
--accent: 220 14% 96%;
--accent-foreground: 220 14% 10%;
--destructive: 0 72% 51%;
--destructive-foreground: 0 0% 100%;
--border: 220 13% 91%;
--input: 220 13% 91%;
--ring: 217 91% 60%;
--radius: 0.625rem;
--sidebar-background: 0 0% 98%;
--sidebar-foreground: 220 14% 20%;
--sidebar-primary: 217 91% 60%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 220 14% 94%;
--sidebar-accent-foreground: 220 14% 10%;
--sidebar-border: 220 13% 91%;
--sidebar-ring: 217 91% 60%;
--dg-bg-primary: #ffffff;
--dg-bg-secondary: #f8f9fb;
--dg-bg-tertiary: #f0f1f5;
--dg-bg-hover: #e8eaef;
--dg-border: #e2e5eb;
--dg-border-light: #eaecf0;
--dg-text-primary: #1a1d23;
--dg-text-secondary: #5c6370;
--dg-text-muted: #9ca3af;
--dg-blue: #2563eb;
--dg-green: #16a34a;
--dg-red: #dc2626;
--dg-orange: #ea580c;
--dg-purple: #7c3aed;
--dg-cyan: #0891b2;
--dg-yellow: #ca8a04;
--dg-card-hover-border: rgba(37, 99, 235, 0.3);
--dg-gradient-start: #2563eb;
--dg-gradient-end: #7c3aed;
--dg-gradient-text-start: #1d4ed8;
--dg-gradient-text-end: #6d28d9;
--dg-input-focus-ring: rgba(37, 99, 235, 0.15);
}
/* ===== Theme 3: Midnight Purple (午夜紫) ===== */
[data-theme="midnight-purple"] {
--background: 264 56% 4%;
--foreground: 280 100% 98%;
--card: 264 48% 8%;
--card-foreground: 280 100% 98%;
--popover: 264 48% 8%;
--popover-foreground: 280 100% 98%;
--primary: 270 91% 65%;
--primary-foreground: 0 0% 100%;
--secondary: 264 40% 20%;
--secondary-foreground: 280 100% 98%;
--muted: 264 40% 20%;
--muted-foreground: 280 30% 60%;
--accent: 264 40% 20%;
--accent-foreground: 280 100% 98%;
--destructive: 348 100% 72%;
--destructive-foreground: 264 56% 4%;
--border: 264 40% 20%;
--input: 264 40% 20%;
--ring: 270 91% 65%;
--radius: 0.625rem;
--sidebar-background: 264 48% 6%;
--sidebar-foreground: 280 100% 92%;
--sidebar-primary: 270 91% 65%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 264 40% 20%;
--sidebar-accent-foreground: 280 100% 98%;
--sidebar-border: 264 40% 20%;
--sidebar-ring: 270 91% 65%;
--dg-bg-primary: #0a0612;
--dg-bg-secondary: #130c21;
--dg-bg-tertiary: #1c1330;
--dg-bg-hover: #2d1f4a;
--dg-border: #2d1f4a;
--dg-border-light: #1c1330;
--dg-text-primary: #faf5ff;
--dg-text-secondary: #b794c7;
--dg-text-muted: #7c5a8a;
--dg-blue: #818cf8;
--dg-green: #34d399;
--dg-red: #fb7185;
--dg-orange: #fb923c;
--dg-purple: #c084fc;
--dg-cyan: #22d3ee;
--dg-yellow: #facc15;
--dg-card-hover-border: rgba(192, 132, 252, 0.5);
--dg-gradient-start: #9333ea;
--dg-gradient-end: #c084fc;
--dg-gradient-text-start: #a855f7;
--dg-gradient-text-end: #c084fc;
--dg-input-focus-ring: rgba(192, 132, 252, 0.2);
}
/* ===== Theme 4: Pine Green (松林绿) ===== */
[data-theme="pine-green"] {
--background: 150 25% 5%;
--foreground: 140 60% 97%;
--card: 150 25% 9%;
--card-foreground: 140 60% 97%;
--popover: 150 25% 9%;
--popover-foreground: 140 60% 97%;
--primary: 142 71% 45%;
--primary-foreground: 150 25% 5%;
--secondary: 150 20% 18%;
--secondary-foreground: 140 60% 97%;
--muted: 150 20% 18%;
--muted-foreground: 150 20% 55%;
--accent: 150 20% 18%;
--accent-foreground: 140 60% 97%;
--destructive: 0 90% 72%;
--destructive-foreground: 150 25% 5%;
--border: 150 20% 18%;
--input: 150 20% 18%;
--ring: 142 71% 45%;
--radius: 0.625rem;
--sidebar-background: 150 25% 7%;
--sidebar-foreground: 140 40% 88%;
--sidebar-primary: 142 71% 45%;
--sidebar-primary-foreground: 150 25% 5%;
--sidebar-accent: 150 20% 18%;
--sidebar-accent-foreground: 140 60% 97%;
--sidebar-border: 150 20% 18%;
--sidebar-ring: 142 71% 45%;
--dg-bg-primary: #0a1210;
--dg-bg-secondary: #111f1a;
--dg-bg-tertiary: #192c24;
--dg-bg-hover: #253d32;
--dg-border: #253d32;
--dg-border-light: #192c24;
--dg-text-primary: #f0fdf4;
--dg-text-secondary: #86b8a0;
--dg-text-muted: #5a7c6b;
--dg-blue: #38bdf8;
--dg-green: #4ade80;
--dg-red: #f87171;
--dg-orange: #fb923c;
--dg-purple: #c084fc;
--dg-cyan: #2dd4bf;
--dg-yellow: #facc15;
--dg-card-hover-border: rgba(74, 222, 128, 0.5);
--dg-gradient-start: #059669;
--dg-gradient-end: #10b981;
--dg-gradient-text-start: #059669;
--dg-gradient-text-end: #34d399;
--dg-input-focus-ring: rgba(74, 222, 128, 0.2);
}
/* ===== Theme 5: Orange Vitality (活力橙) ===== */
[data-theme="orange-vitality"] {
--background: 30 35% 5%;
--foreground: 30 100% 97%;
--card: 30 30% 9%;
--card-foreground: 30 100% 97%;
--popover: 30 30% 9%;
--popover-foreground: 30 100% 97%;
--primary: 25 95% 53%;
--primary-foreground: 0 0% 100%;
--secondary: 30 25% 18%;
--secondary-foreground: 30 100% 97%;
--muted: 30 25% 18%;
--muted-foreground: 30 25% 55%;
--accent: 30 25% 18%;
--accent-foreground: 30 100% 97%;
--destructive: 0 80% 70%;
--destructive-foreground: 30 35% 5%;
--border: 30 25% 18%;
--input: 30 25% 18%;
--ring: 25 95% 53%;
--radius: 0.625rem;
--sidebar-background: 30 30% 7%;
--sidebar-foreground: 30 80% 88%;
--sidebar-primary: 25 95% 53%;
--sidebar-primary-foreground: 0 0% 100%;
--sidebar-accent: 30 25% 18%;
--sidebar-accent-foreground: 30 100% 97%;
--sidebar-border: 30 25% 18%;
--sidebar-ring: 25 95% 53%;
--dg-bg-primary: #110d08;
--dg-bg-secondary: #1d1610;
--dg-bg-tertiary: #2b1f16;
--dg-bg-hover: #3d2d20;
--dg-border: #3d2d20;
--dg-border-light: #2b1f16;
--dg-text-primary: #fef7ed;
--dg-text-secondary: #d4a574;
--dg-text-muted: #9c7a54;
--dg-blue: #60a5fa;
--dg-green: #4ade80;
--dg-red: #f87171;
--dg-orange: #fb923c;
--dg-purple: #a78bfa;
--dg-cyan: #2dd4bf;
--dg-yellow: #fcd34d;
--dg-card-hover-border: rgba(251, 146, 60, 0.5);
--dg-gradient-start: #ea580c;
--dg-gradient-end: #f59e0b;
--dg-gradient-text-start: #f97316;
--dg-gradient-text-end: #fbbf24;
--dg-input-focus-ring: rgba(251, 146, 60, 0.2);
} }
} }
@@ -45,37 +286,144 @@
} }
body { body {
@apply bg-slate-950 text-slate-100 antialiased; @apply bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] antialiased;
font-feature-settings: "rlig" 1, "calt" 1; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Noto Sans SC', sans-serif;
font-feature-settings: 'rlig' 1, 'calt' 1;
transition: background-color 0.3s ease, color 0.3s ease;
}
/* Fix: Radix Dialog scroll-lock adds padding-right to body, causing layout shift. */
body[data-scroll-locked],
body[data-radix-scroll-locked],
body[style*='padding-right'] {
padding-right: 0 !important;
}
code,
pre,
.font-mono {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
} }
} }
@layer utilities { @layer utilities {
/* Custom scrollbar */ /* ===== Design System Card / Input ===== */
.scrollbar-thin { .dg-card {
scrollbar-width: thin; @apply bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)] rounded-xl;
scrollbar-color: rgba(100, 116, 139, 0.5) transparent; transition: background-color 0.3s ease, border-color 0.3s ease;
}
.dg-card-hover {
@apply transition-all duration-300 hover:-translate-y-0.5;
}
.dg-card-hover:hover {
border-color: var(--dg-card-hover-border);
}
.dg-input {
@apply bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] placeholder:text-[var(--dg-text-muted)] focus:border-[var(--dg-blue)] focus:ring-1;
}
.dg-input:focus {
--tw-ring-color: var(--dg-input-focus-ring);
}
.dg-text-muted {
color: var(--dg-text-muted);
}
.dg-text-secondary {
color: var(--dg-text-secondary);
}
.dg-gradient-text {
background: linear-gradient(to right, var(--dg-gradient-text-start), var(--dg-gradient-text-end));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.dg-gradient-bg {
background: linear-gradient(to right, var(--dg-gradient-start), var(--dg-gradient-end));
}
.dg-gradient-br {
background: linear-gradient(to bottom right, var(--dg-gradient-start), var(--dg-gradient-end));
} }
/* ===== Buttons ===== */
.dg-btn {
@apply inline-flex items-center justify-center gap-2 px-4 py-2 rounded-lg text-sm font-medium transition-all duration-200;
}
.dg-btn-primary {
@apply dg-btn dg-gradient-bg text-white hover:opacity-90 active:scale-[0.98];
}
.dg-btn-secondary {
@apply dg-btn bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] active:scale-[0.98];
}
.dg-btn-ghost {
@apply dg-btn text-[var(--dg-text-secondary)] hover:bg-[var(--dg-bg-hover)] hover:text-[var(--dg-text-primary)];
}
.dg-btn-danger {
@apply dg-btn bg-red-500/10 text-red-400 hover:bg-red-500/20 active:scale-[0.98];
}
.dg-btn-sm {
@apply px-3 py-1.5 text-xs;
}
.dg-btn-lg {
@apply px-6 py-3 text-base;
}
/* ===== Badges ===== */
.dg-badge {
@apply inline-flex items-center px-2 py-0.5 rounded-md text-xs font-medium;
}
.dg-badge-blue {
@apply dg-badge bg-blue-500/10 text-blue-400;
}
.dg-badge-green {
@apply dg-badge bg-green-500/10 text-green-400;
}
.dg-badge-red {
@apply dg-badge bg-red-500/10 text-red-400;
}
.dg-badge-yellow {
@apply dg-badge bg-yellow-500/10 text-yellow-400;
}
.dg-badge-purple {
@apply dg-badge bg-purple-500/10 text-purple-400;
}
.dg-badge-orange {
@apply dg-badge bg-orange-500/10 text-orange-400;
}
.dg-badge-cyan {
@apply dg-badge bg-cyan-500/10 text-cyan-400;
}
.dg-badge-muted {
@apply dg-badge bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-secondary)];
}
/* ===== Scrollbar ===== */
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: var(--dg-border) transparent;
}
.scrollbar-thin::-webkit-scrollbar { .scrollbar-thin::-webkit-scrollbar {
width: 6px; width: 6px;
height: 6px; height: 6px;
} }
.scrollbar-thin::-webkit-scrollbar-track { .scrollbar-thin::-webkit-scrollbar-track {
background: transparent; background: transparent;
} }
.scrollbar-thin::-webkit-scrollbar-thumb { .scrollbar-thin::-webkit-scrollbar-thumb {
background-color: rgba(100, 116, 139, 0.5); background: var(--dg-border);
border-radius: 3px; border-radius: 3px;
} }
.scrollbar-thin::-webkit-scrollbar-thumb:hover { .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: rgba(100, 116, 139, 0.7); background: var(--dg-text-muted);
}
[data-theme='light-professional'] .scrollbar-thin::-webkit-scrollbar-thumb,
[data-theme='light'] .scrollbar-thin::-webkit-scrollbar-thumb {
background: #d1d5db;
}
[data-theme='light-professional'] .scrollbar-thin::-webkit-scrollbar-thumb:hover,
[data-theme='light'] .scrollbar-thin::-webkit-scrollbar-thumb:hover {
background: #9ca3af;
} }
/* Animation utilities */ /* ===== Animations ===== */
@keyframes pulse-ring { @keyframes pulse-ring {
0% { 0% {
transform: scale(0.8); transform: scale(0.8);
@@ -86,49 +434,76 @@
opacity: 0; opacity: 0;
} }
} }
.animate-pulse-ring { .animate-pulse-ring {
animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; animation: pulse-ring 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
} }
/* Glass effect */ @keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes scaleIn {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}
/* ===== Glass effect ===== */
.glass { .glass {
background: rgba(15, 23, 42, 0.8); background: rgba(15, 23, 42, 0.8);
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
} }
/* Gradient text */ /* ===== Status indicators ===== */
.gradient-text {
background: linear-gradient(135deg, #3b82f6 0%, #06b6d4 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
/* Status indicator */
.status-dot { .status-dot {
@apply w-2 h-2 rounded-full; @apply w-2 h-2 rounded-full;
} }
.status-dot-running { .status-dot-running {
@apply bg-emerald-400 animate-pulse; @apply bg-emerald-400 animate-pulse;
} }
.status-dot-stopped { .status-dot-stopped {
@apply bg-slate-400; @apply bg-slate-400;
} }
.status-dot-warning { .status-dot-warning {
@apply bg-amber-400; @apply bg-amber-400;
} }
.status-dot-error { .status-dot-error {
@apply bg-red-400; @apply bg-red-400;
} }
/* Badge variants (legacy) */
.badge-critical {
@apply border-red-500/30 text-red-400 bg-red-500/10;
}
.badge-warning {
@apply border-amber-500/30 text-amber-400 bg-amber-500/10;
}
.badge-success {
@apply border-emerald-500/30 text-emerald-400 bg-emerald-500/10;
}
.badge-info {
@apply border-blue-500/30 text-blue-400 bg-blue-500/10;
}
/* Card hover effect (legacy) */
.card-hover {
@apply transition-all duration-300 hover:shadow-lg hover:shadow-slate-900/50 hover:-translate-y-0.5;
}
} }
/* Code block styling */ /* ===== Global element styling (kept for backward compatibility) ===== */
pre { pre {
@apply bg-slate-950 rounded-lg p-4 overflow-x-auto; @apply bg-slate-950 rounded-lg p-4 overflow-x-auto;
} }
@@ -137,7 +512,6 @@ code {
@apply font-mono text-sm; @apply font-mono text-sm;
} }
/* Table styling */
table { table {
@apply w-full; @apply w-full;
} }
@@ -150,36 +524,26 @@ td {
@apply text-sm; @apply text-sm;
} }
/* Form element focus states */
input:focus, input:focus,
textarea:focus, textarea:focus,
select:focus { select:focus {
@apply outline-none ring-2 ring-blue-500/30 border-blue-500/50; @apply outline-none ring-2 ring-blue-500/30 border-blue-500/50;
} }
/* Button hover transitions */
button { button {
@apply transition-all duration-200; @apply transition-all duration-200;
} }
/* Card hover effect */
.card-hover {
@apply transition-all duration-300 hover:shadow-lg hover:shadow-slate-900/50 hover:-translate-y-0.5;
}
/* Terminal styling */ /* Terminal styling */
.terminal { .terminal {
@apply bg-slate-950 font-mono text-sm text-slate-300; @apply bg-slate-950 font-mono text-sm text-slate-300;
} }
.terminal .prompt { .terminal .prompt {
@apply text-emerald-400; @apply text-emerald-400;
} }
.terminal .command { .terminal .command {
@apply text-slate-100; @apply text-slate-100;
} }
.terminal .output { .terminal .output {
@apply text-slate-400; @apply text-slate-400;
} }
@@ -190,32 +554,15 @@ button {
} }
/* Dialog overlay */ /* Dialog overlay */
[data-state="open"] > [data-radix-dialog-overlay] { [data-state='open'] > [data-radix-dialog-overlay] {
@apply bg-slate-950/80 backdrop-blur-sm; @apply bg-slate-950/80 backdrop-blur-sm;
} }
/* Dropdown menu styling */ /* Dropdown menu styling */
[role="menuitem"] { [role='menuitem'] {
@apply cursor-pointer; @apply cursor-pointer;
} }
/* Badge variants */
.badge-critical {
@apply border-red-500/30 text-red-400 bg-red-500/10;
}
.badge-warning {
@apply border-amber-500/30 text-amber-400 bg-amber-500/10;
}
.badge-success {
@apply border-emerald-500/30 text-emerald-400 bg-emerald-500/10;
}
.badge-info {
@apply border-blue-500/30 text-blue-400 bg-blue-500/10;
}
/* Progress bar animation */ /* Progress bar animation */
@keyframes progress-indeterminate { @keyframes progress-indeterminate {
0% { 0% {
+385 -415
View File
@@ -1,456 +1,426 @@
// API Service for AIOps Platform // API Service for AIOps Platform
// 有后端支持的模块调用真实 API,无后端的模块暂用 mock 数据
import { http, type ListResponse } from './http';
import * as mockData from '@/mocks/data'; import * as mockData from '@/mocks/data';
// Mock API delay // ==================== 用户认证 API ====================
const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); export const userApi = {
login: async (username: string, password: string) => {
return http.post<{ token: string; expires_in: number; user: any }>(
'/auth/login',
{ username, password }
);
},
register: async (data: { username: string; password: string; nickname?: string; email?: string }) => {
return http.post('/auth/register', data);
},
getProfile: async () => {
return http.get('/user/profile');
},
updateProfile: async (data: any) => {
return http.put('/user/profile', data);
},
changePassword: async (data: { old_password: string; new_password: string }) => {
return http.put('/user/password', data);
},
getUsers: async (params?: { page?: number; page_size?: number; keyword?: string }) => {
return http.get<ListResponse>('/admin/users', params);
},
createUser: async (data: any) => {
return http.post('/admin/users', data);
},
updateUser: async (id: number | string, data: any) => {
return http.put(`/admin/users/${id}`, data);
},
deleteUser: async (id: number | string) => {
return http.delete(`/admin/users/${id}`);
},
};
// SQL Audit API // ==================== 仪表盘 API ====================
export const sqlAuditApi = { export const dashboardStatsApi = {
getRecords: async () => { getStats: async () => {
await delay(300); return http.get('/dashboard/stats');
return { data: mockData.sqlAuditRecords as any }; },
getAlerts: async (limit?: number) => {
return http.get('/dashboard/recent-alerts', limit ? { limit } : undefined);
},
getReleases: async (limit?: number) => {
return http.get('/dashboard/recent-releases', limit ? { limit } : undefined);
},
getMetrics: async (hours?: number) => {
return http.get('/dashboard/metrics', hours ? { hours } : undefined);
},
};
// ==================== 服务器管理 API ====================
export const serverApi = {
getServers: async (params?: { page?: number; page_size?: number; keyword?: string; status?: string }) => {
return http.get<ListResponse>('/servers', params);
},
getServerDetail: async (id: number | string) => {
return http.get(`/servers/${id}`);
},
getServerStats: async () => {
return http.get('/servers/stats');
},
createServer: async (data: any) => {
return http.post('/servers', data);
},
updateServer: async (id: number | string, data: any) => {
return http.put(`/servers/${id}`, data);
},
deleteServer: async (id: number | string) => {
return http.delete(`/servers/${id}`);
},
batchDelete: async (ids: number[]) => {
return http.post('/servers/batch-delete', { ids });
},
getMetrics: async (id: number | string) => {
// 后端暂无单机 metrics,用 dashboard metrics
return http.get('/dashboard/metrics');
},
};
// ==================== K8s 集群 API ====================
export const k8sApi = {
getClusters: async () => {
return http.get<ListResponse>('/k8s-clusters');
},
getClusterDetail: async (id: number | string) => {
return http.get(`/k8s-clusters/${id}`);
},
createCluster: async (data: any) => {
return http.post('/k8s-clusters', data);
},
updateCluster: async (id: number | string, data: any) => {
return http.put(`/k8s-clusters/${id}`, data);
},
deleteCluster: async (id: number | string) => {
return http.delete(`/k8s-clusters/${id}`);
},
getNodes: async (clusterId: number | string) => {
return http.get(`/k8s-clusters/${clusterId}/nodes`);
},
getPods: async (clusterId: number | string) => {
return http.get(`/k8s-clusters/${clusterId}/pods`);
},
getDeployments: async (clusterId: number | string) => {
return http.get(`/k8s-clusters/${clusterId}/deployments`);
},
getServices: async (clusterId: number | string) => {
return http.get(`/k8s-clusters/${clusterId}/services`);
},
getIngresses: async (clusterId: number | string) => {
return http.get(`/k8s-clusters/${clusterId}/ingresses`);
},
scaleDeployment: async (_clusterId: string, _namespace: string, _name: string, _replicas: number) => {
return { success: true };
},
restartDeployment: async (_clusterId: string, _namespace: string, _name: string) => {
return { success: true };
},
deletePod: async (_clusterId: string, _namespace: string, _name: string) => {
return { success: true };
},
};
// ==================== 告警 API ====================
export const alertApi = {
getAlerts: async (params?: { page?: number; page_size?: number; status?: string; severity?: string }) => {
return http.get<ListResponse>('/alerts', params);
}, },
getRules: async () => { getRules: async () => {
await delay(200); return http.get('/alerts/rules');
return { data: mockData.sqlAuditRules as any };
}, },
createRule: async (data: any) => {
return http.post('/alerts/rules', data);
},
updateRule: async (id: number | string, data: any) => {
return http.put(`/alerts/rules/${id}`, data);
},
deleteRule: async (id: number | string) => {
return http.delete(`/alerts/rules/${id}`);
},
toggleRule: async (id: number | string) => {
return http.post(`/alerts/rules/${id}/toggle`);
},
acknowledge: async (id: number | string) => {
return http.post(`/alerts/${id}/ack`);
},
};
// ==================== 流水线 API ====================
export const pipelineApi = {
getPipelines: async (params?: { page?: number; page_size?: number }) => {
return http.get<ListResponse>('/pipelines', params);
},
getPipelineDetail: async (id: number | string) => {
return http.get(`/pipelines/${id}`);
},
createPipeline: async (data: any) => {
return http.post('/pipelines', data);
},
updatePipeline: async (id: number | string, data: any) => {
return http.put(`/pipelines/${id}`, data);
},
deletePipeline: async (id: number | string) => {
return http.delete(`/pipelines/${id}`);
},
triggerPipeline: async (id: number | string) => {
return http.post(`/pipelines/${id}/trigger`);
},
getRuns: async (id: number | string) => {
return http.get(`/pipelines/${id}/runs`);
},
};
// ==================== 应用发布 API ====================
export const releaseApi = {
getReleases: async (params?: { page?: number; page_size?: number }) => {
return http.get<ListResponse>('/releases', params);
},
createRelease: async (data: any) => {
return http.post('/releases', data);
},
approveRelease: async (id: number | string) => {
return http.post(`/releases/${id}/approve`);
},
deployRelease: async (id: number | string) => {
return http.post(`/releases/${id}/deploy`);
},
rollbackRelease: async (id: number | string) => {
return http.post(`/releases/${id}/rollback`);
},
};
// ==================== 定时任务 API ====================
export const cronJobApi = {
getJobs: async (params?: { page?: number; page_size?: number }) => {
return http.get<ListResponse>('/cron-jobs', params);
},
getJob: async (id: number | string) => {
return http.get(`/cron-jobs/${id}`);
},
createJob: async (data: any) => {
return http.post('/cron-jobs', data);
},
updateJob: async (id: number | string, data: any) => {
return http.put(`/cron-jobs/${id}`, data);
},
deleteJob: async (id: number | string) => {
return http.delete(`/cron-jobs/${id}`);
},
toggleJob: async (id: number | string) => {
return http.post(`/cron-jobs/${id}/toggle`);
},
executeJob: async (id: number | string) => {
return http.post(`/cron-jobs/${id}/execute`);
},
};
// ==================== 脚本 API ====================
export const scriptApi = {
getScripts: async (params?: { page?: number; page_size?: number }) => {
return http.get<ListResponse>('/scripts', params);
},
getScript: async (id: number | string) => {
return http.get(`/scripts/${id}`);
},
createScript: async (data: any) => {
return http.post('/scripts', data);
},
updateScript: async (id: number | string, data: any) => {
return http.put(`/scripts/${id}`, data);
},
deleteScript: async (id: number | string) => {
return http.delete(`/scripts/${id}`);
},
executeScript: async (id: number | string) => {
return http.post(`/scripts/${id}/execute`);
},
};
// ==================== 审批流程 API ====================
export const approvalApi = {
getTemplates: async () => {
return http.get('/approvals/templates');
},
getTemplate: async (id: number | string) => {
return http.get(`/approvals/templates/${id}`);
},
createTemplate: async (data: any) => {
return http.post('/approvals/templates', data);
},
updateTemplate: async (id: number | string, data: any) => {
return http.put(`/approvals/templates/${id}`, data);
},
deleteTemplate: async (id: number | string) => {
return http.delete(`/approvals/templates/${id}`);
},
getProcesses: async (params?: { page?: number; page_size?: number; status?: string }) => {
return http.get('/approvals/processes', params);
},
createProcess: async (data: any) => {
return http.post('/approvals/processes', data);
},
approveProcess: async (id: number | string, comment?: string) => {
return http.post(`/approvals/processes/${id}/approve`, { comment });
},
rejectProcess: async (id: number | string, comment?: string) => {
return http.post(`/approvals/processes/${id}/reject`, { comment });
},
};
// ==================== 导航管理 API ====================
export const navigationApi = {
getGroups: async () => {
return http.get('/navigations/groups');
},
createGroup: async (data: any) => {
return http.post('/navigations/groups', data);
},
updateGroup: async (id: number | string, data: any) => {
return http.put(`/navigations/groups/${id}`, data);
},
deleteGroup: async (id: number | string) => {
return http.delete(`/navigations/groups/${id}`);
},
getLinks: async () => {
return http.get('/navigations/links');
},
createLink: async (data: any) => {
return http.post('/navigations/links', data);
},
updateLink: async (id: number | string, data: any) => {
return http.put(`/navigations/links/${id}`, data);
},
deleteLink: async (id: number | string) => {
return http.delete(`/navigations/links/${id}`);
},
toggleFavorite: async (id: number | string) => {
return http.post(`/navigations/links/${id}/favorite`);
},
};
// ==================== 云管理 API ====================
export const cloudApi = {
getAccounts: async () => {
return http.get<ListResponse>('/cloud-accounts');
},
createAccount: async (data: any) => {
return http.post('/cloud-accounts', data);
},
deleteAccount: async (id: number | string) => {
return http.delete(`/cloud-accounts/${id}`);
},
getDomains: async (params?: { page?: number; page_size?: number; keyword?: string }) => {
return http.get<ListResponse>('/domains', params);
},
createDomain: async (data: any) => {
return http.post('/domains', data);
},
deleteDomain: async (id: number | string) => {
return http.delete(`/domains/${id}`);
},
};
// ==================== 审计日志 API ====================
export const auditApi = {
getLogs: async (params?: { page?: number; page_size?: number; module?: string; action?: string }) => {
return http.get<ListResponse>('/audit/logs', params);
},
getModules: async () => {
return http.get('/audit/modules');
},
getActions: async () => {
return http.get('/audit/actions');
},
getStatistics: async () => {
return http.get('/audit/statistics');
},
clearLogs: async () => {
return http.post('/audit/logs/clear');
},
};
// ==================== 以下模块暂无后端,使用 Mock 数据 ====================
// SQL Audit API(无后端)
export const sqlAuditApi = {
getRecords: async () => ({ data: mockData.sqlAuditRecords as any }),
getRules: async () => ({ data: mockData.sqlAuditRules as any }),
analyzeSQL: async (data: { sql: string }) => { analyzeSQL: async (data: { sql: string }) => {
await delay(500);
return { data: { id: Date.now().toString(), riskLevel: 'medium', rules: ['规则1'] } }; return { data: { id: Date.now().toString(), riskLevel: 'medium', rules: ['规则1'] } };
}, },
approve: async (id: string) => { approve: async (id: string) => ({ success: true }),
await delay(200); reject: async (id: string) => ({ success: true }),
return { success: true }; execute: async (id: string) => ({ success: true }),
}, toggleRule: async (ruleId: string, enabled: boolean) => ({ success: true }),
reject: async (id: string) => {
await delay(200);
return { success: true };
},
execute: async (id: string) => {
await delay(500);
return { success: true };
},
toggleRule: async (ruleId: string, enabled: boolean) => {
await delay(200);
return { success: true };
}
}; };
// Ansible API // Ansible API(无后端)
export const ansibleApi = { export const ansibleApi = {
getPlaybooks: async () => { getPlaybooks: async () => ({ data: mockData.ansiblePlaybooks as any }),
await delay(300); getJobs: async () => ({ data: mockData.ansibleJobs as any }),
return { data: mockData.ansiblePlaybooks as any }; getInventories: async () => ({ data: mockData.ansibleInventories as any }),
}, createPlaybook: async (data: any) => ({ success: true, data: { id: Date.now().toString() } }),
getJobs: async () => { deletePlaybook: async (id: string) => ({ success: true }),
await delay(300); executePlaybook: async (id: string, config: any) => ({ success: true, data: { jobId: Date.now().toString() } }),
return { data: mockData.ansibleJobs as any };
},
getInventories: async () => {
await delay(200);
return { data: mockData.ansibleInventories as any };
},
createPlaybook: async (data: any) => {
await delay(300);
return { success: true, data: { id: Date.now().toString() } };
},
deletePlaybook: async (id: string) => {
await delay(200);
return { success: true };
},
executePlaybook: async (id: string, config: any) => {
await delay(300);
return { success: true, data: { jobId: Date.now().toString() } };
}
}; };
// Logs API // Logs API(无后端)
export const logsApi = { export const logsApi = {
query: async (query: any) => { query: async (query: any) => ({ data: mockData.logMonitorEntries as any }),
await delay(400); getSources: async () => ({ data: mockData.logSources as any }),
return { data: mockData.logMonitorEntries as any }; export: async (query: any) => ({ success: true }),
},
getSources: async () => {
await delay(200);
return { data: mockData.logSources as any };
},
export: async (query: any) => {
await delay(500);
return { success: true };
}
}; };
// Alert Center API // Alert Center API(无后端,独立于 alertApi)
export const alertCenterApi = { export const alertCenterApi = {
getAlerts: async () => { getAlerts: async () => ({ data: mockData.alertCenterAlerts as any }),
await delay(300); getRules: async () => ({ data: mockData.alertCenterRules as any }),
return { data: mockData.alertCenterAlerts as any }; getChannels: async () => ({ data: mockData.notificationChannels as any }),
}, acknowledge: async (id: string) => ({ success: true }),
getRules: async () => { resolve: async (id: string) => ({ success: true }),
await delay(200); silence: async (id: string, duration: string) => ({ success: true }),
return { data: mockData.alertCenterRules as any }; toggleRule: async (ruleId: string, enabled: boolean) => ({ success: true }),
},
getChannels: async () => {
await delay(200);
return { data: mockData.notificationChannels as any };
},
acknowledge: async (id: string) => {
await delay(200);
return { success: true };
},
resolve: async (id: string) => {
await delay(200);
return { success: true };
},
silence: async (id: string, duration: string) => {
await delay(200);
return { success: true };
},
toggleRule: async (ruleId: string, enabled: boolean) => {
await delay(200);
return { success: true };
}
}; };
// Trace API // Trace API(无后端)
export const traceApi = { export const traceApi = {
getTraces: async (params: any) => { getTraces: async (params: any) => ({ data: mockData.traces as any }),
await delay(400); getServices: async (timeRange: string) => ({ data: mockData.serviceMetrics as any }),
return { data: mockData.traces as any }; getTraceDetail: async (traceId: string) => ({ data: { spans: mockData.traceSpans as any } }),
},
getServices: async (timeRange: string) => {
await delay(300);
return { data: mockData.serviceMetrics as any };
},
getTraceDetail: async (traceId: string) => {
await delay(400);
return { data: { spans: mockData.traceSpans as any } };
}
}; };
// Dashboard API // Grafana Dashboard API(无后端)
export const dashboardApi = { export const dashboardApi = {
getDashboards: async () => { getDashboards: async () => ({ data: mockData.grafanaDashboards as any }),
await delay(300); getDatasources: async () => ({ data: mockData.datasources as any }),
return { data: mockData.grafanaDashboards as any }; createDashboard: async (data: any) => ({ success: true, data: { id: Date.now().toString() } }),
}, updateDashboard: async (id: string, data: any) => ({ success: true }),
getDatasources: async () => { deleteDashboard: async (id: string) => ({ success: true }),
await delay(200); createDatasource: async (data: any) => ({ success: true, data: { id: Date.now().toString() } }),
return { data: mockData.datasources as any };
},
createDashboard: async (data: any) => {
await delay(300);
return { success: true, data: { id: Date.now().toString() } };
},
updateDashboard: async (id: string, data: any) => {
await delay(300);
return { success: true };
},
deleteDashboard: async (id: string) => {
await delay(200);
return { success: true };
},
createDatasource: async (data: any) => {
await delay(300);
return { success: true, data: { id: Date.now().toString() } };
}
}; };
// AI Assistant API // AI Assistant API(无后端)
export const aiAssistantApi = { export const aiAssistantApi = {
chat: async (message: string, history: any[]) => { chat: async (message: string, history: any[]) => {
await delay(1000);
return { return {
data: { data: {
reply: `我收到了您的问题: "${message}"。作为AIOps智能助手,我可以帮您分析告警、诊断问题、生成报告等。`, reply: `我收到了您的问题: "${message}"。作为AIOps智能助手,我可以帮您分析告警、诊断问题、生成报告等。`,
suggestions: ['分析当前告警', '查看性能趋势', '生成运维报告'] suggestions: ['分析当前告警', '查看性能趋势', '生成运维报告'],
} },
}; };
}, },
analyzeAlert: async (alertId: string) => { analyzeAlert: async (alertId: string) => {
await delay(1500);
return { return {
data: { data: {
analysis: '根据告警信息分析,可能是由于数据库连接池耗尽导致的。建议检查数据库连接配置和当前连接数。', analysis: '根据告警信息分析,可能是由于数据库连接池耗尽导致的。建议检查数据库连接配置和当前连接数。',
recommendations: ['增加连接池大小', '检查慢查询', '重启应用服务'] recommendations: ['增加连接池大小', '检查慢查询', '重启应用服务'],
} },
}; };
}, },
generateReport: async (params: any) => { generateReport: async (params: any) => ({ data: { report: '运维报告已生成...' } }),
await delay(2000);
return { data: { report: '运维报告已生成...' } };
}
};
// Pipeline API
export const pipelineApi = {
getPipelines: async () => {
await delay(300);
return { data: mockData.pipelines as any };
},
getPipelineDetail: async (id: string) => {
await delay(300);
return { data: mockData.pipelines.find((p: any) => p.id === id) };
},
createPipeline: async (data: any) => {
await delay(300);
return { success: true, data: { id: Date.now().toString() } };
},
executePipeline: async (id: string) => {
await delay(300);
return { success: true, data: { executionId: Date.now().toString() } };
},
getExecutions: async () => {
await delay(300);
return { data: mockData.pipelineExecutions as any };
}
};
// Release API
export const releaseApi = {
getReleases: async () => {
await delay(300);
return { data: mockData.releases as any };
},
createRelease: async (data: any) => {
await delay(300);
return { success: true, data: { id: Date.now().toString() } };
},
approveRelease: async (id: string) => {
await delay(200);
return { success: true };
},
rejectRelease: async (id: string) => {
await delay(200);
return { success: true };
},
rollbackRelease: async (id: string) => {
await delay(300);
return { success: true };
}
};
// CronJob API
export const cronJobApi = {
getJobs: async () => {
await delay(300);
return { data: mockData.cronJobsList as any };
},
createJob: async (data: any) => {
await delay(300);
return { success: true, data: { id: Date.now().toString() } };
},
updateJob: async (id: string, data: any) => {
await delay(300);
return { success: true };
},
deleteJob: async (id: string) => {
await delay(200);
return { success: true };
},
executeJob: async (id: string) => {
await delay(300);
return { success: true };
},
toggleJob: async (id: string, enabled: boolean) => {
await delay(200);
return { success: true };
},
getExecutions: async (jobId: string) => {
await delay(300);
return { data: mockData.cronJobExecutions as any };
}
};
// K8s API
export const k8sApi = {
getClusters: async () => {
await delay(300);
return { data: mockData.k8sClusters as any };
},
getClusterDetail: async (id: string) => {
await delay(300);
return { data: mockData.k8sClusters.find((c: any) => c.id === id) };
},
getDeployments: async (clusterId: string) => {
await delay(300);
return { data: mockData.k8sDeployments as any };
},
getServices: async (clusterId: string) => {
await delay(300);
return { data: mockData.k8sServices as any };
},
getIngresses: async (clusterId: string) => {
await delay(300);
return { data: mockData.k8sIngresses as any };
},
getPods: async (clusterId: string, namespace?: string) => {
await delay(300);
return { data: mockData.k8sPods as any };
},
scaleDeployment: async (clusterId: string, namespace: string, name: string, replicas: number) => {
await delay(300);
return { success: true };
},
restartDeployment: async (clusterId: string, namespace: string, name: string) => {
await delay(300);
return { success: true };
},
deletePod: async (clusterId: string, namespace: string, name: string) => {
await delay(300);
return { success: true };
}
};
// Server API
export const serverApi = {
getServers: async () => {
await delay(300);
return { data: mockData.servers as any };
},
getServerDetail: async (id: string) => {
await delay(300);
return { data: mockData.servers.find((s: any) => s.id === id) };
},
createServer: async (data: any) => {
await delay(300);
return { success: true, data: { id: Date.now().toString() } };
},
updateServer: async (id: string, data: any) => {
await delay(300);
return { success: true };
},
deleteServer: async (id: string) => {
await delay(200);
return { success: true };
},
importFromCloud: async (provider: string, credentials: any) => {
await delay(1000);
return { success: true, data: { imported: 5 } };
},
importFromExcel: async (file: File) => {
await delay(1000);
return { success: true, data: { imported: 10 } };
},
getMetrics: async (id: string) => {
await delay(300);
return { data: mockData.serverMetrics as any };
}
};
// Dashboard Stats API
export const dashboardStatsApi = {
getStats: async () => {
await delay(300);
return { data: mockData.dashboardStats as any };
},
getAlerts: async () => {
await delay(200);
return { data: mockData.recentAlerts as any };
},
getTopCPU: async () => {
await delay(200);
return { data: mockData.topCPU as any };
},
getTopMemory: async () => {
await delay(200);
return { data: mockData.topMemory as any };
},
getTopDisk: async () => {
await delay(200);
return { data: mockData.topDisk as any };
},
getCostStats: async () => {
await delay(200);
return { data: mockData.costStats as any };
},
getCertExpiry: async () => {
await delay(200);
return { data: mockData.certExpiry as any };
}
};
// Cloud API
export const cloudApi = {
getProviders: async () => {
await delay(200);
return { data: mockData.cloudProvidersList as any };
},
getAccounts: async () => {
await delay(300);
return { data: mockData.cloudAccountsList as any };
},
getRegions: async () => {
await delay(200);
return { data: mockData.cloudRegionsList as any };
},
getDomains: async () => {
await delay(300);
return { data: mockData.domainsList as any };
}
};
// Navigation API
export const navigationApi = {
getLinks: async () => {
await delay(200);
return { data: mockData.navigationLinks as any };
},
createLink: async (data: any) => {
await delay(200);
return { success: true, data: { id: Date.now().toString() } };
},
updateLink: async (id: string, data: any) => {
await delay(200);
return { success: true };
},
deleteLink: async (id: string) => {
await delay(200);
return { success: true };
}
};
// Approval API
export const approvalApi = {
getFlows: async () => {
await delay(300);
return { data: mockData.approvalFlows as any };
},
getPendingApprovals: async () => {
await delay(300);
return { data: mockData.pendingApprovals as any };
},
approve: async (id: string, comment?: string) => {
await delay(200);
return { success: true };
},
reject: async (id: string, comment?: string) => {
await delay(200);
return { success: true };
}
};
// User API
export const userApi = {
login: async (username: string, password: string) => {
await delay(500);
return {
data: {
token: 'mock-jwt-token',
user: mockData.currentUser
}
};
},
getProfile: async () => {
await delay(200);
return { data: mockData.currentUser };
},
updateProfile: async (data: any) => {
await delay(200);
return { success: true };
},
getUsers: async () => {
await delay(300);
return { data: mockData.users as any };
}
}; };
+81
View File
@@ -0,0 +1,81 @@
import { useUserStore } from '@/store';
const BASE_URL = '/api/v1';
// 后端统一响应格式
export interface ApiResponse<T = any> {
code: number;
message: string;
data: T;
}
// 列表响应格式
export interface ListResponse<T = any> {
list: T[];
total: number;
}
// 通用请求函数
async function request<T = any>(
method: string,
url: string,
body?: any,
params?: Record<string, any>
): Promise<T> {
// 构建 URL(含 query params)
let fullUrl = `${BASE_URL}${url}`;
if (params) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
searchParams.append(key, String(value));
}
});
const qs = searchParams.toString();
if (qs) fullUrl += `?${qs}`;
}
// 获取 token
const token = useUserStore.getState().token;
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(fullUrl, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (response.status === 401) {
useUserStore.getState().logout();
window.location.href = '/login';
throw new Error('登录已过期,请重新登录');
}
const json: ApiResponse<T> = await response.json();
if (json.code && json.code !== 200) {
throw new Error(json.message || '请求失败');
}
return json.data;
}
// 导出便捷方法
export const http = {
get: <T = any>(url: string, params?: Record<string, any>) =>
request<T>('GET', url, undefined, params),
post: <T = any>(url: string, body?: any) =>
request<T>('POST', url, body),
put: <T = any>(url: string, body?: any) =>
request<T>('PUT', url, body),
delete: <T = any>(url: string) =>
request<T>('DELETE', url),
};
export default http;
+2
View File
@@ -18,6 +18,7 @@ interface UserState {
user: User | null; user: User | null;
token: string | null; token: string | null;
isAuthenticated: boolean; isAuthenticated: boolean;
theme?: string;
setUser: (user: User | null) => void; setUser: (user: User | null) => void;
setToken: (token: string | null) => void; setToken: (token: string | null) => void;
login: (user: User, token: string) => void; login: (user: User, token: string) => void;
@@ -30,6 +31,7 @@ export const useUserStore = create<UserState>()(
user: null, user: null,
token: null, token: null,
isAuthenticated: false, isAuthenticated: false,
theme: 'ocean-deep',
setUser: (user) => set({ user }), setUser: (user) => set({ user }),
setToken: (token) => set({ token }), setToken: (token) => set({ token }),
login: (user, token) => set({ user, token, isAuthenticated: true }), login: (user, token) => set({ user, token, isAuthenticated: true }),
+10 -5
View File
@@ -162,16 +162,21 @@ export interface AppRelease {
// 系统用户 // 系统用户
export interface User { export interface User {
id: string; id: number | string;
username: string; username: string;
nickname: string; nickname?: string;
real_name?: string;
email: string; email: string;
phone?: string; phone?: string;
avatar?: string; avatar?: string;
roles: string[]; roles?: string[];
status: 'active' | 'inactive'; status: number | 'active' | 'inactive';
is_admin?: boolean;
auth_source?: string;
last_login?: string;
lastLoginAt?: string; lastLoginAt?: string;
createdAt: string; createdAt?: string;
created_at?: string;
} }
// 角色 // 角色
+39 -12
View File
@@ -1,5 +1,5 @@
#!/bin/bash #!/bin/bash
# EZOps AIOps 启动脚本 # EZOps AIOps 智能运维平台 - 启动脚本
# 一键启动前后端服务,支持日志轮转 # 一键启动前后端服务,支持日志轮转
# 用法: ./run.sh [start|stop|restart|status|frontend|backend|help] # 用法: ./run.sh [start|stop|restart|status|frontend|backend|help]
@@ -77,24 +77,20 @@ rotate_log() {
start_backend() { start_backend() {
info "正在启动后端服务..." info "正在启动后端服务..."
cd "$SCRIPT_DIR/$BACKEND_DIR" cd "$SCRIPT_DIR/$BACKEND_DIR"
mkdir -p logs data mkdir -p logs
rotate_log "logs/server.log" rotate_log "logs/server.log"
# 释放被占用的端口 # 释放被占用的端口
kill_port $BACKEND_PORT kill_port $BACKEND_PORT
export PORT=$BACKEND_PORT
export GIN_MODE=debug export GIN_MODE=debug
# 确保使用国内代理 # 编译并运行(SQLite 需要 CGO)
export GOPROXY=https://goproxy.cn,direct
# 编译并运行
info "编译 Go 程序..." info "编译 Go 程序..."
if ! go build -o "$BACKEND_BIN" cmd/main.go; then CGO_ENABLED=1 go build -o "$BACKEND_BIN" ./cmd/ 2>&1 || {
error "Go 编译失败" error "编译失败"
return 1 return 1
fi }
nohup ./"$BACKEND_BIN" >> logs/server.log 2>&1 & nohup ./"$BACKEND_BIN" >> logs/server.log 2>&1 &
echo $! > "$BACKEND_PID_FILE" echo $! > "$BACKEND_PID_FILE"
@@ -105,7 +101,7 @@ start_backend() {
retries=$((retries + 1)) retries=$((retries + 1))
if [ $retries -ge 30 ]; then if [ $retries -ge 30 ]; then
error "后端服务启动超时,请检查日志: $BACKEND_DIR/logs/server.log" error "后端服务启动超时,请检查日志: $BACKEND_DIR/logs/server.log"
tail -20 "$BACKEND_DIR/logs/server.log" 2>/dev/null tail -20 logs/server.log 2>/dev/null
return 1 return 1
fi fi
sleep 1 sleep 1
@@ -152,7 +148,7 @@ start_frontend() {
retries=$((retries + 1)) retries=$((retries + 1))
if [ $retries -ge 30 ]; then if [ $retries -ge 30 ]; then
error "前端服务启动超时,请检查日志: $FRONTEND_DIR/logs/dev.log" error "前端服务启动超时,请检查日志: $FRONTEND_DIR/logs/dev.log"
tail -20 "$FRONTEND_DIR/logs/dev.log" 2>/dev/null tail -20 logs/dev.log 2>/dev/null
return 1 return 1
fi fi
sleep 1 sleep 1
@@ -197,6 +193,12 @@ show_status() {
else else
warning "前端服务: 未运行" warning "前端服务: 未运行"
fi fi
# 显示数据库文件
if [ -f "$SCRIPT_DIR/$BACKEND_DIR/sqlite3.db" ]; then
local db_size=$(du -h "$SCRIPT_DIR/$BACKEND_DIR/sqlite3.db" | cut -f1)
info "数据库: SQLite (sqlite3.db, $db_size)"
fi
} }
show_help() { show_help() {
@@ -211,6 +213,7 @@ show_help() {
echo " backend 仅启动后端服务" echo " backend 仅启动后端服务"
echo " frontend 仅启动前端服务" echo " frontend 仅启动前端服务"
echo " status 查看服务状态" echo " status 查看服务状态"
echo " logs 查看实时日志"
echo " help 显示帮助信息" echo " help 显示帮助信息"
echo "" echo ""
echo "示例:" echo "示例:"
@@ -218,6 +221,26 @@ show_help() {
echo " ./run.sh stop # 停止服务" echo " ./run.sh stop # 停止服务"
echo " ./run.sh restart # 重启服务" echo " ./run.sh restart # 重启服务"
echo " ./run.sh backend # 仅启动后端" echo " ./run.sh backend # 仅启动后端"
echo " ./run.sh logs # 查看实时日志"
}
show_logs() {
info "实时日志 (Ctrl+C 退出):"
echo ""
# 后端日志
if [ -f "$SCRIPT_DIR/$BACKEND_DIR/logs/server.log" ]; then
echo "=== 后端日志 ==="
tail -f "$SCRIPT_DIR/$BACKEND_DIR/logs/server.log" &
local bg_pid=$!
fi
# 前端日志
if [ -f "$SCRIPT_DIR/$FRONTEND_DIR/logs/dev.log" ]; then
echo "=== 前端日志 ==="
tail -f "$SCRIPT_DIR/$FRONTEND_DIR/logs/dev.log" &
local bg_pid2=$!
fi
trap "kill $bg_pid $bg_pid2 2>/dev/null; exit 0" INT TERM
wait
} }
# ============================================================ # ============================================================
@@ -246,6 +269,7 @@ main() {
info "前端地址: http://localhost:$FRONTEND_PORT" info "前端地址: http://localhost:$FRONTEND_PORT"
info "后端地址: http://localhost:$BACKEND_PORT" info "后端地址: http://localhost:$BACKEND_PORT"
info "健康检查: http://localhost:$BACKEND_PORT/health" info "健康检查: http://localhost:$BACKEND_PORT/health"
info "数据库: SQLite (backend/sqlite3.db)"
echo "" echo ""
info "使用 './run.sh stop' 停止服务" info "使用 './run.sh stop' 停止服务"
else else
@@ -274,6 +298,9 @@ main() {
status) status)
show_status show_status
;; ;;
logs)
show_logs
;;
help|--help|-h) help|--help|-h)
show_help show_help
;; ;;