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:
1 parent
97c4207ff8
commit
ff0b268984
39 files changed
+3801
-1396
No files matched your search
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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=
|
||||||
|
|||||||
@@ -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").
|
||||||
|
|||||||
@@ -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},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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" }
|
||||||
@@ -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
@@ -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 点备份数据库"},
|
||||||
|
|||||||
@@ -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 自动建表。
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Code block styling */
|
/* Card hover effect (legacy) */
|
||||||
|
.card-hover {
|
||||||
|
@apply transition-all duration-300 hover:shadow-lg hover:shadow-slate-900/50 hover:-translate-y-0.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 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
@@ -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 };
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
@@ -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;
|
||||||
@@ -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 }),
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 角色
|
// 角色
|
||||||
|
|||||||
@@ -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
|
||||||
;;
|
;;
|
||||||
|
|||||||
Reference in new issue
Block a user