From ff0b2689842c15574cd94ca2559a8f7076c1334c Mon Sep 17 00:00:00 2001 From: attacker Date: Sun, 14 Jun 2026 02:11:10 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=AE=E5=A4=8D=E4=BB=AA=E8=A1=A8?= =?UTF-8?q?=E7=9B=98=E6=95=B0=E6=8D=AE=E7=AE=A1=E7=BA=BF=20=E2=80=94=20?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E5=BA=94=E7=94=A8=E5=8F=91=E5=B8=83=E7=A7=8D?= =?UTF-8?q?=E5=AD=90=E6=95=B0=E6=8D=AE=20+=20=E4=BF=AE=E5=A4=8Dlimit?= =?UTF-8?q?=E5=8F=82=E6=95=B0=E8=A7=A3=E6=9E=90=20+=20LDAP=E8=AE=A4?= =?UTF-8?q?=E8=AF=81=E9=9B=86=E6=88=90=20+=20User=E7=B1=BB=E5=9E=8B?= =?UTF-8?q?=E5=AE=8C=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 后端改进: - 修复 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 正常 --- backend/cmd/main.go | 3 + backend/cmd/seed.go | 58 ++ backend/go.mod | 12 +- backend/go.sum | 112 ++- backend/internal/api/handlers/dashboard.go | 10 +- backend/internal/api/handlers/ldap.go | 457 ++++++++++ backend/internal/api/handlers/user.go | 97 ++- backend/internal/api/routes/routes.go | 12 + backend/internal/models/user.go | 44 +- backend/pkg/database/database.go | 1 + backend/scripts/mock.go | 70 +- docs/dev-log.md | 63 ++ frontend/src/App.tsx | 18 +- .../src/components/approval/ApprovalFlow.tsx | 175 +++- frontend/src/components/auth/Login.tsx | 86 ++ .../src/components/auth/ProtectedRoute.tsx | 10 + .../src/components/automation/Automation.tsx | 116 ++- .../components/automation/CronJobManager.tsx | 297 +++---- .../components/cloud/CloudAccountManager.tsx | 243 +++--- .../src/components/cloud/DomainManager.tsx | 236 ++++-- .../src/components/dashboard/Dashboard.tsx | 456 +++++----- frontend/src/components/deploy/AppRelease.tsx | 102 ++- .../src/components/deploy/PipelineManager.tsx | 191 ++--- .../src/components/deploy/ReleaseManager.tsx | 233 +++-- frontend/src/components/k8s/K8sClusters.tsx | 439 ++++++---- frontend/src/components/layout/Header.tsx | 53 +- .../src/components/monitor/AlertManager.tsx | 111 ++- .../components/navigation/NavigationPage.tsx | 107 ++- .../components/server/CloudServerImport.tsx | 24 +- .../src/components/server/ServerManager.tsx | 365 +++++--- frontend/src/components/server/WebShell.tsx | 17 +- .../src/components/system/SystemManager.tsx | 390 +++++++-- frontend/src/hooks/useTheme.tsx | 101 +++ frontend/src/index.css | 503 +++++++++-- frontend/src/services/api.ts | 800 +++++++++--------- frontend/src/services/http.ts | 81 ++ frontend/src/store/index.ts | 2 + frontend/src/types/index.ts | 15 +- run.sh | 51 +- 39 files changed, 4283 insertions(+), 1878 deletions(-) create mode 100644 backend/cmd/seed.go create mode 100644 backend/internal/api/handlers/ldap.go create mode 100644 docs/dev-log.md create mode 100644 frontend/src/components/auth/Login.tsx create mode 100644 frontend/src/components/auth/ProtectedRoute.tsx create mode 100644 frontend/src/hooks/useTheme.tsx create mode 100644 frontend/src/services/http.ts diff --git a/backend/cmd/main.go b/backend/cmd/main.go index a463492..022a2ab 100644 --- a/backend/cmd/main.go +++ b/backend/cmd/main.go @@ -68,6 +68,9 @@ func main() { log.Fatal("failed to migrate database", zap.Error(err)) } + // 初始化种子数据 + seedData(db) + // 创建Gin实例 r := gin.New() diff --git a/backend/cmd/seed.go b/backend/cmd/seed.go new file mode 100644 index 0000000..f5ea133 --- /dev/null +++ b/backend/cmd/seed.go @@ -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)") + } + } +} diff --git a/backend/go.mod b/backend/go.mod index 54a8579..8dfccd3 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,10 +4,11 @@ go 1.21 require ( 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/spf13/viper v1.18.2 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/postgres v1.5.4 gorm.io/driver/sqlite v1.6.0 @@ -15,16 +16,19 @@ require ( ) require ( + github.com/Azure/go-ntlmssp v0.0.0-20221128193559-754e69321358 // indirect github.com/bytedance/sonic v1.9.1 // indirect github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // 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/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.16.0 // indirect github.com/go-sql-driver/mysql v1.7.0 // 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/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect @@ -53,9 +57,9 @@ require ( go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect - golang.org/x/net v0.19.0 // indirect - golang.org/x/sys v0.16.0 // indirect - golang.org/x/text v0.20.0 // indirect + golang.org/x/net v0.33.0 // indirect + golang.org/x/sys v0.28.0 // indirect + golang.org/x/text v0.21.0 // indirect google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/backend/go.sum b/backend/go.sum index 7f59e9c..22ba5a1 100644 --- a/backend/go.sum +++ b/backend/go.sum @@ -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.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s= 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-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg= 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/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 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/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.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= -github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +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/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/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 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/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= 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/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 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.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 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.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 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/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= 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/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= 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.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k= 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.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +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/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k= -golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c= -golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +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-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.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= -golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= 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/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 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.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/api/handlers/dashboard.go b/backend/internal/api/handlers/dashboard.go index 880a2c8..7a881e8 100644 --- a/backend/internal/api/handlers/dashboard.go +++ b/backend/internal/api/handlers/dashboard.go @@ -3,6 +3,7 @@ package handlers import ( "math" "net/http" + "strconv" "time" "ezops/internal/models" @@ -194,8 +195,8 @@ func (h *DashboardHandler) GetStats(c *gin.Context) { func (h *DashboardHandler) GetRecentAlerts(c *gin.Context) { limit := 10 if l := c.Query("limit"); l != "" { - if parsed, err := time.Parse("2006-01-02", l); err == nil { - _ = parsed + if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 && parsed <= 100 { + limit = parsed } } @@ -223,6 +224,11 @@ func (h *DashboardHandler) GetRecentAlerts(c *gin.Context) { // @Router /dashboard/recent-releases [get] func (h *DashboardHandler) GetRecentReleases(c *gin.Context) { 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 h.db.Order("created_at DESC"). diff --git a/backend/internal/api/handlers/ldap.go b/backend/internal/api/handlers/ldap.go new file mode 100644 index 0000000..2c656d6 --- /dev/null +++ b/backend/internal/api/handlers/ldap.go @@ -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}, + }) +} diff --git a/backend/internal/api/handlers/user.go b/backend/internal/api/handlers/user.go index 0f8b00c..c1868ed 100644 --- a/backend/internal/api/handlers/user.go +++ b/backend/internal/api/handlers/user.go @@ -46,26 +46,87 @@ func (h *UserHandler) Login(c *gin.Context) { } var user models.User - if err := h.db.Where("username = ?", req.Username).First(&user).Error; err != nil { - c.JSON(http.StatusUnauthorized, models.Response{ - Code: 401, - Message: "用户名或密码错误", - }) - return - } + err := h.db.Where("username = ?", req.Username).First(&user).Error - if user.Status != 1 { - c.JSON(http.StatusUnauthorized, models.Response{ - Code: 401, - Message: "账号已被禁用", - }) - return - } + if err == nil { + // 本地用户存在 + if user.Status != 1 { + c.JSON(http.StatusUnauthorized, models.Response{ + Code: 401, + Message: "账号已被禁用", + }) + return + } - if err := bcrypt.CompareHashAndPassword([]byte(user.Password), []byte(req.Password)); err != nil { - c.JSON(http.StatusUnauthorized, models.Response{ - Code: 401, - Message: "用户名或密码错误", + // 先尝试本地密码验证 + 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{ + Code: 401, + Message: "用户名或密码错误", + }) + 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 } diff --git a/backend/internal/api/routes/routes.go b/backend/internal/api/routes/routes.go index 9d51a0d..490b63a 100644 --- a/backend/internal/api/routes/routes.go +++ b/backend/internal/api/routes/routes.go @@ -48,6 +48,18 @@ func Register(r *gin.Engine, db *gorm.DB, log *zap.Logger, cfg *config.Config) { 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) dashboard := authorized.Group("/dashboard") diff --git a/backend/internal/models/user.go b/backend/internal/models/user.go index 8ef1030..4f37f87 100644 --- a/backend/internal/models/user.go +++ b/backend/internal/models/user.go @@ -8,19 +8,20 @@ import ( // User 用户模型 type User struct { - ID uint `gorm:"primarykey" json:"id"` - Username string `gorm:"size:64;uniqueIndex;not null" json:"username"` - Password string `gorm:"size:255;not null" json:"-"` - Email string `gorm:"size:128;uniqueIndex" json:"email"` - Phone string `gorm:"size:32" json:"phone"` - RealName string `gorm:"size:64" json:"real_name"` - Avatar string `gorm:"size:255" json:"avatar"` - Status int `gorm:"default:1" json:"status"` // 0:禁用 1:启用 - IsAdmin bool `gorm:"default:false" json:"is_admin"` - LastLogin *time.Time `json:"last_login"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` + ID uint `gorm:"primarykey" json:"id"` + Username string `gorm:"size:64;uniqueIndex;not null" json:"username"` + Password string `gorm:"size:255;not null" json:"-"` + Email string `gorm:"size:128" json:"email"` + Phone string `gorm:"size:32" json:"phone"` + RealName string `gorm:"size:64" json:"real_name"` + Avatar string `gorm:"size:255" json:"avatar"` + Status int `gorm:"default:1" json:"status"` // 0:禁用 1:启用 + 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"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` // 关联 Roles []Role `gorm:"many2many:user_roles;" json:"roles,omitempty"` @@ -109,3 +110,20 @@ type ChangePasswordRequest struct { OldPassword string `json:"old_password" binding:"required"` 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" } diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go index 73a638f..0bcacaf 100644 --- a/backend/pkg/database/database.go +++ b/backend/pkg/database/database.go @@ -87,5 +87,6 @@ func AutoMigrate(db *gorm.DB) error { &models.NavigationGroup{}, &models.NavigationLink{}, &models.AuditLog{}, + &models.LDAPConfig{}, ) } diff --git a/backend/scripts/mock.go b/backend/scripts/mock.go index cf54d87..b1b5fae 100644 --- a/backend/scripts/mock.go +++ b/backend/scripts/mock.go @@ -52,6 +52,9 @@ func generateMockData(db *gorm.DB) error { if err := generatePipelines(db); err != nil { return err } + if err := generateAppReleases(db); err != nil { + return err + } if err := generateCronJobs(db); err != nil { return err } @@ -210,10 +213,11 @@ func generateAlertRules(db *gorm.DB) error { } } for i := 0; i < 20; i++ { + rule := rules[rand.Intn(len(rules))] event := models.AlertEvent{ - RuleID: rules[rand.Intn(len(rules))].ID, - RuleName: rules[rand.Intn(len(rules))].Name, - Severity: []string{"info", "warning", "critical"}[rand.Intn(3)], + RuleID: rule.ID, + RuleName: rule.Name, + Severity: rule.Severity, Status: []string{"firing", "resolved"}[rand.Intn(2)], Target: fmt.Sprintf("server-%03d", rand.Intn(50)+1), Message: fmt.Sprintf("告警事件 %d", i+1), @@ -262,6 +266,66 @@ func generatePipelines(db *gorm.DB) error { 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 { cronJobs := []models.CronJob{ {Name: "数据库备份", CronExpr: "0 2 * * *", Type: "script", Content: "/scripts/backup-db.sh", Enabled: true, Description: "每日凌晨 2 点备份数据库"}, diff --git a/docs/dev-log.md b/docs/dev-log.md new file mode 100644 index 0000000..4461b9d --- /dev/null +++ b/docs/dev-log.md @@ -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 自动建表。 diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index c7d7abd..fad1b47 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,9 @@ import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { ThemeProvider } from '@/hooks/useTheme'; 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 { ServerManager } from '@/components/server/ServerManager'; import { WebShell } from '@/components/server/WebShell'; @@ -45,10 +48,12 @@ const queryClient = new QueryClient({ function App() { return ( - - - - }> + + + + + } /> + }> } /> } /> } /> @@ -96,8 +101,9 @@ function App() { } /> - - + + + ); } diff --git a/frontend/src/components/approval/ApprovalFlow.tsx b/frontend/src/components/approval/ApprovalFlow.tsx index 792f79f..93c2c87 100644 --- a/frontend/src/components/approval/ApprovalFlow.tsx +++ b/frontend/src/components/approval/ApprovalFlow.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { cn } from '@/lib/utils'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; @@ -34,45 +34,106 @@ import { Eye, Edit, GitBranch, + Loader2, } from 'lucide-react'; -import { approvalFlows, approvalInstances } from '@/mocks/data'; -import type { ApprovalFlow as ApprovalFlowType, ApprovalInstance } from '@/types'; +import { approvalApi } from '@/services/api'; + +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() { const [activeTab, setActiveTab] = useState('instances'); const [searchQuery, setSearchQuery] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); - const [selectedInstance, setSelectedInstance] = useState(null); + const [processes, setProcesses] = useState([]); + const [templates, setTemplates] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedInstance, setSelectedInstance] = useState(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 = - instance.flowName.toLowerCase().includes(searchQuery.toLowerCase()) || - instance.applicantName.toLowerCase().includes(searchQuery.toLowerCase()); + (instance.title || '').toLowerCase().includes(searchQuery.toLowerCase()) || + (instance.applicant_name || '').toLowerCase().includes(searchQuery.toLowerCase()); const matchesStatus = statusFilter === 'all' || instance.status === statusFilter; return matchesSearch && matchesStatus; }); - const getStatusBadge = (status: ApprovalInstance['status']) => { - const configs = { + const getStatusBadge = (status: any) => { + const configs: Record = { pending: { label: '审批中', className: 'border-amber-500/30 text-amber-400 bg-amber-500/10', icon: Clock }, approved: { label: '已通过', className: 'border-emerald-500/30 text-emerald-400 bg-emerald-500/10', icon: CheckCircle }, rejected: { label: '已拒绝', className: 'border-red-500/30 text-red-400 bg-red-500/10', icon: XCircle }, }; - const config = configs[status]; + const config = configs[status] || configs.pending; + const Icon = config.icon; return ( - + {config.label} ); }; + 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
暂无流程节点
; + } return (
- {flow.nodes.map((node, index) => ( -
+ {nodes.map((node: any, index: number) => ( +

{node.name}

{node.approvers && ( -

{node.approvers.join(', ')}

+

+ {Array.isArray(node.approvers) ? node.approvers.join(', ') : node.approvers} +

)}
- {index < flow.nodes.length - 1 && ( + {index < nodes.length - 1 && ( )}
@@ -124,7 +187,7 @@ export function ApprovalFlowManager() {

待审批

- {approvalInstances.filter((i) => i.status === 'pending').length} + {processes.filter((i) => i.status === 'pending').length}

@@ -139,7 +202,7 @@ export function ApprovalFlowManager() {

已通过

- {approvalInstances.filter((i) => i.status === 'approved').length} + {processes.filter((i) => i.status === 'approved').length}

@@ -154,7 +217,7 @@ export function ApprovalFlowManager() {

已拒绝

- {approvalInstances.filter((i) => i.status === 'rejected').length} + {processes.filter((i) => i.status === 'rejected').length}

@@ -168,7 +231,7 @@ export function ApprovalFlowManager() {

流程模板

-

{approvalFlows.length}

+

{templates.length}

@@ -233,35 +296,42 @@ export function ApprovalFlowManager() { - {filteredInstances.map((instance) => ( + {loading ? ( + + + + 加载中... + + + ) : filteredInstances.map((instance) => (
- {instance.flowName} + {instance.title}
- {instance.applicantName} + {instance.applicant_name || instance.applicant}
{getStatusBadge(instance.status)} - {instance.currentNode} + {instance.current_step || '--'} - {new Date(instance.createdAt).toLocaleString()} + {instance.created_at ? new Date(instance.created_at).toLocaleString() : '--'} - {instance.completedAt - ? new Date(instance.completedAt).toLocaleString() + {instance.completed_at + ? new Date(instance.completed_at).toLocaleString() : '--'} @@ -278,11 +348,21 @@ export function ApprovalFlowManager() { {instance.status === 'pending' && ( <> - - @@ -301,7 +381,7 @@ export function ApprovalFlowManager() {
- {approvalFlows.map((flow) => ( + {templates.map((flow) => (
@@ -318,12 +398,12 @@ export function ApprovalFlowManager() { - {flow.enabled ? '已启用' : '已停用'} + {flow.status === 1 || flow.status === 'active' || flow.status === true ? '已启用' : '已停用'}
- 创建于 {new Date(flow.createdAt).toLocaleDateString()} - {flow.nodes.length} 个节点 + 创建于 {flow.created_at ? new Date(flow.created_at).toLocaleDateString() : '--'} + {parseSteps(flow.steps).length} 个节点
@@ -364,11 +444,11 @@ export function ApprovalFlowManager() { - {selectedInstance?.flowName} + {selectedInstance?.title} - 申请人: {selectedInstance?.applicantName} · 创建于{' '} - {selectedInstance && new Date(selectedInstance.createdAt).toLocaleString()} + 申请人: {selectedInstance?.applicant_name || selectedInstance?.applicant} · 创建于{' '} + {selectedInstance && selectedInstance.created_at ? new Date(selectedInstance.created_at).toLocaleString() : '--'} @@ -388,10 +468,10 @@ export function ApprovalFlowManager() {
提交申请 - {selectedInstance && new Date(selectedInstance.createdAt).toLocaleString()} + {selectedInstance && selectedInstance.created_at ? new Date(selectedInstance.created_at).toLocaleString() : '--'}
-

申请人: {selectedInstance?.applicantName}

+

申请人: {selectedInstance?.applicant_name || selectedInstance?.applicant}

@@ -418,10 +498,10 @@ export function ApprovalFlowManager() {
- {selectedInstance?.currentNode} + {selectedInstance?.current_step || '审批节点'} - {selectedInstance?.completedAt - ? new Date(selectedInstance.completedAt).toLocaleString() + {selectedInstance?.completed_at + ? new Date(selectedInstance.completed_at).toLocaleString() : '进行中'}
@@ -441,11 +521,18 @@ export function ApprovalFlowManager() { {selectedInstance?.status === 'pending' && ( <> - - diff --git a/frontend/src/components/auth/Login.tsx b/frontend/src/components/auth/Login.tsx new file mode 100644 index 0000000..887d071 --- /dev/null +++ b/frontend/src/components/auth/Login.tsx @@ -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 ( +
+
+ {/* Logo */} +
+
+ + + +
+

AIOps 智能运维平台

+

EZOps Platform

+
+ + {/* Form */} +
+ {error && ( +
+ {error} +
+ )} +
+ + 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 + /> +
+
+ + 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="请输入密码" + /> +
+ +
+ +

+ 默认账号 admin / 123456 +

+
+
+ ); +} diff --git a/frontend/src/components/auth/ProtectedRoute.tsx b/frontend/src/components/auth/ProtectedRoute.tsx new file mode 100644 index 0000000..7a05c38 --- /dev/null +++ b/frontend/src/components/auth/ProtectedRoute.tsx @@ -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 ; + } + return <>{children}; +} diff --git a/frontend/src/components/automation/Automation.tsx b/frontend/src/components/automation/Automation.tsx index 25e6e6a..1c19f17 100644 --- a/frontend/src/components/automation/Automation.tsx +++ b/frontend/src/components/automation/Automation.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useState, useEffect } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -41,30 +41,66 @@ import { Edit, Copy, Download, + Loader2, } from 'lucide-react'; -import { cronJobs, scripts } from '@/mocks/data'; -import type { CronJob, Script } from '@/types'; +import { cronJobApi, scriptApi } from '@/services/api'; + +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() { const [activeTab, setActiveTab] = useState('cronjobs'); const [searchQuery, setSearchQuery] = useState(''); - const [selectedScript, setSelectedScript] = useState