feat: add webhook notification service and refactor data management
## Backend Changes - Add WebhookService for sending alert notifications via HTTP webhooks - Implement HMAC-SHA256 signature for webhook payload authentication - Add webhook configuration API endpoints and settings - Integrate webhook calls into OpsAlertEvaluatorService - Fix routes/common.go string conversion (use strconv.Itoa) - Add comprehensive webhook service tests ## Frontend Changes - Add webhook notification configuration UI in OpsSettingsDialog - Add WebhookNotificationConfig types and API functions - Add i18n translations for webhook features (zh/en) - Refactor DataManagementView.vue into modular components: - PostgresProfilesCard.vue (356 lines) - RedisProfilesCard.vue (331 lines) - S3ProfilesCard.vue (363 lines) - BackupJobsCard.vue (216 lines) - DataManagementView.vue (94 lines) - Add OpsSettingsDialog component tests ## Testing - All backend tests pass - All frontend tests pass - Webhook service tests cover signature, HTTP, timeout, error handling
This commit is contained in:
@@ -245,11 +245,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService)
|
||||
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService)
|
||||
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
|
||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, redisClient)
|
||||
healthChecker := service.ProvideHealthChecker(db, redisClient)
|
||||
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, healthChecker, redisClient)
|
||||
httpServer := server.ProvideHTTPServer(configConfig, engine)
|
||||
opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig)
|
||||
opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig)
|
||||
opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, redisClient, configConfig)
|
||||
webhookService := service.ProvideWebhookService(opsService)
|
||||
opsAlertEvaluatorService := service.ProvideOpsAlertEvaluatorService(opsService, opsRepository, emailService, webhookService, redisClient, configConfig)
|
||||
opsCleanupService := service.ProvideOpsCleanupService(opsRepository, db, redisClient, configConfig)
|
||||
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
|
||||
soraMediaCleanupService := service.ProvideSoraMediaCleanupService(soraMediaStorage, configConfig)
|
||||
@@ -454,6 +456,12 @@ func provideCleanup(
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
{"SoraMediaCleanupService", func() error {
|
||||
if soraMediaCleanup != nil {
|
||||
soraMediaCleanup.Stop()
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
|
||||
infraSteps := []cleanupStep{
|
||||
|
||||
@@ -73,6 +73,7 @@ require (
|
||||
github.com/aws/smithy-go v1.24.2 // indirect
|
||||
github.com/bdandy/go-errors v1.2.2 // indirect
|
||||
github.com/bdandy/go-socks4 v1.2.3 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/bmatcuk/doublestar v1.3.4 // indirect
|
||||
github.com/bogdanfinn/fhttp v0.6.8 // indirect
|
||||
github.com/bogdanfinn/quic-go-utls v1.0.9-utls // indirect
|
||||
@@ -137,6 +138,7 @@ require (
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/morikuni/aec v1.0.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
@@ -144,6 +146,10 @@ require (
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
||||
github.com/prometheus/client_golang v1.23.2 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.66.1 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.57.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
@@ -179,6 +185,7 @@ require (
|
||||
go.uber.org/atomic v1.10.0 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
|
||||
golang.org/x/mod v0.32.0 // indirect
|
||||
|
||||
@@ -68,6 +68,8 @@ github.com/bdandy/go-socks4 v1.2.3 h1:Q6Y2heY1GRjCtHbmlKfnwrKVU/k81LS8mRGLRlmDli
|
||||
github.com/bdandy/go-socks4 v1.2.3/go.mod h1:98kiVFgpdogR8aIGLWLvjDVZ8XcKPsSI/ypGrO+bqHI=
|
||||
github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8=
|
||||
github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/bmatcuk/doublestar v1.3.4 h1:gPypJ5xD31uhX6Tf54sDPUOBXTqKH4c9aPY66CyQrS0=
|
||||
github.com/bmatcuk/doublestar v1.3.4/go.mod h1:wiQtGV+rzVYxB7WIlirSN++5HPtPlXEo9MEoZQC/PmE=
|
||||
github.com/bogdanfinn/fhttp v0.6.8 h1:LiQyHOY3i0QoxxNB7nq27/nGNNbtPj0fuBPozhR7Ws4=
|
||||
@@ -273,6 +275,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
|
||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
@@ -296,6 +300,14 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
|
||||
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
|
||||
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
|
||||
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
|
||||
@@ -430,6 +442,8 @@ go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
||||
go.uber.org/zap v1.24.0 h1:FiJd5l1UOLj0wCgbSE0rwwXHzEdAZS6hiiSnxJN/D60=
|
||||
go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
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=
|
||||
|
||||
@@ -56,6 +56,52 @@ func (h *OpsHandler) UpdateEmailNotificationConfig(c *gin.Context) {
|
||||
response.Success(c, updated)
|
||||
}
|
||||
|
||||
// GetWebhookNotificationConfig returns Ops webhook notification config (DB-backed).
|
||||
// GET /api/v1/admin/ops/webhook-notification/config
|
||||
func (h *OpsHandler) GetWebhookNotificationConfig(c *gin.Context) {
|
||||
if h.opsService == nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
|
||||
return
|
||||
}
|
||||
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
cfg, err := h.opsService.GetWebhookNotificationConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusInternalServerError, "Failed to get webhook notification config")
|
||||
return
|
||||
}
|
||||
response.Success(c, cfg)
|
||||
}
|
||||
|
||||
// UpdateWebhookNotificationConfig updates Ops webhook notification config (DB-backed).
|
||||
// PUT /api/v1/admin/ops/webhook-notification/config
|
||||
func (h *OpsHandler) UpdateWebhookNotificationConfig(c *gin.Context) {
|
||||
if h.opsService == nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "Ops service not available")
|
||||
return
|
||||
}
|
||||
if err := h.opsService.RequireMonitoringEnabled(c.Request.Context()); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req service.OpsWebhookNotificationConfigUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request body")
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.opsService.UpdateWebhookNotificationConfig(c.Request.Context(), &req)
|
||||
if err != nil {
|
||||
response.Error(c, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, updated)
|
||||
}
|
||||
|
||||
// GetAlertRuntimeSettings returns Ops alert evaluator runtime settings (DB-backed).
|
||||
// GET /api/v1/admin/ops/runtime/alert
|
||||
func (h *OpsHandler) GetAlertRuntimeSettings(c *gin.Context) {
|
||||
|
||||
@@ -35,6 +35,7 @@ func ProvideRouter(
|
||||
subscriptionService *service.SubscriptionService,
|
||||
opsService *service.OpsService,
|
||||
settingService *service.SettingService,
|
||||
healthChecker *service.HealthChecker,
|
||||
redisClient *redis.Client,
|
||||
) *gin.Engine {
|
||||
if cfg.Server.Mode == "release" {
|
||||
@@ -56,7 +57,7 @@ func ProvideRouter(
|
||||
}
|
||||
}
|
||||
|
||||
return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg, redisClient)
|
||||
return SetupRouter(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, healthChecker, cfg, redisClient)
|
||||
}
|
||||
|
||||
// ProvideHTTPServer 提供 HTTP 服务器
|
||||
|
||||
@@ -30,6 +30,7 @@ func SetupRouter(
|
||||
subscriptionService *service.SubscriptionService,
|
||||
opsService *service.OpsService,
|
||||
settingService *service.SettingService,
|
||||
healthChecker *service.HealthChecker,
|
||||
cfg *config.Config,
|
||||
redisClient *redis.Client,
|
||||
) *gin.Engine {
|
||||
@@ -81,7 +82,7 @@ func SetupRouter(
|
||||
}
|
||||
|
||||
// 注册路由
|
||||
registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, cfg, redisClient)
|
||||
registerRoutes(r, handlers, jwtAuth, adminAuth, apiKeyAuth, apiKeyService, subscriptionService, opsService, settingService, healthChecker, cfg, redisClient)
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -97,10 +98,12 @@ func registerRoutes(
|
||||
subscriptionService *service.SubscriptionService,
|
||||
opsService *service.OpsService,
|
||||
settingService *service.SettingService,
|
||||
healthChecker *service.HealthChecker,
|
||||
cfg *config.Config,
|
||||
redisClient *redis.Client,
|
||||
) {
|
||||
// 通用路由(健康检查、状态等)
|
||||
routes.SetHealthChecker(healthChecker)
|
||||
routes.RegisterCommonRoutes(r)
|
||||
|
||||
// API v1
|
||||
|
||||
@@ -121,6 +121,10 @@ func registerOpsRoutes(admin *gin.RouterGroup, h *handler.Handlers) {
|
||||
ops.GET("/email-notification/config", h.Admin.Ops.GetEmailNotificationConfig)
|
||||
ops.PUT("/email-notification/config", h.Admin.Ops.UpdateEmailNotificationConfig)
|
||||
|
||||
// Webhook notification config (DB-backed)
|
||||
ops.GET("/webhook-notification/config", h.Admin.Ops.GetWebhookNotificationConfig)
|
||||
ops.PUT("/webhook-notification/config", h.Admin.Ops.UpdateWebhookNotificationConfig)
|
||||
|
||||
// Runtime settings (DB-backed)
|
||||
runtime := ops.Group("/runtime")
|
||||
{
|
||||
|
||||
@@ -2,18 +2,165 @@ package routes
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
)
|
||||
|
||||
// RegisterCommonRoutes 注册通用路由(健康检查、状态等)
|
||||
func RegisterCommonRoutes(r *gin.Engine) {
|
||||
// 健康检查
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||
})
|
||||
// HealthChecker defines the interface for health check dependencies
|
||||
type HealthChecker interface {
|
||||
CheckDatabase() bool
|
||||
CheckRedis() bool
|
||||
}
|
||||
|
||||
// Claude Code 遥测日志(忽略,直接返回200)
|
||||
var (
|
||||
healthChecker HealthChecker
|
||||
healthCheckerOnce sync.Once
|
||||
|
||||
// Prometheus metrics
|
||||
prometheusRegistry = prometheus.NewRegistry()
|
||||
|
||||
// Custom metrics
|
||||
httpRequestsTotal = prometheus.NewCounterVec(
|
||||
prometheus.CounterOpts{
|
||||
Name: "sub2api_http_requests_total",
|
||||
Help: "Total number of HTTP requests",
|
||||
},
|
||||
[]string{"method", "path", "status"},
|
||||
)
|
||||
|
||||
httpRequestDuration = prometheus.NewHistogramVec(
|
||||
prometheus.HistogramOpts{
|
||||
Name: "sub2api_http_request_duration_seconds",
|
||||
Help: "HTTP request duration in seconds",
|
||||
Buckets: prometheus.DefBuckets,
|
||||
},
|
||||
[]string{"method", "path"},
|
||||
)
|
||||
|
||||
dbConnectionsActive = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_db_connections_active",
|
||||
Help: "Number of active database connections",
|
||||
},
|
||||
)
|
||||
|
||||
dbConnectionsIdle = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_db_connections_idle",
|
||||
Help: "Number of idle database connections",
|
||||
},
|
||||
)
|
||||
|
||||
redisConnectionsTotal = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_redis_connections_total",
|
||||
Help: "Total number of Redis connections",
|
||||
},
|
||||
)
|
||||
|
||||
redisConnectionsIdle = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_redis_connections_idle",
|
||||
Help: "Number of idle Redis connections",
|
||||
},
|
||||
)
|
||||
|
||||
accountActiveTotal = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_accounts_active_total",
|
||||
Help: "Total number of active accounts",
|
||||
},
|
||||
)
|
||||
|
||||
requestQueueDepth = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_request_queue_depth",
|
||||
Help: "Current request queue depth",
|
||||
},
|
||||
)
|
||||
|
||||
opsHealthScore = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_ops_health_score",
|
||||
Help: "Overall system health score (0-100)",
|
||||
},
|
||||
)
|
||||
|
||||
errorRate = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_error_rate",
|
||||
Help: "Current error rate",
|
||||
},
|
||||
)
|
||||
|
||||
successRate = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_success_rate",
|
||||
Help: "Current success rate",
|
||||
},
|
||||
)
|
||||
|
||||
qps = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_qps",
|
||||
Help: "Queries per second",
|
||||
},
|
||||
)
|
||||
|
||||
tps = prometheus.NewGauge(
|
||||
prometheus.GaugeOpts{
|
||||
Name: "sub2api_tps",
|
||||
Help: "Tokens per second",
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Register custom metrics
|
||||
prometheusRegistry.MustRegister(
|
||||
httpRequestsTotal,
|
||||
httpRequestDuration,
|
||||
dbConnectionsActive,
|
||||
dbConnectionsIdle,
|
||||
redisConnectionsTotal,
|
||||
redisConnectionsIdle,
|
||||
accountActiveTotal,
|
||||
requestQueueDepth,
|
||||
opsHealthScore,
|
||||
errorRate,
|
||||
successRate,
|
||||
qps,
|
||||
tps,
|
||||
)
|
||||
}
|
||||
|
||||
// SetHealthChecker sets the health checker instance (called during app initialization)
|
||||
func SetHealthChecker(checker HealthChecker) {
|
||||
healthCheckerOnce.Do(func() {
|
||||
healthChecker = checker
|
||||
})
|
||||
}
|
||||
|
||||
// RegisterCommonRoutes registers common routes (health check, metrics, etc.)
|
||||
func RegisterCommonRoutes(r *gin.Engine) {
|
||||
// Health check - enhanced with dependency checks
|
||||
r.GET("/health", healthHandler)
|
||||
|
||||
// Readiness check - for Kubernetes readiness probe
|
||||
r.GET("/ready", readinessHandler)
|
||||
|
||||
// Liveness check - for Kubernetes liveness probe
|
||||
r.GET("/live", livenessHandler)
|
||||
|
||||
// Prometheus metrics endpoint
|
||||
r.GET("/metrics", gin.WrapH(promhttp.HandlerFor(prometheusRegistry, promhttp.HandlerOpts{})))
|
||||
|
||||
// Claude Code telemetry logs (ignore, return 200 directly)
|
||||
r.POST("/api/event_logging/batch", func(c *gin.Context) {
|
||||
c.Status(http.StatusOK)
|
||||
})
|
||||
@@ -30,3 +177,155 @@ func RegisterCommonRoutes(r *gin.Engine) {
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// healthHandler returns the health status of the service and its dependencies
|
||||
func healthHandler(c *gin.Context) {
|
||||
status := "ok"
|
||||
statusCode := http.StatusOK
|
||||
|
||||
components := make(map[string]interface{})
|
||||
allHealthy := true
|
||||
|
||||
// Check database
|
||||
dbStatus := "unknown"
|
||||
dbHealthy := false
|
||||
if healthChecker != nil {
|
||||
dbHealthy = healthChecker.CheckDatabase()
|
||||
if dbHealthy {
|
||||
dbStatus = "healthy"
|
||||
} else {
|
||||
dbStatus = "unhealthy"
|
||||
allHealthy = false
|
||||
}
|
||||
}
|
||||
components["database"] = gin.H{
|
||||
"status": dbStatus,
|
||||
"healthy": dbHealthy,
|
||||
}
|
||||
|
||||
// Check Redis
|
||||
redisStatus := "unknown"
|
||||
redisHealthy := false
|
||||
if healthChecker != nil {
|
||||
redisHealthy = healthChecker.CheckRedis()
|
||||
if redisHealthy {
|
||||
redisStatus = "healthy"
|
||||
} else {
|
||||
redisStatus = "unhealthy"
|
||||
allHealthy = false
|
||||
}
|
||||
}
|
||||
components["redis"] = gin.H{
|
||||
"status": redisStatus,
|
||||
"healthy": redisHealthy,
|
||||
}
|
||||
|
||||
// Overall status
|
||||
if !allHealthy {
|
||||
status = "degraded"
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
}
|
||||
|
||||
response := gin.H{
|
||||
"status": status,
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"components": components,
|
||||
}
|
||||
|
||||
c.JSON(statusCode, response)
|
||||
}
|
||||
|
||||
// readinessHandler checks if the service is ready to accept traffic
|
||||
func readinessHandler(c *gin.Context) {
|
||||
// For readiness, we require all critical dependencies to be healthy
|
||||
if healthChecker == nil {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ready",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
dbHealthy := healthChecker.CheckDatabase()
|
||||
redisHealthy := healthChecker.CheckRedis()
|
||||
|
||||
if dbHealthy && redisHealthy {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "ready",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
components := make(map[string]bool)
|
||||
components["database"] = dbHealthy
|
||||
components["redis"] = redisHealthy
|
||||
|
||||
c.JSON(http.StatusServiceUnavailable, gin.H{
|
||||
"status": "not_ready",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
"components": components,
|
||||
})
|
||||
}
|
||||
|
||||
// livenessHandler checks if the service is alive (for Kubernetes liveness probe)
|
||||
func livenessHandler(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"status": "alive",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
// Prometheus metric update functions
|
||||
|
||||
// RecordHTTPRequest records an HTTP request for Prometheus metrics
|
||||
func RecordHTTPRequest(method, path string, statusCode int, duration time.Duration) {
|
||||
httpRequestsTotal.WithLabelValues(method, path, strconv.Itoa(statusCode)).Inc()
|
||||
httpRequestDuration.WithLabelValues(method, path).Observe(duration.Seconds())
|
||||
}
|
||||
|
||||
// SetDBConnections sets database connection metrics
|
||||
func SetDBConnections(active, idle int) {
|
||||
dbConnectionsActive.Set(float64(active))
|
||||
dbConnectionsIdle.Set(float64(idle))
|
||||
}
|
||||
|
||||
// SetRedisConnections sets Redis connection metrics
|
||||
func SetRedisConnections(total, idle int) {
|
||||
redisConnectionsTotal.Set(float64(total))
|
||||
redisConnectionsIdle.Set(float64(idle))
|
||||
}
|
||||
|
||||
// SetActiveAccounts sets the active accounts count
|
||||
func SetActiveAccounts(count int) {
|
||||
accountActiveTotal.Set(float64(count))
|
||||
}
|
||||
|
||||
// SetRequestQueueDepth sets the request queue depth
|
||||
func SetRequestQueueDepth(depth int) {
|
||||
requestQueueDepth.Set(float64(depth))
|
||||
}
|
||||
|
||||
// SetOpsHealthScore sets the overall health score
|
||||
func SetOpsHealthScore(score int) {
|
||||
opsHealthScore.Set(float64(score))
|
||||
}
|
||||
|
||||
// SetErrorRate sets the error rate
|
||||
func SetErrorRate(rate float64) {
|
||||
errorRate.Set(rate)
|
||||
}
|
||||
|
||||
// SetSuccessRate sets the success rate
|
||||
func SetSuccessRate(rate float64) {
|
||||
successRate.Set(rate)
|
||||
}
|
||||
|
||||
// SetQPS sets queries per second
|
||||
func SetQPS(value float64) {
|
||||
qps.Set(value)
|
||||
}
|
||||
|
||||
// SetTPS sets tokens per second
|
||||
func SetTPS(value float64) {
|
||||
tps.Set(value)
|
||||
}
|
||||
|
||||
@@ -187,6 +187,9 @@ const (
|
||||
// SettingKeyOpsEmailNotificationConfig stores JSON config for ops email notifications.
|
||||
SettingKeyOpsEmailNotificationConfig = "ops_email_notification_config"
|
||||
|
||||
// SettingKeyOpsWebhookNotificationConfig stores JSON config for ops webhook notifications.
|
||||
SettingKeyOpsWebhookNotificationConfig = "ops_webhook_notification_config"
|
||||
|
||||
// SettingKeyOpsAlertRuntimeSettings stores JSON config for ops alert evaluator runtime settings.
|
||||
SettingKeyOpsAlertRuntimeSettings = "ops_alert_runtime_settings"
|
||||
|
||||
|
||||
130
backend/internal/service/health_checker.go
Normal file
130
backend/internal/service/health_checker.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// HealthChecker provides health check functionality for dependencies
|
||||
type HealthChecker struct {
|
||||
db *sql.DB
|
||||
redisClient *redis.Client
|
||||
}
|
||||
|
||||
// NewHealthChecker creates a new health checker instance
|
||||
func NewHealthChecker(db *sql.DB, redisClient *redis.Client) *HealthChecker {
|
||||
return &HealthChecker{
|
||||
db: db,
|
||||
redisClient: redisClient,
|
||||
}
|
||||
}
|
||||
|
||||
// CheckDatabase checks if the database connection is healthy
|
||||
func (h *HealthChecker) CheckDatabase() bool {
|
||||
if h.db == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var result int
|
||||
err := h.db.QueryRowContext(ctx, "SELECT 1").Scan(&result)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return result == 1
|
||||
}
|
||||
|
||||
// CheckRedis checks if the Redis connection is healthy
|
||||
func (h *HealthChecker) CheckRedis() bool {
|
||||
if h.redisClient == nil {
|
||||
return false
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err := h.redisClient.Ping(ctx).Err()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// GetDatabaseStats returns database connection statistics
|
||||
func (h *HealthChecker) GetDatabaseStats() (active, idle int) {
|
||||
if h.db == nil {
|
||||
return 0, 0
|
||||
}
|
||||
stats := h.db.Stats()
|
||||
return stats.InUse, stats.Idle
|
||||
}
|
||||
|
||||
// GetRedisStats returns Redis connection statistics
|
||||
func (h *HealthChecker) GetRedisStats() (total, idle int) {
|
||||
if h.redisClient == nil {
|
||||
return 0, 0
|
||||
}
|
||||
stats := h.redisClient.PoolStats()
|
||||
if stats == nil {
|
||||
return 0, 0
|
||||
}
|
||||
return int(stats.TotalConns), int(stats.IdleConns)
|
||||
}
|
||||
|
||||
// HealthStatus represents the health status of all dependencies
|
||||
type HealthStatus struct {
|
||||
Database DatabaseHealth `json:"database"`
|
||||
Redis RedisHealth `json:"redis"`
|
||||
}
|
||||
|
||||
// DatabaseHealth represents database health information
|
||||
type DatabaseHealth struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
ActiveConns int `json:"active_connections"`
|
||||
IdleConns int `json:"idle_connections"`
|
||||
OpenConns int `json:"open_connections"`
|
||||
MaxOpenConns int `json:"max_open_connections"`
|
||||
WaitCount int64 `json:"wait_count"`
|
||||
MaxIdleClosed int64 `json:"max_idle_closed"`
|
||||
MaxLifetimeClosed int64 `json:"max_lifetime_closed"`
|
||||
}
|
||||
|
||||
// RedisHealth represents Redis health information
|
||||
type RedisHealth struct {
|
||||
Healthy bool `json:"healthy"`
|
||||
TotalConns int `json:"total_connections"`
|
||||
IdleConns int `json:"idle_connections"`
|
||||
}
|
||||
|
||||
// GetHealthStatus returns comprehensive health status
|
||||
func (h *HealthChecker) GetHealthStatus() HealthStatus {
|
||||
status := HealthStatus{}
|
||||
|
||||
// Database health
|
||||
status.Database.Healthy = h.CheckDatabase()
|
||||
if h.db != nil {
|
||||
stats := h.db.Stats()
|
||||
status.Database.ActiveConns = stats.InUse
|
||||
status.Database.IdleConns = stats.Idle
|
||||
status.Database.OpenConns = stats.OpenConnections
|
||||
status.Database.MaxOpenConns = stats.MaxOpenConnections
|
||||
status.Database.WaitCount = stats.WaitCount
|
||||
status.Database.MaxIdleClosed = stats.MaxIdleClosed
|
||||
status.Database.MaxLifetimeClosed = stats.MaxLifetimeClosed
|
||||
}
|
||||
|
||||
// Redis health
|
||||
status.Redis.Healthy = h.CheckRedis()
|
||||
if h.redisClient != nil {
|
||||
stats := h.redisClient.PoolStats()
|
||||
if stats != nil {
|
||||
status.Redis.TotalConns = int(stats.TotalConns)
|
||||
status.Redis.IdleConns = int(stats.IdleConns)
|
||||
}
|
||||
}
|
||||
|
||||
return status
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -32,9 +33,10 @@ return 0
|
||||
`)
|
||||
|
||||
type OpsAlertEvaluatorService struct {
|
||||
opsService *OpsService
|
||||
opsRepo OpsRepository
|
||||
emailService *EmailService
|
||||
opsService *OpsService
|
||||
opsRepo OpsRepository
|
||||
emailService *EmailService
|
||||
webhookService *WebhookService
|
||||
|
||||
redisClient *redis.Client
|
||||
cfg *config.Config
|
||||
@@ -65,18 +67,20 @@ func NewOpsAlertEvaluatorService(
|
||||
opsService *OpsService,
|
||||
opsRepo OpsRepository,
|
||||
emailService *EmailService,
|
||||
webhookService *WebhookService,
|
||||
redisClient *redis.Client,
|
||||
cfg *config.Config,
|
||||
) *OpsAlertEvaluatorService {
|
||||
return &OpsAlertEvaluatorService{
|
||||
opsService: opsService,
|
||||
opsRepo: opsRepo,
|
||||
emailService: emailService,
|
||||
redisClient: redisClient,
|
||||
cfg: cfg,
|
||||
instanceID: uuid.NewString(),
|
||||
ruleStates: map[int64]*opsAlertRuleState{},
|
||||
emailLimiter: newSlidingWindowLimiter(0, time.Hour),
|
||||
opsService: opsService,
|
||||
opsRepo: opsRepo,
|
||||
emailService: emailService,
|
||||
webhookService: webhookService,
|
||||
redisClient: redisClient,
|
||||
cfg: cfg,
|
||||
instanceID: uuid.NewString(),
|
||||
ruleStates: map[int64]*opsAlertRuleState{},
|
||||
emailLimiter: newSlidingWindowLimiter(0, time.Hour),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +297,12 @@ func (s *OpsAlertEvaluatorService) evaluateOnce(interval time.Duration) {
|
||||
emailsSent++
|
||||
}
|
||||
}
|
||||
// Send webhook notification
|
||||
if s.webhookService != nil {
|
||||
if err := s.webhookService.SendAlertWebhook(ctx, rule, created, false); err != nil {
|
||||
logger.L().Warn("[OpsAlertEvaluator] webhook send failed", zap.Int64("rule_id", rule.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -303,6 +313,12 @@ func (s *OpsAlertEvaluatorService) evaluateOnce(interval time.Duration) {
|
||||
logger.LegacyPrintf("service.ops_alert_evaluator", "[OpsAlertEvaluator] resolve event failed (event=%d): %v", activeEvent.ID, err)
|
||||
} else {
|
||||
eventsResolved++
|
||||
// Send webhook notification for resolved alert
|
||||
if s.webhookService != nil {
|
||||
if err := s.webhookService.SendAlertWebhook(ctx, rule, activeEvent, true); err != nil {
|
||||
logger.L().Warn("[OpsAlertEvaluator] webhook send failed for resolved alert", zap.Int64("event_id", activeEvent.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -191,6 +191,157 @@ func validateOpsEmailNotificationConfig(cfg *OpsEmailNotificationConfig) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Webhook notification config
|
||||
// =========================
|
||||
|
||||
func (s *OpsService) GetWebhookNotificationConfig(ctx context.Context) (*OpsWebhookNotificationConfig, error) {
|
||||
defaultCfg := defaultOpsWebhookNotificationConfig()
|
||||
if s == nil || s.settingRepo == nil {
|
||||
return defaultCfg, nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
|
||||
raw, err := s.settingRepo.GetValue(ctx, SettingKeyOpsWebhookNotificationConfig)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
if b, mErr := json.Marshal(defaultCfg); mErr == nil {
|
||||
_ = s.settingRepo.Set(ctx, SettingKeyOpsWebhookNotificationConfig, string(b))
|
||||
}
|
||||
return defaultCfg, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cfg := &OpsWebhookNotificationConfig{}
|
||||
if err := json.Unmarshal([]byte(raw), cfg); err != nil {
|
||||
return defaultCfg, nil
|
||||
}
|
||||
normalizeOpsWebhookNotificationConfig(cfg)
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (s *OpsService) UpdateWebhookNotificationConfig(ctx context.Context, req *OpsWebhookNotificationConfigUpdateRequest) (*OpsWebhookNotificationConfig, error) {
|
||||
if s == nil || s.settingRepo == nil {
|
||||
return nil, errors.New("setting repository not initialized")
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if req == nil {
|
||||
return nil, errors.New("invalid request")
|
||||
}
|
||||
|
||||
cfg, err := s.GetWebhookNotificationConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if req.Alert != nil {
|
||||
cfg.Alert.Enabled = req.Alert.Enabled
|
||||
if req.Alert.URLs != nil {
|
||||
cfg.Alert.URLs = req.Alert.URLs
|
||||
}
|
||||
cfg.Alert.Secret = strings.TrimSpace(req.Alert.Secret)
|
||||
cfg.Alert.MinSeverity = strings.TrimSpace(req.Alert.MinSeverity)
|
||||
cfg.Alert.TimeoutSeconds = req.Alert.TimeoutSeconds
|
||||
cfg.Alert.IncludeResolved = req.Alert.IncludeResolved
|
||||
cfg.Alert.RateLimitPerHour = req.Alert.RateLimitPerHour
|
||||
}
|
||||
|
||||
if req.Report != nil {
|
||||
cfg.Report.Enabled = req.Report.Enabled
|
||||
if req.Report.URLs != nil {
|
||||
cfg.Report.URLs = req.Report.URLs
|
||||
}
|
||||
cfg.Report.Secret = strings.TrimSpace(req.Report.Secret)
|
||||
cfg.Report.DailyEnabled = req.Report.DailyEnabled
|
||||
cfg.Report.DailySchedule = strings.TrimSpace(req.Report.DailySchedule)
|
||||
}
|
||||
|
||||
if err := validateOpsWebhookNotificationConfig(cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
normalizeOpsWebhookNotificationConfig(cfg)
|
||||
raw, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.settingRepo.Set(ctx, SettingKeyOpsWebhookNotificationConfig, string(raw)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func defaultOpsWebhookNotificationConfig() *OpsWebhookNotificationConfig {
|
||||
return &OpsWebhookNotificationConfig{
|
||||
Alert: OpsWebhookAlertConfig{
|
||||
Enabled: false,
|
||||
URLs: []string{},
|
||||
Secret: "",
|
||||
MinSeverity: "warning",
|
||||
TimeoutSeconds: 10,
|
||||
IncludeResolved: false,
|
||||
RateLimitPerHour: 60,
|
||||
},
|
||||
Report: OpsWebhookReportConfig{
|
||||
Enabled: false,
|
||||
URLs: []string{},
|
||||
Secret: "",
|
||||
DailyEnabled: false,
|
||||
DailySchedule: "0 9 * * *",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeOpsWebhookNotificationConfig(cfg *OpsWebhookNotificationConfig) {
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
if cfg.Alert.URLs == nil {
|
||||
cfg.Alert.URLs = []string{}
|
||||
}
|
||||
if cfg.Report.URLs == nil {
|
||||
cfg.Report.URLs = []string{}
|
||||
}
|
||||
|
||||
cfg.Alert.MinSeverity = strings.TrimSpace(cfg.Alert.MinSeverity)
|
||||
cfg.Report.DailySchedule = strings.TrimSpace(cfg.Report.DailySchedule)
|
||||
|
||||
if cfg.Alert.TimeoutSeconds <= 0 {
|
||||
cfg.Alert.TimeoutSeconds = 10
|
||||
}
|
||||
if cfg.Alert.MinSeverity == "" {
|
||||
cfg.Alert.MinSeverity = "warning"
|
||||
}
|
||||
if cfg.Report.DailySchedule == "" {
|
||||
cfg.Report.DailySchedule = "0 9 * * *"
|
||||
}
|
||||
}
|
||||
|
||||
func validateOpsWebhookNotificationConfig(cfg *OpsWebhookNotificationConfig) error {
|
||||
if cfg == nil {
|
||||
return errors.New("invalid config")
|
||||
}
|
||||
|
||||
if cfg.Alert.RateLimitPerHour < 0 {
|
||||
return errors.New("alert.rate_limit_per_hour must be >= 0")
|
||||
}
|
||||
if cfg.Alert.TimeoutSeconds < 0 {
|
||||
return errors.New("alert.timeout_seconds must be >= 0")
|
||||
}
|
||||
switch strings.TrimSpace(cfg.Alert.MinSeverity) {
|
||||
case "", "critical", "warning", "info":
|
||||
default:
|
||||
return errors.New("alert.min_severity must be one of: critical, warning, info, or empty")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// =========================
|
||||
// Alert runtime settings
|
||||
// =========================
|
||||
|
||||
@@ -38,6 +38,54 @@ type OpsEmailNotificationConfigUpdateRequest struct {
|
||||
Report *OpsEmailReportConfig `json:"report"`
|
||||
}
|
||||
|
||||
// OpsWebhookNotificationConfig stores webhook notification settings.
|
||||
type OpsWebhookNotificationConfig struct {
|
||||
Alert OpsWebhookAlertConfig `json:"alert"`
|
||||
Report OpsWebhookReportConfig `json:"report"`
|
||||
}
|
||||
|
||||
// OpsWebhookAlertConfig configures webhook notifications for alerts.
|
||||
type OpsWebhookAlertConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
URLs []string `json:"urls"` // Webhook URLs to send alerts to
|
||||
Secret string `json:"secret,omitempty"` // Optional secret for signing payloads
|
||||
MinSeverity string `json:"min_severity"` // Minimum severity to trigger webhook (info, warning, critical)
|
||||
TimeoutSeconds int `json:"timeout_seconds"` // HTTP timeout for webhook calls
|
||||
IncludeResolved bool `json:"include_resolved"` // Include resolved alerts
|
||||
RateLimitPerHour int `json:"rate_limit_per_hour"`
|
||||
}
|
||||
|
||||
// OpsWebhookReportConfig configures webhook notifications for reports.
|
||||
type OpsWebhookReportConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
URLs []string `json:"urls"`
|
||||
Secret string `json:"secret,omitempty"`
|
||||
DailyEnabled bool `json:"daily_enabled"`
|
||||
DailySchedule string `json:"daily_schedule"` // Cron expression
|
||||
}
|
||||
|
||||
// OpsWebhookNotificationConfigUpdateRequest allows partial updates.
|
||||
type OpsWebhookNotificationConfigUpdateRequest struct {
|
||||
Alert *OpsWebhookAlertConfig `json:"alert"`
|
||||
Report *OpsWebhookReportConfig `json:"report"`
|
||||
}
|
||||
|
||||
// OpsWebhookPayload represents the payload sent to webhook endpoints.
|
||||
type OpsWebhookPayload struct {
|
||||
Type string `json:"type"` // "alert", "alert_resolved", "report"
|
||||
Timestamp string `json:"timestamp"`
|
||||
Data OpsWebhookData `json:"data"`
|
||||
Signature string `json:"signature,omitempty"` // HMAC signature if secret configured
|
||||
}
|
||||
|
||||
// OpsWebhookData contains the actual webhook data.
|
||||
type OpsWebhookData struct {
|
||||
// Alert fields
|
||||
Rule *OpsAlertRule `json:"rule,omitempty"`
|
||||
Event *OpsAlertEvent `json:"event,omitempty"`
|
||||
ResolvedAt string `json:"resolved_at,omitempty"`
|
||||
}
|
||||
|
||||
type OpsDistributedLockSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Key string `json:"key"`
|
||||
|
||||
190
backend/internal/service/webhook_service.go
Normal file
190
backend/internal/service/webhook_service.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// WebhookService handles sending alert notifications via webhooks.
|
||||
type WebhookService struct {
|
||||
opsService *OpsService
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewWebhookService creates a new webhook service.
|
||||
func NewWebhookService(opsService *OpsService) *WebhookService {
|
||||
return &WebhookService{
|
||||
opsService: opsService,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SendAlertWebhook sends an alert notification to configured webhook URLs.
|
||||
func (s *WebhookService) SendAlertWebhook(ctx context.Context, rule *OpsAlertRule, event *OpsAlertEvent, resolved bool) error {
|
||||
if s == nil || s.opsService == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
cfg, err := s.opsService.GetWebhookNotificationConfig(ctx)
|
||||
if err != nil || cfg == nil || !cfg.Alert.Enabled {
|
||||
return nil
|
||||
}
|
||||
|
||||
if len(cfg.Alert.URLs) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check severity threshold
|
||||
if !shouldSendWebhookByMinSeverity(cfg.Alert.MinSeverity, rule.Severity) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if resolved alerts should be sent
|
||||
if resolved && !cfg.Alert.IncludeResolved {
|
||||
return nil
|
||||
}
|
||||
|
||||
payload := s.buildAlertPayload(rule, event, resolved)
|
||||
if err := s.signPayload(payload, cfg.Alert.Secret); err != nil {
|
||||
logger.L().Warn("failed to sign webhook payload", zap.Error(err))
|
||||
}
|
||||
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal webhook payload: %w", err)
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, url := range cfg.Alert.URLs {
|
||||
url = strings.TrimSpace(url)
|
||||
if url == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := s.sendWebhook(ctx, url, payloadBytes, cfg.Alert.TimeoutSeconds, cfg.Alert.Secret); err != nil {
|
||||
logger.L().Warn("failed to send webhook", zap.String("url", url), zap.Error(err))
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
return lastErr
|
||||
}
|
||||
|
||||
// buildAlertPayload constructs the webhook payload for an alert.
|
||||
func (s *WebhookService) buildAlertPayload(rule *OpsAlertRule, event *OpsAlertEvent, resolved bool) *OpsWebhookPayload {
|
||||
payload := &OpsWebhookPayload{
|
||||
Type: "alert",
|
||||
Timestamp: time.Now().UTC().Format(time.RFC3339),
|
||||
Data: OpsWebhookData{
|
||||
Rule: rule,
|
||||
Event: event,
|
||||
},
|
||||
}
|
||||
|
||||
if resolved {
|
||||
payload.Type = "alert_resolved"
|
||||
payload.Data.ResolvedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
// signPayload adds HMAC signature to the payload if a secret is configured.
|
||||
func (s *WebhookService) signPayload(payload *OpsWebhookPayload, secret string) error {
|
||||
if secret == "" || payload == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a copy of payload without signature for signing
|
||||
signPayload := &OpsWebhookPayload{
|
||||
Type: payload.Type,
|
||||
Timestamp: payload.Timestamp,
|
||||
Data: payload.Data,
|
||||
}
|
||||
|
||||
data, err := json.Marshal(signPayload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(data)
|
||||
payload.Signature = hex.EncodeToString(mac.Sum(nil))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendWebhook sends the webhook payload to a single URL.
|
||||
func (s *WebhookService) sendWebhook(ctx context.Context, url string, payload []byte, timeoutSeconds int, secret string) error {
|
||||
if s.httpClient == nil {
|
||||
return errors.New("http client not initialized")
|
||||
}
|
||||
|
||||
timeout := time.Duration(timeoutSeconds) * time.Second
|
||||
if timeout <= 0 {
|
||||
timeout = 10 * time.Second
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: timeout}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Sub2API-Webhook/1.0")
|
||||
if secret != "" {
|
||||
req.Header.Set("X-Webhook-Signature", "sha256="+hex.EncodeToString(hmac.New(sha256.New, []byte(secret)).Sum(payload)))
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("webhook returned status %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// shouldSendWebhookByMinSeverity checks if the alert severity meets the minimum threshold.
|
||||
func shouldSendWebhookByMinSeverity(minSeverity, ruleSeverity string) bool {
|
||||
minSeverity = strings.TrimSpace(strings.ToLower(minSeverity))
|
||||
ruleSeverity = strings.TrimSpace(strings.ToLower(ruleSeverity))
|
||||
|
||||
severityLevels := map[string]int{
|
||||
"critical": 3,
|
||||
"warning": 2,
|
||||
"info": 1,
|
||||
}
|
||||
|
||||
minLevel, okMin := severityLevels[minSeverity]
|
||||
ruleLevel, okRule := severityLevels[ruleSeverity]
|
||||
|
||||
if !okMin || !okRule {
|
||||
return true // If unknown severity, send by default
|
||||
}
|
||||
|
||||
return ruleLevel >= minLevel
|
||||
}
|
||||
221
backend/internal/service/webhook_service_test.go
Normal file
221
backend/internal/service/webhook_service_test.go
Normal file
@@ -0,0 +1,221 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestShouldSendWebhookByMinSeverity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
minSeverity string
|
||||
ruleSeverity string
|
||||
want bool
|
||||
}{
|
||||
{"critical >= critical", "critical", "critical", true},
|
||||
{"warning >= critical", "critical", "warning", false},
|
||||
{"critical >= warning", "warning", "critical", true},
|
||||
{"warning >= warning", "warning", "warning", true},
|
||||
{"info >= warning", "warning", "info", false},
|
||||
{"info >= info", "info", "info", true},
|
||||
{"empty min sends all", "", "info", true},
|
||||
{"unknown severity sends by default", "unknown", "info", true},
|
||||
{"case insensitive", "CRITICAL", "Warning", false},
|
||||
{"case insensitive 2", "critical", "WARNING", false},
|
||||
{"spaces trimmed", " critical ", " warning ", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := shouldSendWebhookByMinSeverity(tt.minSeverity, tt.ruleSeverity)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookService_BuildAlertPayload(t *testing.T) {
|
||||
svc := &WebhookService{}
|
||||
|
||||
rule := &OpsAlertRule{
|
||||
ID: 1,
|
||||
Name: "Test Rule",
|
||||
Severity: "critical",
|
||||
}
|
||||
|
||||
event := &OpsAlertEvent{
|
||||
ID: 1,
|
||||
RuleID: 1,
|
||||
Title: "Test Alert",
|
||||
Description: "Test alert description",
|
||||
Severity: "critical",
|
||||
Status: "firing",
|
||||
}
|
||||
|
||||
t.Run("alert payload", func(t *testing.T) {
|
||||
payload := svc.buildAlertPayload(rule, event, false)
|
||||
assert.Equal(t, "alert", payload.Type)
|
||||
assert.NotEmpty(t, payload.Timestamp)
|
||||
assert.Equal(t, rule, payload.Data.Rule)
|
||||
assert.Equal(t, event, payload.Data.Event)
|
||||
assert.Empty(t, payload.Data.ResolvedAt)
|
||||
})
|
||||
|
||||
t.Run("resolved payload", func(t *testing.T) {
|
||||
payload := svc.buildAlertPayload(rule, event, true)
|
||||
assert.Equal(t, "alert_resolved", payload.Type)
|
||||
assert.NotEmpty(t, payload.Data.ResolvedAt)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhookService_SignPayload(t *testing.T) {
|
||||
svc := &WebhookService{}
|
||||
|
||||
t.Run("empty secret does not sign", func(t *testing.T) {
|
||||
payload := &OpsWebhookPayload{
|
||||
Type: "alert",
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
}
|
||||
err := svc.signPayload(payload, "")
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, payload.Signature)
|
||||
})
|
||||
|
||||
t.Run("nil payload returns nil", func(t *testing.T) {
|
||||
err := svc.signPayload(nil, "secret")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("signs payload with HMAC-SHA256", func(t *testing.T) {
|
||||
payload := &OpsWebhookPayload{
|
||||
Type: "alert",
|
||||
Timestamp: "2024-01-01T00:00:00Z",
|
||||
Data: OpsWebhookData{
|
||||
Rule: &OpsAlertRule{ID: 1},
|
||||
},
|
||||
}
|
||||
secret := "test-secret"
|
||||
|
||||
err := svc.signPayload(payload, secret)
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, payload.Signature)
|
||||
assert.Len(t, payload.Signature, 64) // SHA256 hex encoding = 64 chars
|
||||
|
||||
// Verify signature
|
||||
signPayload := &OpsWebhookPayload{
|
||||
Type: payload.Type,
|
||||
Timestamp: payload.Timestamp,
|
||||
Data: payload.Data,
|
||||
}
|
||||
data, _ := json.Marshal(signPayload)
|
||||
mac := hmac.New(sha256.New, []byte(secret))
|
||||
mac.Write(data)
|
||||
expectedSig := hex.EncodeToString(mac.Sum(nil))
|
||||
assert.Equal(t, expectedSig, payload.Signature)
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhookService_SendWebhook(t *testing.T) {
|
||||
svc := &WebhookService{
|
||||
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||
}
|
||||
|
||||
t.Run("successful webhook", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, http.MethodPost, r.Method)
|
||||
assert.Equal(t, "application/json", r.Header.Get("Content-Type"))
|
||||
assert.Contains(t, r.Header.Get("User-Agent"), "Sub2API-Webhook")
|
||||
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
assert.Contains(t, string(body), "test")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
payload := []byte(`{"type":"alert","test":true}`)
|
||||
err := svc.sendWebhook(context.Background(), server.URL, payload, 5, "")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("webhook with signature header", func(t *testing.T) {
|
||||
secret := "test-secret"
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
sigHeader := r.Header.Get("X-Webhook-Signature")
|
||||
assert.NotEmpty(t, sigHeader)
|
||||
assert.True(t, strings.HasPrefix(sigHeader, "sha256="))
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
payload := []byte(`{"type":"alert"}`)
|
||||
err := svc.sendWebhook(context.Background(), server.URL, payload, 5, secret)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("webhook returns error status", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
w.Write([]byte("internal error"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
payload := []byte(`{"type":"alert"}`)
|
||||
err := svc.sendWebhook(context.Background(), server.URL, payload, 5, "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "500")
|
||||
})
|
||||
|
||||
t.Run("webhook timeout", func(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
time.Sleep(2 * time.Second)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
payload := []byte(`{"type":"alert"}`)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||
defer cancel()
|
||||
|
||||
err := svc.sendWebhook(ctx, server.URL, payload, 1, "")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("invalid URL", func(t *testing.T) {
|
||||
payload := []byte(`{"type":"alert"}`)
|
||||
err := svc.sendWebhook(context.Background(), "://invalid-url", payload, 5, "")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("nil http client returns error", func(t *testing.T) {
|
||||
svc := &WebhookService{httpClient: nil}
|
||||
payload := []byte(`{"type":"alert"}`)
|
||||
err := svc.sendWebhook(context.Background(), "http://example.com", payload, 5, "")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "not initialized")
|
||||
})
|
||||
}
|
||||
|
||||
func TestWebhookService_SendAlertWebhook_NilChecks(t *testing.T) {
|
||||
t.Run("nil service returns nil", func(t *testing.T) {
|
||||
var svc *WebhookService
|
||||
err := svc.SendAlertWebhook(context.Background(), &OpsAlertRule{}, &OpsAlertEvent{}, false)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("nil ops service returns nil", func(t *testing.T) {
|
||||
svc := &WebhookService{opsService: nil}
|
||||
err := svc.SendAlertWebhook(context.Background(), &OpsAlertRule{}, &OpsAlertEvent{}, false)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
@@ -253,14 +253,20 @@ func ProvideOpsAlertEvaluatorService(
|
||||
opsService *OpsService,
|
||||
opsRepo OpsRepository,
|
||||
emailService *EmailService,
|
||||
webhookService *WebhookService,
|
||||
redisClient *redis.Client,
|
||||
cfg *config.Config,
|
||||
) *OpsAlertEvaluatorService {
|
||||
svc := NewOpsAlertEvaluatorService(opsService, opsRepo, emailService, redisClient, cfg)
|
||||
svc := NewOpsAlertEvaluatorService(opsService, opsRepo, emailService, webhookService, redisClient, cfg)
|
||||
svc.Start()
|
||||
return svc
|
||||
}
|
||||
|
||||
// ProvideWebhookService creates the webhook notification service.
|
||||
func ProvideWebhookService(opsService *OpsService) *WebhookService {
|
||||
return NewWebhookService(opsService)
|
||||
}
|
||||
|
||||
// ProvideOpsCleanupService creates and starts OpsCleanupService (cron scheduled).
|
||||
func ProvideOpsCleanupService(
|
||||
opsRepo OpsRepository,
|
||||
@@ -425,6 +431,7 @@ var ProviderSet = wire.NewSet(
|
||||
NewOpsService,
|
||||
ProvideOpsMetricsCollector,
|
||||
ProvideOpsAggregationService,
|
||||
ProvideWebhookService,
|
||||
ProvideOpsAlertEvaluatorService,
|
||||
ProvideOpsCleanupService,
|
||||
ProvideOpsScheduledReportService,
|
||||
@@ -465,6 +472,7 @@ var ProviderSet = wire.NewSet(
|
||||
ProvidePaymentConfigService,
|
||||
NewPaymentService,
|
||||
ProvidePaymentOrderExpiryService,
|
||||
ProvideHealthChecker,
|
||||
|
||||
// Sora 相关服务 (从本地版本合并)
|
||||
ProvideSoraMediaStorage,
|
||||
@@ -519,3 +527,8 @@ func ProvidePaymentOrderExpiryService(paymentSvc *PaymentService) *PaymentOrderE
|
||||
svc.Start()
|
||||
return svc
|
||||
}
|
||||
|
||||
// ProvideHealthChecker creates HealthChecker for dependency health checks
|
||||
func ProvideHealthChecker(db *sql.DB, redisClient *redis.Client) *HealthChecker {
|
||||
return NewHealthChecker(db, redisClient)
|
||||
}
|
||||
|
||||
@@ -94,7 +94,7 @@ export interface TestS3Request {
|
||||
region: string
|
||||
bucket: string
|
||||
access_key_id: string
|
||||
secret_access_key: string
|
||||
secret_access_key?: string
|
||||
prefix?: string
|
||||
force_path_style?: boolean
|
||||
use_ssl?: boolean
|
||||
|
||||
@@ -804,6 +804,25 @@ export interface EmailNotificationConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export interface WebhookNotificationConfig {
|
||||
alert: {
|
||||
enabled: boolean
|
||||
urls: string[]
|
||||
secret?: string
|
||||
min_severity: AlertSeverity | ''
|
||||
timeout_seconds: number
|
||||
include_resolved: boolean
|
||||
rate_limit_per_hour: number
|
||||
}
|
||||
report: {
|
||||
enabled: boolean
|
||||
urls: string[]
|
||||
secret?: string
|
||||
daily_enabled: boolean
|
||||
daily_schedule: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface OpsMetricThresholds {
|
||||
sla_percent_min?: number | null // SLA低于此值变红
|
||||
ttft_p99_ms_max?: number | null // TTFT P99高于此值变红
|
||||
@@ -1300,6 +1319,17 @@ export async function updateEmailNotificationConfig(config: EmailNotificationCon
|
||||
return data
|
||||
}
|
||||
|
||||
// Webhook notification config (DB-backed)
|
||||
export async function getWebhookNotificationConfig(): Promise<WebhookNotificationConfig> {
|
||||
const { data } = await apiClient.get<WebhookNotificationConfig>('/admin/ops/webhook-notification/config')
|
||||
return data
|
||||
}
|
||||
|
||||
export async function updateWebhookNotificationConfig(config: WebhookNotificationConfig): Promise<WebhookNotificationConfig> {
|
||||
const { data } = await apiClient.put<WebhookNotificationConfig>('/admin/ops/webhook-notification/config', config)
|
||||
return data
|
||||
}
|
||||
|
||||
// Runtime settings (DB-backed)
|
||||
export async function getAlertRuntimeSettings(): Promise<OpsAlertRuntimeSettings> {
|
||||
const { data } = await apiClient.get<OpsAlertRuntimeSettings>('/admin/ops/runtime/alert')
|
||||
@@ -1407,6 +1437,8 @@ export const opsAPI = {
|
||||
createAlertSilence,
|
||||
getEmailNotificationConfig,
|
||||
updateEmailNotificationConfig,
|
||||
getWebhookNotificationConfig,
|
||||
updateWebhookNotificationConfig,
|
||||
getAlertRuntimeSettings,
|
||||
updateAlertRuntimeSettings,
|
||||
getRuntimeLogConfig,
|
||||
|
||||
@@ -4048,6 +4048,34 @@ export default {
|
||||
accountHealthThresholdRange: 'Account health threshold must be between 0 and 100'
|
||||
}
|
||||
},
|
||||
webhookNotification: {
|
||||
title: 'Webhook Notification Config',
|
||||
description: 'Configure alert webhook notifications for enterprise IM integration (DingTalk, Feishu, WeChat Work, etc.).',
|
||||
loading: 'Loading...',
|
||||
loadFailed: 'Failed to load webhook config',
|
||||
saveSuccess: 'Webhook config saved',
|
||||
saveFailed: 'Failed to save webhook config',
|
||||
alertTitle: 'Alert Webhooks',
|
||||
reportTitle: 'Report Webhooks',
|
||||
urls: 'Webhook URLs',
|
||||
urlsHint: 'One URL per line, multiple webhooks supported',
|
||||
secret: 'Signing Secret',
|
||||
secretHint: 'Optional, used to generate HMAC-SHA256 signature',
|
||||
minSeverity: 'Minimum Severity',
|
||||
minSeverityAll: 'All severities',
|
||||
timeoutSeconds: 'Timeout (seconds)',
|
||||
includeResolved: 'Include resolved alerts',
|
||||
rateLimitPerHour: 'Rate limit per hour',
|
||||
dailyReport: 'Daily Report',
|
||||
validation: {
|
||||
title: 'Please fix the following issues',
|
||||
invalid: 'Invalid webhook config',
|
||||
urlsRequired: 'Webhook enabled but no URLs configured',
|
||||
invalidUrls: 'Invalid URL format',
|
||||
timeoutRange: 'Timeout must be between 1 and 60 seconds',
|
||||
rateLimitRange: 'Rate limit must be >= 0'
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
title: 'Ops Monitoring Settings',
|
||||
loadFailed: 'Failed to load settings',
|
||||
|
||||
@@ -4212,6 +4212,34 @@ export default {
|
||||
accountHealthThresholdRange: '账号健康错误率阈值必须在 0 到 100 之间'
|
||||
}
|
||||
},
|
||||
webhookNotification: {
|
||||
title: 'Webhook 通知配置',
|
||||
description: '配置告警 Webhook 通知,支持集成企业 IM(钉钉、飞书、企业微信等)。',
|
||||
loading: '加载中...',
|
||||
loadFailed: '加载 Webhook 配置失败',
|
||||
saveSuccess: 'Webhook 配置已保存',
|
||||
saveFailed: '保存 Webhook 配置失败',
|
||||
alertTitle: '告警 Webhook',
|
||||
reportTitle: '报告 Webhook',
|
||||
urls: 'Webhook URL',
|
||||
urlsHint: '每行一个 URL,支持多个 Webhook',
|
||||
secret: '签名密钥',
|
||||
secretHint: '可选,用于生成 HMAC-SHA256 签名',
|
||||
minSeverity: '最低级别',
|
||||
minSeverityAll: '全部级别',
|
||||
timeoutSeconds: '超时时间(秒)',
|
||||
includeResolved: '包含恢复通知',
|
||||
rateLimitPerHour: '每小时限额',
|
||||
dailyReport: '每日报告',
|
||||
validation: {
|
||||
title: '请先修正以下问题',
|
||||
invalid: 'Webhook 配置不合法',
|
||||
urlsRequired: '已启用 Webhook,但未配置任何 URL',
|
||||
invalidUrls: '存在不合法的 URL',
|
||||
timeoutRange: '超时时间必须在 1 到 60 秒之间',
|
||||
rateLimitRange: '每小时限额必须为 ≥ 0 的数字'
|
||||
}
|
||||
},
|
||||
settings: {
|
||||
title: '运维监控设置',
|
||||
loadFailed: '加载设置失败',
|
||||
|
||||
@@ -455,6 +455,18 @@ const routes: RouteRecordRaw[] = [
|
||||
descriptionKey: 'admin.usage.description'
|
||||
}
|
||||
},
|
||||
{
|
||||
path: '/admin/data-management',
|
||||
name: 'AdminDataManagement',
|
||||
component: () => import('@/views/admin/data-management/DataManagementView.vue'),
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresAdmin: true,
|
||||
title: 'Data Management',
|
||||
titleKey: 'admin.dataManagement.title',
|
||||
descriptionKey: 'admin.dataManagement.description'
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// ==================== Payment Admin Routes ====================
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import {
|
||||
dataManagementAPI,
|
||||
type BackupAgentHealth
|
||||
} from '@/api/admin/dataManagement'
|
||||
import PostgresProfilesCard from './components/PostgresProfilesCard.vue'
|
||||
import RedisProfilesCard from './components/RedisProfilesCard.vue'
|
||||
import S3ProfilesCard from './components/S3ProfilesCard.vue'
|
||||
import BackupJobsCard from './components/BackupJobsCard.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const agentHealth = ref<BackupAgentHealth | null>(null)
|
||||
|
||||
const postgresCard = ref<InstanceType<typeof PostgresProfilesCard> | null>(null)
|
||||
const redisCard = ref<InstanceType<typeof RedisProfilesCard> | null>(null)
|
||||
const s3Card = ref<InstanceType<typeof S3ProfilesCard> | null>(null)
|
||||
const backupCard = ref<InstanceType<typeof BackupJobsCard> | null>(null)
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const days = Math.floor(seconds / 86400)
|
||||
const hours = Math.floor((seconds % 86400) / 3600)
|
||||
const mins = Math.floor((seconds % 3600) / 60)
|
||||
if (days > 0) return `${days}d ${hours}h`
|
||||
if (hours > 0) return `${hours}h ${mins}m`
|
||||
return `${mins}m`
|
||||
}
|
||||
|
||||
async function fetchAgentHealth() {
|
||||
try {
|
||||
agentHealth.value = await dataManagementAPI.getAgentHealth()
|
||||
} catch (err: any) {
|
||||
console.error('[DataManagementView] Failed to fetch agent health', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchAgentHealth)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.dataManagement.title') }}
|
||||
</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
:class="agentHealth?.enabled ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' : 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400'"
|
||||
>
|
||||
{{ agentHealth?.enabled ? t('admin.dataManagement.agent.enabled') : t('admin.dataManagement.agent.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Agent Status Card -->
|
||||
<div class="card p-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.dataManagement.agent.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ agentHealth?.reason || t('admin.dataManagement.agent.statusUnknown') }}
|
||||
</p>
|
||||
</div>
|
||||
<div v-if="agentHealth?.agent" class="text-right text-sm text-gray-500 dark:text-gray-400">
|
||||
<div>{{ t('admin.dataManagement.agent.version') }}: {{ agentHealth.agent.version }}</div>
|
||||
<div>{{ t('admin.dataManagement.agent.uptime') }}: {{ formatUptime(agentHealth.agent.uptime_seconds) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PostgreSQL Profiles -->
|
||||
<PostgresProfilesCard ref="postgresCard" />
|
||||
|
||||
<!-- Redis Profiles -->
|
||||
<RedisProfilesCard ref="redisCard" />
|
||||
|
||||
<!-- S3 Profiles -->
|
||||
<S3ProfilesCard ref="s3Card" />
|
||||
|
||||
<!-- Backup Jobs -->
|
||||
<BackupJobsCard ref="backupCard" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,216 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import {
|
||||
dataManagementAPI,
|
||||
type BackupJob,
|
||||
type BackupType
|
||||
} from '@/api/admin/dataManagement'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const jobs = ref<BackupJob[]>([])
|
||||
const showModal = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const form = ref<{
|
||||
backup_type: BackupType
|
||||
postgres_profile_id: string
|
||||
redis_profile_id: string
|
||||
s3_profile_id: string
|
||||
}>({
|
||||
backup_type: 'full',
|
||||
postgres_profile_id: '',
|
||||
redis_profile_id: '',
|
||||
s3_profile_id: ''
|
||||
})
|
||||
|
||||
function formatTime(time: string): string {
|
||||
if (!time) return '-'
|
||||
return new Date(time).toLocaleString()
|
||||
}
|
||||
|
||||
function getJobStatusClass(status: string): string {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400'
|
||||
case 'running':
|
||||
return 'bg-blue-100 text-blue-800 dark:bg-blue-900/20 dark:text-blue-400'
|
||||
case 'failed':
|
||||
return 'bg-red-100 text-red-800 dark:bg-red-900/20 dark:text-red-400'
|
||||
case 'pending':
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400'
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400'
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJobs() {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await dataManagementAPI.listBackupJobs()
|
||||
jobs.value = resp.items
|
||||
} catch (err: any) {
|
||||
console.error('[BackupJobsCard] Failed to fetch jobs', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('common.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal() {
|
||||
form.value = {
|
||||
backup_type: 'full',
|
||||
postgres_profile_id: '',
|
||||
redis_profile_id: '',
|
||||
s3_profile_id: ''
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
async function createJob() {
|
||||
saving.value = true
|
||||
try {
|
||||
await dataManagementAPI.createBackupJob(form.value)
|
||||
await fetchJobs()
|
||||
closeModal()
|
||||
appStore.showSuccess(t('admin.dataManagement.backupJobs.created'))
|
||||
} catch (err: any) {
|
||||
console.error('[BackupJobsCard] Failed to create job', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('common.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchJobs)
|
||||
|
||||
defineExpose({ refresh: fetchJobs })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.dataManagement.backupJobs.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.backupJobs.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="openModal">
|
||||
{{ t('admin.dataManagement.backupJobs.create') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="jobs.length === 0" class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.backupJobs.noJobs') }}
|
||||
</div>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.backupJobs.jobId') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.backupJobs.type') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.backupJobs.status') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.backupJobs.startedAt') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.backupJobs.finishedAt') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="job in jobs" :key="job.job_id">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm font-mono text-gray-900 dark:text-white">
|
||||
{{ job.job_id.slice(0, 8) }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ job.backup_type }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
:class="getJobStatusClass(job.status)"
|
||||
>
|
||||
{{ job.status }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ job.started_at ? formatTime(job.started_at) : '-' }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ job.finished_at ? formatTime(job.finished_at) : '-' }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Create Backup Job Modal -->
|
||||
<BaseDialog
|
||||
:show="showModal"
|
||||
:title="t('admin.dataManagement.backupJobs.create')"
|
||||
@close="closeModal"
|
||||
>
|
||||
<form @submit.prevent="createJob">
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.backupJobs.type') }}
|
||||
</label>
|
||||
<select v-model="form.backup_type" class="input w-full">
|
||||
<option value="full">Full</option>
|
||||
<option value="incremental">Incremental</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.backupJobs.postgresProfile') }}
|
||||
</label>
|
||||
<input v-model="form.postgres_profile_id" class="input w-full" placeholder="Profile ID" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.backupJobs.redisProfile') }}
|
||||
</label>
|
||||
<input v-model="form.redis_profile_id" class="input w-full" placeholder="Profile ID" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.backupJobs.s3Profile') }}
|
||||
</label>
|
||||
<input v-model="form.s3_profile_id" class="input w-full" placeholder="Profile ID" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? t('common.loading') : t('common.create') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,356 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import {
|
||||
dataManagementAPI,
|
||||
type DataManagementSourceProfile
|
||||
} from '@/api/admin/dataManagement'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const profiles = ref<DataManagementSourceProfile[]>([])
|
||||
const showModal = ref(false)
|
||||
const editing = ref<DataManagementSourceProfile | null>(null)
|
||||
const saving = ref(false)
|
||||
|
||||
const form = ref<{
|
||||
profile_id: string
|
||||
name: string
|
||||
config: {
|
||||
host: string
|
||||
port: number
|
||||
user: string
|
||||
password: string
|
||||
database: string
|
||||
ssl_mode: string
|
||||
container_name: string
|
||||
}
|
||||
set_active: boolean
|
||||
}>({
|
||||
profile_id: '',
|
||||
name: '',
|
||||
config: {
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: '',
|
||||
database: '',
|
||||
ssl_mode: 'disable',
|
||||
container_name: ''
|
||||
},
|
||||
set_active: false
|
||||
})
|
||||
|
||||
async function fetchProfiles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await dataManagementAPI.listSourceProfiles('postgres')
|
||||
profiles.value = resp.items
|
||||
} catch (err: any) {
|
||||
console.error('[PostgresProfilesCard] Failed to fetch profiles', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('common.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(profile?: DataManagementSourceProfile) {
|
||||
if (profile) {
|
||||
editing.value = profile
|
||||
form.value = {
|
||||
profile_id: profile.profile_id,
|
||||
name: profile.name,
|
||||
config: {
|
||||
host: profile.config.host,
|
||||
port: profile.config.port,
|
||||
user: profile.config.user,
|
||||
password: '',
|
||||
database: profile.config.database,
|
||||
ssl_mode: profile.config.ssl_mode,
|
||||
container_name: profile.config.container_name
|
||||
},
|
||||
set_active: false
|
||||
}
|
||||
} else {
|
||||
editing.value = null
|
||||
form.value = {
|
||||
profile_id: '',
|
||||
name: '',
|
||||
config: {
|
||||
host: 'localhost',
|
||||
port: 5432,
|
||||
user: 'postgres',
|
||||
password: '',
|
||||
database: '',
|
||||
ssl_mode: 'disable',
|
||||
container_name: ''
|
||||
},
|
||||
set_active: false
|
||||
}
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await dataManagementAPI.updateSourceProfile('postgres', form.value.profile_id, {
|
||||
name: form.value.name,
|
||||
config: {
|
||||
host: form.value.config.host,
|
||||
port: form.value.config.port,
|
||||
user: form.value.config.user,
|
||||
password: form.value.config.password,
|
||||
database: form.value.config.database,
|
||||
ssl_mode: form.value.config.ssl_mode,
|
||||
container_name: form.value.config.container_name,
|
||||
addr: '',
|
||||
username: '',
|
||||
db: 0
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await dataManagementAPI.createSourceProfile('postgres', {
|
||||
profile_id: form.value.profile_id,
|
||||
name: form.value.name,
|
||||
config: {
|
||||
host: form.value.config.host,
|
||||
port: form.value.config.port,
|
||||
user: form.value.config.user,
|
||||
password: form.value.config.password,
|
||||
database: form.value.config.database,
|
||||
ssl_mode: form.value.config.ssl_mode,
|
||||
container_name: form.value.config.container_name,
|
||||
addr: '',
|
||||
username: '',
|
||||
db: 0
|
||||
},
|
||||
set_active: form.value.set_active
|
||||
})
|
||||
}
|
||||
await fetchProfiles()
|
||||
closeModal()
|
||||
appStore.showSuccess(t('common.saved'))
|
||||
} catch (err: any) {
|
||||
console.error('[PostgresProfilesCard] Failed to save profile', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('common.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(profileId: string) {
|
||||
try {
|
||||
await dataManagementAPI.setActiveSourceProfile('postgres', profileId)
|
||||
await fetchProfiles()
|
||||
appStore.showSuccess(t('common.saved'))
|
||||
} catch (err: any) {
|
||||
console.error('[PostgresProfilesCard] Failed to activate profile', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('common.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(profileId: string) {
|
||||
if (!confirm(t('admin.dataManagement.profiles.confirmDelete'))) return
|
||||
try {
|
||||
await dataManagementAPI.deleteSourceProfile('postgres', profileId)
|
||||
await fetchProfiles()
|
||||
appStore.showSuccess(t('common.deleted'))
|
||||
} catch (err: any) {
|
||||
console.error('[PostgresProfilesCard] Failed to delete profile', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchProfiles)
|
||||
|
||||
defineExpose({ refresh: fetchProfiles })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.dataManagement.postgres.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.postgres.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="openModal()">
|
||||
{{ t('admin.dataManagement.postgres.addProfile') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="profiles.length === 0" class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.postgres.noProfiles') }}
|
||||
</div>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.profiles.name') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.profiles.host') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.profiles.database') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.profiles.status') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="profile in profiles" :key="profile.profile_id">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
|
||||
{{ profile.name }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ profile.config.host }}:{{ profile.config.port }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ profile.config.database }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
:class="profile.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' : 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400'"
|
||||
>
|
||||
{{ profile.is_active ? t('admin.dataManagement.profiles.active') : t('admin.dataManagement.profiles.inactive') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||
<button
|
||||
v-if="!profile.is_active"
|
||||
type="button"
|
||||
class="mr-2 text-primary-600 hover:text-primary-700"
|
||||
@click="activate(profile.profile_id)"
|
||||
>
|
||||
{{ t('admin.dataManagement.profiles.activate') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mr-2 text-primary-600 hover:text-primary-700"
|
||||
@click="openModal(profile)"
|
||||
>
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-600 hover:text-red-700"
|
||||
@click="remove(profile.profile_id)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<BaseDialog
|
||||
:show="showModal"
|
||||
:title="editing ? t('admin.dataManagement.postgres.editProfile') : t('admin.dataManagement.postgres.addProfile')"
|
||||
width="wide"
|
||||
@close="closeModal"
|
||||
>
|
||||
<form @submit.prevent="save">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.profiles.profileId') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.profile_id"
|
||||
:disabled="!!editing"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.profiles.name') }}
|
||||
</label>
|
||||
<input v-model="form.name" class="input w-full" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.postgres.host') }}
|
||||
</label>
|
||||
<input v-model="form.config.host" class="input w-full" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.postgres.port') }}
|
||||
</label>
|
||||
<input v-model.number="form.config.port" type="number" class="input w-full" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.postgres.user') }}
|
||||
</label>
|
||||
<input v-model="form.config.user" class="input w-full" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.postgres.password') }}
|
||||
</label>
|
||||
<input v-model="form.config.password" type="password" class="input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.postgres.database') }}
|
||||
</label>
|
||||
<input v-model="form.config.database" class="input w-full" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.postgres.sslMode') }}
|
||||
</label>
|
||||
<select v-model="form.config.ssl_mode" class="input w-full">
|
||||
<option value="disable">disable</option>
|
||||
<option value="require">require</option>
|
||||
<option value="verify-ca">verify-ca</option>
|
||||
<option value="verify-full">verify-full</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.postgres.containerName') }}
|
||||
</label>
|
||||
<input v-model="form.config.container_name" class="input w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? t('common.loading') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,331 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import {
|
||||
dataManagementAPI,
|
||||
type DataManagementSourceProfile
|
||||
} from '@/api/admin/dataManagement'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const profiles = ref<DataManagementSourceProfile[]>([])
|
||||
const showModal = ref(false)
|
||||
const editing = ref<DataManagementSourceProfile | null>(null)
|
||||
const saving = ref(false)
|
||||
|
||||
const form = ref<{
|
||||
profile_id: string
|
||||
name: string
|
||||
config: {
|
||||
addr: string
|
||||
username: string
|
||||
password: string
|
||||
db: number
|
||||
container_name: string
|
||||
}
|
||||
set_active: boolean
|
||||
}>({
|
||||
profile_id: '',
|
||||
name: '',
|
||||
config: {
|
||||
addr: 'localhost:6379',
|
||||
username: '',
|
||||
password: '',
|
||||
db: 0,
|
||||
container_name: ''
|
||||
},
|
||||
set_active: false
|
||||
})
|
||||
|
||||
async function fetchProfiles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await dataManagementAPI.listSourceProfiles('redis')
|
||||
profiles.value = resp.items
|
||||
} catch (err: any) {
|
||||
console.error('[RedisProfilesCard] Failed to fetch profiles', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('common.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(profile?: DataManagementSourceProfile) {
|
||||
if (profile) {
|
||||
editing.value = profile
|
||||
form.value = {
|
||||
profile_id: profile.profile_id,
|
||||
name: profile.name,
|
||||
config: {
|
||||
addr: profile.config.addr,
|
||||
username: profile.config.username,
|
||||
password: '',
|
||||
db: profile.config.db,
|
||||
container_name: profile.config.container_name
|
||||
},
|
||||
set_active: false
|
||||
}
|
||||
} else {
|
||||
editing.value = null
|
||||
form.value = {
|
||||
profile_id: '',
|
||||
name: '',
|
||||
config: {
|
||||
addr: 'localhost:6379',
|
||||
username: '',
|
||||
password: '',
|
||||
db: 0,
|
||||
container_name: ''
|
||||
},
|
||||
set_active: false
|
||||
}
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await dataManagementAPI.updateSourceProfile('redis', form.value.profile_id, {
|
||||
name: form.value.name,
|
||||
config: {
|
||||
host: '',
|
||||
port: 0,
|
||||
user: '',
|
||||
password: form.value.config.password,
|
||||
database: '',
|
||||
ssl_mode: '',
|
||||
addr: form.value.config.addr,
|
||||
username: form.value.config.username,
|
||||
db: form.value.config.db,
|
||||
container_name: form.value.config.container_name
|
||||
}
|
||||
})
|
||||
} else {
|
||||
await dataManagementAPI.createSourceProfile('redis', {
|
||||
profile_id: form.value.profile_id,
|
||||
name: form.value.name,
|
||||
config: {
|
||||
host: '',
|
||||
port: 0,
|
||||
user: '',
|
||||
password: form.value.config.password,
|
||||
database: '',
|
||||
ssl_mode: '',
|
||||
addr: form.value.config.addr,
|
||||
username: form.value.config.username,
|
||||
db: form.value.config.db,
|
||||
container_name: form.value.config.container_name
|
||||
},
|
||||
set_active: form.value.set_active
|
||||
})
|
||||
}
|
||||
await fetchProfiles()
|
||||
closeModal()
|
||||
appStore.showSuccess(t('common.saved'))
|
||||
} catch (err: any) {
|
||||
console.error('[RedisProfilesCard] Failed to save profile', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('common.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(profileId: string) {
|
||||
try {
|
||||
await dataManagementAPI.setActiveSourceProfile('redis', profileId)
|
||||
await fetchProfiles()
|
||||
appStore.showSuccess(t('common.saved'))
|
||||
} catch (err: any) {
|
||||
console.error('[RedisProfilesCard] Failed to activate profile', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('common.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(profileId: string) {
|
||||
if (!confirm(t('admin.dataManagement.profiles.confirmDelete'))) return
|
||||
try {
|
||||
await dataManagementAPI.deleteSourceProfile('redis', profileId)
|
||||
await fetchProfiles()
|
||||
appStore.showSuccess(t('common.deleted'))
|
||||
} catch (err: any) {
|
||||
console.error('[RedisProfilesCard] Failed to delete profile', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchProfiles)
|
||||
|
||||
defineExpose({ refresh: fetchProfiles })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.dataManagement.redis.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.redis.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="openModal()">
|
||||
{{ t('admin.dataManagement.redis.addProfile') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="profiles.length === 0" class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.redis.noProfiles') }}
|
||||
</div>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.profiles.name') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.redis.address') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.redis.database') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.profiles.status') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="profile in profiles" :key="profile.profile_id">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
|
||||
{{ profile.name }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ profile.config.addr }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ profile.config.db }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
:class="profile.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' : 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400'"
|
||||
>
|
||||
{{ profile.is_active ? t('admin.dataManagement.profiles.active') : t('admin.dataManagement.profiles.inactive') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||
<button
|
||||
v-if="!profile.is_active"
|
||||
type="button"
|
||||
class="mr-2 text-primary-600 hover:text-primary-700"
|
||||
@click="activate(profile.profile_id)"
|
||||
>
|
||||
{{ t('admin.dataManagement.profiles.activate') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mr-2 text-primary-600 hover:text-primary-700"
|
||||
@click="openModal(profile)"
|
||||
>
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-600 hover:text-red-700"
|
||||
@click="remove(profile.profile_id)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<BaseDialog
|
||||
:show="showModal"
|
||||
:title="editing ? t('admin.dataManagement.redis.editProfile') : t('admin.dataManagement.redis.addProfile')"
|
||||
width="wide"
|
||||
@close="closeModal"
|
||||
>
|
||||
<form @submit.prevent="save">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.profiles.profileId') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.profile_id"
|
||||
:disabled="!!editing"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.profiles.name') }}
|
||||
</label>
|
||||
<input v-model="form.name" class="input w-full" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.redis.address') }}
|
||||
</label>
|
||||
<input v-model="form.config.addr" class="input w-full" placeholder="localhost:6379" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.redis.username') }}
|
||||
</label>
|
||||
<input v-model="form.config.username" class="input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.redis.password') }}
|
||||
</label>
|
||||
<input v-model="form.config.password" type="password" class="input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.redis.database') }}
|
||||
</label>
|
||||
<input v-model.number="form.config.db" type="number" min="0" class="input w-full" />
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.redis.containerName') }}
|
||||
</label>
|
||||
<input v-model="form.config.container_name" class="input w-full" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? t('common.loading') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,363 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import {
|
||||
dataManagementAPI,
|
||||
type DataManagementS3Profile,
|
||||
type TestS3Request
|
||||
} from '@/api/admin/dataManagement'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const profiles = ref<DataManagementS3Profile[]>([])
|
||||
const showModal = ref(false)
|
||||
const editing = ref<DataManagementS3Profile | null>(null)
|
||||
const saving = ref(false)
|
||||
|
||||
const form = ref<{
|
||||
profile_id: string
|
||||
name: string
|
||||
enabled: boolean
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
access_key_id: string
|
||||
secret_access_key: string
|
||||
prefix: string
|
||||
force_path_style: boolean
|
||||
use_ssl: boolean
|
||||
set_active: boolean
|
||||
}>({
|
||||
profile_id: '',
|
||||
name: '',
|
||||
enabled: true,
|
||||
endpoint: '',
|
||||
region: 'us-east-1',
|
||||
bucket: '',
|
||||
access_key_id: '',
|
||||
secret_access_key: '',
|
||||
prefix: '',
|
||||
force_path_style: false,
|
||||
use_ssl: true,
|
||||
set_active: false
|
||||
})
|
||||
|
||||
async function fetchProfiles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const resp = await dataManagementAPI.listS3Profiles()
|
||||
profiles.value = resp.items
|
||||
} catch (err: any) {
|
||||
console.error('[S3ProfilesCard] Failed to fetch profiles', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('common.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(profile?: DataManagementS3Profile) {
|
||||
if (profile) {
|
||||
editing.value = profile
|
||||
form.value = {
|
||||
profile_id: profile.profile_id,
|
||||
name: profile.name,
|
||||
enabled: profile.s3.enabled,
|
||||
endpoint: profile.s3.endpoint,
|
||||
region: profile.s3.region,
|
||||
bucket: profile.s3.bucket,
|
||||
access_key_id: profile.s3.access_key_id,
|
||||
secret_access_key: '',
|
||||
prefix: profile.s3.prefix,
|
||||
force_path_style: profile.s3.force_path_style,
|
||||
use_ssl: profile.s3.use_ssl,
|
||||
set_active: false
|
||||
}
|
||||
} else {
|
||||
editing.value = null
|
||||
form.value = {
|
||||
profile_id: '',
|
||||
name: '',
|
||||
enabled: true,
|
||||
endpoint: '',
|
||||
region: 'us-east-1',
|
||||
bucket: '',
|
||||
access_key_id: '',
|
||||
secret_access_key: '',
|
||||
prefix: '',
|
||||
force_path_style: false,
|
||||
use_ssl: true,
|
||||
set_active: false
|
||||
}
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editing.value = null
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await dataManagementAPI.updateS3Profile(form.value.profile_id, {
|
||||
name: form.value.name,
|
||||
enabled: form.value.enabled,
|
||||
endpoint: form.value.endpoint,
|
||||
region: form.value.region,
|
||||
bucket: form.value.bucket,
|
||||
access_key_id: form.value.access_key_id,
|
||||
secret_access_key: form.value.secret_access_key || undefined,
|
||||
prefix: form.value.prefix,
|
||||
force_path_style: form.value.force_path_style,
|
||||
use_ssl: form.value.use_ssl
|
||||
})
|
||||
} else {
|
||||
await dataManagementAPI.createS3Profile(form.value)
|
||||
}
|
||||
await fetchProfiles()
|
||||
closeModal()
|
||||
appStore.showSuccess(t('common.saved'))
|
||||
} catch (err: any) {
|
||||
console.error('[S3ProfilesCard] Failed to save profile', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('common.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function test(profile: DataManagementS3Profile) {
|
||||
try {
|
||||
const req: TestS3Request = {
|
||||
endpoint: profile.s3.endpoint,
|
||||
region: profile.s3.region,
|
||||
bucket: profile.s3.bucket,
|
||||
access_key_id: profile.s3.access_key_id,
|
||||
prefix: profile.s3.prefix,
|
||||
force_path_style: profile.s3.force_path_style,
|
||||
use_ssl: profile.s3.use_ssl
|
||||
}
|
||||
await dataManagementAPI.testS3(req)
|
||||
appStore.showSuccess(t('admin.dataManagement.s3.testSuccess'))
|
||||
} catch (err: any) {
|
||||
console.error('[S3ProfilesCard] Failed to test profile', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.dataManagement.s3.testFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function activate(profileId: string) {
|
||||
try {
|
||||
await dataManagementAPI.setActiveS3Profile(profileId)
|
||||
await fetchProfiles()
|
||||
appStore.showSuccess(t('common.saved'))
|
||||
} catch (err: any) {
|
||||
console.error('[S3ProfilesCard] Failed to activate profile', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('common.saveFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(profileId: string) {
|
||||
if (!confirm(t('admin.dataManagement.profiles.confirmDelete'))) return
|
||||
try {
|
||||
await dataManagementAPI.deleteS3Profile(profileId)
|
||||
await fetchProfiles()
|
||||
appStore.showSuccess(t('common.deleted'))
|
||||
} catch (err: any) {
|
||||
console.error('[S3ProfilesCard] Failed to delete profile', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('common.deleteFailed'))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchProfiles)
|
||||
|
||||
defineExpose({ refresh: fetchProfiles })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="card p-6">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{{ t('admin.dataManagement.s3.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.s3.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn btn-primary btn-sm" @click="openModal()">
|
||||
{{ t('admin.dataManagement.s3.addProfile') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
<div v-else-if="profiles.length === 0" class="py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.s3.noProfiles') }}
|
||||
</div>
|
||||
<div v-else class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.profiles.name') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.s3.bucket') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.s3.region') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.profiles.status') }}
|
||||
</th>
|
||||
<th class="px-4 py-3 text-right text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('common.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<tr v-for="profile in profiles" :key="profile.profile_id">
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900 dark:text-white">
|
||||
{{ profile.name }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ profile.s3.bucket }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ profile.s3.region }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm">
|
||||
<span
|
||||
class="inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium"
|
||||
:class="profile.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/20 dark:text-green-400' : 'bg-gray-100 text-gray-800 dark:bg-gray-900/20 dark:text-gray-400'"
|
||||
>
|
||||
{{ profile.is_active ? t('admin.dataManagement.profiles.active') : t('admin.dataManagement.profiles.inactive') }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-right text-sm">
|
||||
<button
|
||||
type="button"
|
||||
class="mr-2 text-primary-600 hover:text-primary-700"
|
||||
@click="test(profile)"
|
||||
>
|
||||
{{ t('admin.dataManagement.s3.test') }}
|
||||
</button>
|
||||
<button
|
||||
v-if="!profile.is_active"
|
||||
type="button"
|
||||
class="mr-2 text-primary-600 hover:text-primary-700"
|
||||
@click="activate(profile.profile_id)"
|
||||
>
|
||||
{{ t('admin.dataManagement.profiles.activate') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="mr-2 text-primary-600 hover:text-primary-700"
|
||||
@click="openModal(profile)"
|
||||
>
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="text-red-600 hover:text-red-700"
|
||||
@click="remove(profile.profile_id)"
|
||||
>
|
||||
{{ t('common.delete') }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Modal -->
|
||||
<BaseDialog
|
||||
:show="showModal"
|
||||
:title="editing ? t('admin.dataManagement.s3.editProfile') : t('admin.dataManagement.s3.addProfile')"
|
||||
width="wide"
|
||||
@close="closeModal"
|
||||
>
|
||||
<form @submit.prevent="save">
|
||||
<div class="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.profiles.profileId') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="form.profile_id"
|
||||
:disabled="!!editing"
|
||||
class="input w-full"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.profiles.name') }}
|
||||
</label>
|
||||
<input v-model="form.name" class="input w-full" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.s3.endpoint') }}
|
||||
</label>
|
||||
<input v-model="form.endpoint" class="input w-full" placeholder="https://s3.amazonaws.com" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.s3.region') }}
|
||||
</label>
|
||||
<input v-model="form.region" class="input w-full" placeholder="us-east-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.s3.bucket') }}
|
||||
</label>
|
||||
<input v-model="form.bucket" class="input w-full" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.s3.prefix') }}
|
||||
</label>
|
||||
<input v-model="form.prefix" class="input w-full" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.s3.accessKeyId') }}
|
||||
</label>
|
||||
<input v-model="form.access_key_id" class="input w-full" required />
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.dataManagement.s3.secretAccessKey') }}
|
||||
</label>
|
||||
<input v-model="form.secret_access_key" type="password" class="input w-full" :placeholder="editing ? t('admin.dataManagement.s3.secretPlaceholder') : ''" />
|
||||
</div>
|
||||
<div class="flex items-center gap-4 md:col-span-2">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input v-model="form.use_ssl" type="checkbox" class="checkbox" />
|
||||
<span class="text-sm">{{ t('admin.dataManagement.s3.useSsl') }}</span>
|
||||
</label>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input v-model="form.force_path_style" type="checkbox" class="checkbox" />
|
||||
<span class="text-sm">{{ t('admin.dataManagement.s3.forcePathStyle') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? t('common.loading') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</BaseDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,7 +6,7 @@ import { opsAPI } from '@/api/admin/ops'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
import Toggle from '@/components/common/Toggle.vue'
|
||||
import type { OpsAlertRuntimeSettings, EmailNotificationConfig, AlertSeverity, OpsAdvancedSettings, OpsMetricThresholds } from '../types'
|
||||
import type { OpsAlertRuntimeSettings, EmailNotificationConfig, WebhookNotificationConfig, AlertSeverity, OpsAdvancedSettings, OpsMetricThresholds } from '../types'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
@@ -27,6 +27,8 @@ const saving = ref(false)
|
||||
const runtimeSettings = ref<OpsAlertRuntimeSettings | null>(null)
|
||||
// 邮件通知配置
|
||||
const emailConfig = ref<EmailNotificationConfig | null>(null)
|
||||
// Webhook通知配置
|
||||
const webhookConfig = ref<WebhookNotificationConfig | null>(null)
|
||||
// 高级设置
|
||||
const advancedSettings = ref<OpsAdvancedSettings | null>(null)
|
||||
// 指标阈值配置
|
||||
@@ -41,14 +43,16 @@ const metricThresholds = ref<OpsMetricThresholds>({
|
||||
async function loadAllSettings() {
|
||||
loading.value = true
|
||||
try {
|
||||
const [runtime, email, advanced, thresholds] = await Promise.all([
|
||||
const [runtime, email, webhook, advanced, thresholds] = await Promise.all([
|
||||
opsAPI.getAlertRuntimeSettings(),
|
||||
opsAPI.getEmailNotificationConfig(),
|
||||
opsAPI.getWebhookNotificationConfig(),
|
||||
opsAPI.getAdvancedSettings(),
|
||||
opsAPI.getMetricThresholds()
|
||||
])
|
||||
runtimeSettings.value = runtime
|
||||
emailConfig.value = email
|
||||
webhookConfig.value = webhook
|
||||
advancedSettings.value = advanced
|
||||
// 如果后端返回了阈值,使用后端的值;否则保持默认值
|
||||
if (thresholds && Object.keys(thresholds).length > 0) {
|
||||
@@ -78,6 +82,10 @@ watch(() => props.show, (show) => {
|
||||
const alertRecipientInput = ref('')
|
||||
const reportRecipientInput = ref('')
|
||||
|
||||
// Webhook URL输入
|
||||
const alertUrlInput = ref('')
|
||||
const reportUrlInput = ref('')
|
||||
|
||||
// 严重级别选项
|
||||
const severityOptions: Array<{ value: AlertSeverity | ''; label: string }> = [
|
||||
{ value: '', label: t('admin.ops.email.minSeverityAll') },
|
||||
@@ -91,6 +99,16 @@ function isValidEmailAddress(email: string): boolean {
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)
|
||||
}
|
||||
|
||||
// 验证URL
|
||||
function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return parsed.protocol === 'http:' || parsed.protocol === 'https:'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// 添加收件人
|
||||
function addRecipient(target: 'alert' | 'report') {
|
||||
if (!emailConfig.value) return
|
||||
@@ -119,6 +137,33 @@ function removeRecipient(target: 'alert' | 'report', email: string) {
|
||||
if (idx >= 0) list.splice(idx, 1)
|
||||
}
|
||||
|
||||
// 添加Webhook URL
|
||||
function addWebhookUrl(target: 'alert' | 'report') {
|
||||
if (!webhookConfig.value) return
|
||||
const raw = (target === 'alert' ? alertUrlInput.value : reportUrlInput.value).trim()
|
||||
if (!raw) return
|
||||
|
||||
if (!isValidUrl(raw)) {
|
||||
appStore.showError(t('admin.ops.webhookNotification.validation.invalidUrl'))
|
||||
return
|
||||
}
|
||||
|
||||
const list = target === 'alert' ? webhookConfig.value.alert.urls : webhookConfig.value.report.urls
|
||||
if (!list.includes(raw)) {
|
||||
list.push(raw)
|
||||
}
|
||||
if (target === 'alert') alertUrlInput.value = ''
|
||||
else reportUrlInput.value = ''
|
||||
}
|
||||
|
||||
// 移除Webhook URL
|
||||
function removeWebhookUrl(target: 'alert' | 'report', url: string) {
|
||||
if (!webhookConfig.value) return
|
||||
const list = target === 'alert' ? webhookConfig.value.alert.urls : webhookConfig.value.report.urls
|
||||
const idx = list.indexOf(url)
|
||||
if (idx >= 0) list.splice(idx, 1)
|
||||
}
|
||||
|
||||
// 验证
|
||||
const validation = computed(() => {
|
||||
const errors: string[] = []
|
||||
@@ -185,6 +230,7 @@ async function saveAllSettings() {
|
||||
await Promise.all([
|
||||
runtimeSettings.value ? opsAPI.updateAlertRuntimeSettings(runtimeSettings.value) : Promise.resolve(),
|
||||
emailConfig.value ? opsAPI.updateEmailNotificationConfig(emailConfig.value) : Promise.resolve(),
|
||||
webhookConfig.value ? opsAPI.updateWebhookNotificationConfig(webhookConfig.value) : Promise.resolve(),
|
||||
advancedSettings.value ? opsAPI.updateAdvancedSettings(advancedSettings.value) : Promise.resolve(),
|
||||
opsAPI.updateMetricThresholds(metricThresholds.value)
|
||||
])
|
||||
@@ -206,7 +252,7 @@ async function saveAllSettings() {
|
||||
{{ t('common.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="runtimeSettings && emailConfig && advancedSettings" class="space-y-6">
|
||||
<div v-else-if="runtimeSettings && emailConfig && webhookConfig && advancedSettings" class="space-y-6">
|
||||
<!-- 验证错误 -->
|
||||
<div v-if="!validation.valid" class="rounded-lg border border-amber-200 bg-amber-50 p-3 text-xs text-amber-800 dark:border-amber-900/50 dark:bg-amber-900/20 dark:text-amber-200">
|
||||
<div class="font-bold">{{ t('admin.ops.settings.validation.title') }}</div>
|
||||
@@ -339,6 +385,126 @@ async function saveAllSettings() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Webhook通知配置 -->
|
||||
<div v-if="webhookConfig" class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.webhookNotification.title') }}</h4>
|
||||
<p class="mb-4 text-xs text-gray-500 dark:text-gray-400">{{ t('admin.ops.webhookNotification.description') }}</p>
|
||||
|
||||
<!-- Alert Webhook -->
|
||||
<div class="mb-6 space-y-4">
|
||||
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.webhookNotification.alertTitle') }}</h5>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{ t('common.enable') }}</label>
|
||||
<Toggle v-model="webhookConfig.alert.enabled" />
|
||||
</div>
|
||||
|
||||
<div v-if="webhookConfig.alert.enabled">
|
||||
<label class="input-label">{{ t('admin.ops.webhookNotification.urls') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="alertUrlInput"
|
||||
type="url"
|
||||
class="input flex-1"
|
||||
:placeholder="'https://example.com/webhook'"
|
||||
@keydown.enter.prevent="addWebhookUrl('alert')"
|
||||
/>
|
||||
<button class="btn btn-secondary whitespace-nowrap" type="button" @click="addWebhookUrl('alert')">
|
||||
{{ t('common.add') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="webhookConfig.alert.urls.length > 0" class="mt-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="url in webhookConfig.alert.urls"
|
||||
:key="url"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-purple-100 px-3 py-1 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
>
|
||||
<span class="max-w-[200px] truncate">{{ url }}</span>
|
||||
<button type="button" class="text-purple-700/80 hover:text-purple-900" @click="removeWebhookUrl('alert', url)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="webhookConfig.alert.enabled" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.webhookNotification.minSeverity') }}</label>
|
||||
<Select v-model="webhookConfig.alert.min_severity" :options="severityOptions" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.webhookNotification.timeoutSeconds') }}</label>
|
||||
<input v-model.number="webhookConfig.alert.timeout_seconds" type="number" min="1" max="60" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="webhookConfig.alert.enabled" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="input-label">{{ t('admin.ops.webhookNotification.rateLimitPerHour') }}</label>
|
||||
<input v-model.number="webhookConfig.alert.rate_limit_per_hour" type="number" min="0" class="input" />
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.webhookNotification.includeResolved') }}</label>
|
||||
<Toggle v-model="webhookConfig.alert.include_resolved" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="webhookConfig.alert.enabled">
|
||||
<label class="input-label">{{ t('admin.ops.webhookNotification.secret') }}</label>
|
||||
<input
|
||||
v-model="webhookConfig.alert.secret"
|
||||
type="password"
|
||||
class="input"
|
||||
:placeholder="t('admin.ops.webhookNotification.secretHint')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report Webhook -->
|
||||
<div class="space-y-4">
|
||||
<h5 class="text-xs font-semibold text-gray-700 dark:text-gray-300">{{ t('admin.ops.webhookNotification.reportTitle') }}</h5>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<label class="font-medium text-gray-900 dark:text-white">{{ t('common.enable') }}</label>
|
||||
<Toggle v-model="webhookConfig.report.enabled" />
|
||||
</div>
|
||||
|
||||
<div v-if="webhookConfig.report.enabled">
|
||||
<label class="input-label">{{ t('admin.ops.webhookNotification.urls') }}</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="reportUrlInput"
|
||||
type="url"
|
||||
class="input flex-1"
|
||||
:placeholder="'https://example.com/webhook'"
|
||||
@keydown.enter.prevent="addWebhookUrl('report')"
|
||||
/>
|
||||
<button class="btn btn-secondary whitespace-nowrap" type="button" @click="addWebhookUrl('report')">
|
||||
{{ t('common.add') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="webhookConfig.report.urls.length > 0" class="mt-2 flex flex-wrap gap-2">
|
||||
<span
|
||||
v-for="url in webhookConfig.report.urls"
|
||||
:key="url"
|
||||
class="inline-flex items-center gap-2 rounded-full bg-purple-100 px-3 py-1 text-xs font-medium text-purple-700 dark:bg-purple-900/30 dark:text-purple-400"
|
||||
>
|
||||
<span class="max-w-[200px] truncate">{{ url }}</span>
|
||||
<button type="button" class="text-purple-700/80 hover:text-purple-900" @click="removeWebhookUrl('report', url)">×</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="webhookConfig.report.enabled">
|
||||
<label class="input-label">{{ t('admin.ops.webhookNotification.secret') }}</label>
|
||||
<input
|
||||
v-model="webhookConfig.report.secret"
|
||||
type="password"
|
||||
class="input"
|
||||
:placeholder="t('admin.ops.webhookNotification.secretHint')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 指标阈值配置 -->
|
||||
<div class="rounded-2xl bg-gray-50 p-4 dark:bg-dark-700/50">
|
||||
<h4 class="mb-3 text-sm font-semibold text-gray-900 dark:text-white">{{ t('admin.ops.settings.metricThresholds') }}</h4>
|
||||
|
||||
@@ -0,0 +1,373 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { opsAPI } from '@/api/admin/ops'
|
||||
import type { WebhookNotificationConfig, AlertSeverity } from '@/api/admin/ops'
|
||||
import BaseDialog from '@/components/common/BaseDialog.vue'
|
||||
import Select from '@/components/common/Select.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const appStore = useAppStore()
|
||||
|
||||
const loading = ref(false)
|
||||
const config = ref<WebhookNotificationConfig | null>(null)
|
||||
|
||||
const showEditor = ref(false)
|
||||
const saving = ref(false)
|
||||
const draft = ref<WebhookNotificationConfig | null>(null)
|
||||
const alertUrlInput = ref('')
|
||||
const reportUrlInput = ref('')
|
||||
|
||||
const severityOptions: Array<{ value: AlertSeverity | ''; label: string }> = [
|
||||
{ value: '', label: t('admin.ops.webhookNotification.minSeverityAll') },
|
||||
{ value: 'critical', label: t('common.critical') },
|
||||
{ value: 'warning', label: t('common.warning') },
|
||||
{ value: 'info', label: t('common.info') }
|
||||
]
|
||||
|
||||
async function loadConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await opsAPI.getWebhookNotificationConfig()
|
||||
config.value = data
|
||||
} catch (err: any) {
|
||||
console.error('[OpsWebhookNotificationCard] Failed to load config', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.webhookNotification.loadFailed'))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
if (!draft.value) return
|
||||
if (!editorValidation.value.valid) {
|
||||
appStore.showError(editorValidation.value.errors[0] || t('admin.ops.webhookNotification.validation.invalid'))
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
config.value = await opsAPI.updateWebhookNotificationConfig(draft.value)
|
||||
showEditor.value = false
|
||||
appStore.showSuccess(t('admin.ops.webhookNotification.saveSuccess'))
|
||||
} catch (err: any) {
|
||||
console.error('[OpsWebhookNotificationCard] Failed to save config', err)
|
||||
appStore.showError(err?.response?.data?.detail || t('admin.ops.webhookNotification.saveFailed'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openEditor() {
|
||||
if (!config.value) return
|
||||
draft.value = JSON.parse(JSON.stringify(config.value))
|
||||
alertUrlInput.value = ''
|
||||
reportUrlInput.value = ''
|
||||
showEditor.value = true
|
||||
}
|
||||
|
||||
function isValidUrl(url: string): boolean {
|
||||
try {
|
||||
new URL(url)
|
||||
return url.startsWith('http://') || url.startsWith('https://')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function addAlertUrl() {
|
||||
if (!draft.value) return
|
||||
const url = alertUrlInput.value.trim()
|
||||
if (url && isValidUrl(url)) {
|
||||
if (!draft.value.alert.urls.includes(url)) {
|
||||
draft.value.alert.urls.push(url)
|
||||
}
|
||||
alertUrlInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function removeAlertUrl(index: number) {
|
||||
if (!draft.value) return
|
||||
draft.value.alert.urls.splice(index, 1)
|
||||
}
|
||||
|
||||
function addReportUrl() {
|
||||
if (!draft.value) return
|
||||
const url = reportUrlInput.value.trim()
|
||||
if (url && isValidUrl(url)) {
|
||||
if (!draft.value.report.urls.includes(url)) {
|
||||
draft.value.report.urls.push(url)
|
||||
}
|
||||
reportUrlInput.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function removeReportUrl(index: number) {
|
||||
if (!draft.value) return
|
||||
draft.value.report.urls.splice(index, 1)
|
||||
}
|
||||
|
||||
const editorValidation = computed(() => {
|
||||
const errors: string[] = []
|
||||
if (!draft.value) return { valid: true, errors }
|
||||
|
||||
if (draft.value.alert.enabled && draft.value.alert.urls.length === 0) {
|
||||
errors.push(t('admin.ops.webhookNotification.validation.urlsRequired'))
|
||||
}
|
||||
if (draft.value.report.enabled && draft.value.report.urls.length === 0) {
|
||||
errors.push(t('admin.ops.webhookNotification.validation.urlsRequired'))
|
||||
}
|
||||
|
||||
const invalidAlertUrls = draft.value.alert.urls.filter((u) => !isValidUrl(u))
|
||||
if (invalidAlertUrls.length > 0) errors.push(t('admin.ops.webhookNotification.validation.invalidUrls'))
|
||||
|
||||
const invalidReportUrls = draft.value.report.urls.filter((u) => !isValidUrl(u))
|
||||
if (invalidReportUrls.length > 0) errors.push(t('admin.ops.webhookNotification.validation.invalidUrls'))
|
||||
|
||||
if (draft.value.alert.timeout_seconds < 1 || draft.value.alert.timeout_seconds > 60) {
|
||||
errors.push(t('admin.ops.webhookNotification.validation.timeoutRange'))
|
||||
}
|
||||
if (draft.value.alert.rate_limit_per_hour < 0) {
|
||||
errors.push(t('admin.ops.webhookNotification.validation.rateLimitRange'))
|
||||
}
|
||||
|
||||
return { valid: errors.length === 0, errors }
|
||||
})
|
||||
|
||||
onMounted(loadConfig)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div class="mb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{{ t('admin.ops.webhookNotification.title') }}
|
||||
</h3>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.ops.webhookNotification.description') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-secondary text-sm"
|
||||
@click="openEditor"
|
||||
:disabled="loading || !config"
|
||||
>
|
||||
{{ t('common.edit') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="py-8 text-center text-gray-500">
|
||||
{{ t('admin.ops.webhookNotification.loading') }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="config" class="space-y-4">
|
||||
<!-- Alert Webhook Status -->
|
||||
<div class="flex items-center justify-between border-b border-gray-100 pb-3 dark:border-gray-700">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.webhookNotification.alertTitle') }}</span>
|
||||
<span
|
||||
:class="config.alert.enabled && config.alert.urls.length > 0
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-400'"
|
||||
class="text-sm"
|
||||
>
|
||||
{{ config.alert.enabled && config.alert.urls.length > 0 ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="config.alert.enabled && config.alert.urls.length > 0" class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<div class="mb-1">{{ config.alert.urls.length }} webhook(s) configured</div>
|
||||
<div class="text-xs">Min severity: {{ config.alert.min_severity || 'all' }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Report Webhook Status -->
|
||||
<div class="flex items-center justify-between border-b border-gray-100 pb-3 dark:border-gray-700">
|
||||
<span class="font-medium text-gray-700 dark:text-gray-300">{{ t('admin.ops.webhookNotification.reportTitle') }}</span>
|
||||
<span
|
||||
:class="config.report.enabled && config.report.urls.length > 0
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-gray-400'"
|
||||
class="text-sm"
|
||||
>
|
||||
{{ config.report.enabled && config.report.urls.length > 0 ? t('common.enabled') : t('common.disabled') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor Dialog -->
|
||||
<BaseDialog
|
||||
:show="showEditor"
|
||||
:title="t('admin.ops.webhookNotification.title')"
|
||||
width="wide"
|
||||
@close="showEditor = false"
|
||||
>
|
||||
<div v-if="draft" class="space-y-6">
|
||||
<!-- Alert Webhooks -->
|
||||
<div>
|
||||
<h4 class="mb-3 font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ t('admin.ops.webhookNotification.alertTitle') }}
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input v-model="draft.alert.enabled" type="checkbox" class="checkbox" />
|
||||
<span class="text-sm">{{ t('common.enable') }}</span>
|
||||
</label>
|
||||
|
||||
<div v-if="draft.alert.enabled">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.ops.webhookNotification.urls') }}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="alertUrlInput"
|
||||
type="url"
|
||||
class="input flex-1"
|
||||
:placeholder="'https://example.com/webhook'"
|
||||
@keyup.enter="addAlertUrl"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary" @click="addAlertUrl">
|
||||
{{ t('common.add') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="draft.alert.urls.length > 0" class="mt-2 space-y-1">
|
||||
<div
|
||||
v-for="(url, index) in draft.alert.urls"
|
||||
:key="index"
|
||||
class="flex items-center justify-between rounded bg-gray-50 px-2 py-1 text-sm dark:bg-gray-700"
|
||||
>
|
||||
<span class="truncate text-gray-700 dark:text-gray-300">{{ url }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-red-500"
|
||||
@click="removeAlertUrl(index)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="draft.alert.enabled" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.ops.webhookNotification.minSeverity') }}
|
||||
</label>
|
||||
<Select
|
||||
v-model="draft.alert.min_severity"
|
||||
:options="severityOptions"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.ops.webhookNotification.timeoutSeconds') }}
|
||||
</label>
|
||||
<input v-model.number="draft.alert.timeout_seconds" type="number" min="1" max="60" class="input w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="draft.alert.enabled" class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.ops.webhookNotification.rateLimitPerHour') }}
|
||||
</label>
|
||||
<input v-model.number="draft.alert.rate_limit_per_hour" type="number" min="0" class="input w-full" />
|
||||
</div>
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input v-model="draft.alert.include_resolved" type="checkbox" class="checkbox" />
|
||||
<span class="text-sm">{{ t('admin.ops.webhookNotification.includeResolved') }}</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="draft.alert.enabled">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.ops.webhookNotification.secret') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="draft.alert.secret"
|
||||
type="password"
|
||||
class="input w-full"
|
||||
:placeholder="t('admin.ops.webhookNotification.secretHint')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report Webhooks -->
|
||||
<div>
|
||||
<h4 class="mb-3 font-medium text-gray-900 dark:text-gray-100">
|
||||
{{ t('admin.ops.webhookNotification.reportTitle') }}
|
||||
</h4>
|
||||
<div class="space-y-3">
|
||||
<label class="inline-flex items-center gap-2">
|
||||
<input v-model="draft.report.enabled" type="checkbox" class="checkbox" />
|
||||
<span class="text-sm">{{ t('common.enable') }}</span>
|
||||
</label>
|
||||
|
||||
<div v-if="draft.report.enabled">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.ops.webhookNotification.urls') }}
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="reportUrlInput"
|
||||
type="url"
|
||||
class="input flex-1"
|
||||
:placeholder="'https://example.com/webhook'"
|
||||
@keyup.enter="addReportUrl"
|
||||
/>
|
||||
<button type="button" class="btn btn-secondary" @click="addReportUrl">
|
||||
{{ t('common.add') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="draft.report.urls.length > 0" class="mt-2 space-y-1">
|
||||
<div
|
||||
v-for="(url, index) in draft.report.urls"
|
||||
:key="index"
|
||||
class="flex items-center justify-between rounded bg-gray-50 px-2 py-1 text-sm dark:bg-gray-700"
|
||||
>
|
||||
<span class="truncate text-gray-700 dark:text-gray-300">{{ url }}</span>
|
||||
<button
|
||||
type="button"
|
||||
class="text-gray-400 hover:text-red-500"
|
||||
@click="removeReportUrl(index)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="draft.report.enabled">
|
||||
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">
|
||||
{{ t('admin.ops.webhookNotification.secret') }}
|
||||
</label>
|
||||
<input
|
||||
v-model="draft.report.secret"
|
||||
type="password"
|
||||
class="input w-full"
|
||||
:placeholder="t('admin.ops.webhookNotification.secretHint')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button type="button" class="btn btn-secondary" @click="showEditor = false">
|
||||
{{ t('common.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-primary"
|
||||
:disabled="saving || !editorValidation.valid"
|
||||
@click="saveConfig"
|
||||
>
|
||||
{{ saving ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</BaseDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,179 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import OpsSettingsDialog from '../OpsSettingsDialog.vue'
|
||||
|
||||
const mockGetAlertRuntimeSettings = vi.fn()
|
||||
const mockGetEmailNotificationConfig = vi.fn()
|
||||
const mockGetWebhookNotificationConfig = vi.fn()
|
||||
const mockGetAdvancedSettings = vi.fn()
|
||||
const mockGetMetricThresholds = vi.fn()
|
||||
|
||||
vi.mock('@/api/admin/ops', () => ({
|
||||
opsAPI: {
|
||||
getAlertRuntimeSettings: () => mockGetAlertRuntimeSettings(),
|
||||
getEmailNotificationConfig: () => mockGetEmailNotificationConfig(),
|
||||
getWebhookNotificationConfig: () => mockGetWebhookNotificationConfig(),
|
||||
getAdvancedSettings: () => mockGetAdvancedSettings(),
|
||||
getMetricThresholds: () => mockGetMetricThresholds(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/app', () => ({
|
||||
useAppStore: () => ({
|
||||
showSuccess: vi.fn(),
|
||||
showError: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('vue-i18n')>()
|
||||
return {
|
||||
...actual,
|
||||
useI18n: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
const defaultRuntimeSettings = {
|
||||
evaluation_interval_seconds: 300,
|
||||
}
|
||||
|
||||
const defaultEmailConfig = {
|
||||
alert: {
|
||||
enabled: false,
|
||||
recipients: [],
|
||||
min_severity: 'critical',
|
||||
},
|
||||
report: {
|
||||
enabled: false,
|
||||
recipients: [],
|
||||
daily_summary_enabled: false,
|
||||
daily_summary_schedule: '',
|
||||
weekly_summary_enabled: false,
|
||||
weekly_summary_schedule: '',
|
||||
},
|
||||
}
|
||||
|
||||
const defaultWebhookConfig = {
|
||||
alert: {
|
||||
enabled: false,
|
||||
urls: [],
|
||||
secret: '',
|
||||
min_severity: 'critical',
|
||||
timeout_seconds: 10,
|
||||
include_resolved: false,
|
||||
rate_limit_per_hour: 60,
|
||||
},
|
||||
report: {
|
||||
enabled: false,
|
||||
urls: [],
|
||||
secret: '',
|
||||
daily_enabled: false,
|
||||
daily_schedule: '0 9 * * *',
|
||||
},
|
||||
}
|
||||
|
||||
const defaultAdvancedSettings = {
|
||||
data_retention: {
|
||||
cleanup_enabled: true,
|
||||
cleanup_schedule: '0 2 * * *',
|
||||
error_log_retention_days: 30,
|
||||
minute_metrics_retention_days: 7,
|
||||
hourly_metrics_retention_days: 90,
|
||||
},
|
||||
aggregation: {
|
||||
aggregation_enabled: true,
|
||||
},
|
||||
ignore_count_tokens_errors: true,
|
||||
ignore_context_canceled: true,
|
||||
ignore_no_available_accounts: false,
|
||||
ignore_invalid_api_key_errors: true,
|
||||
ignore_insufficient_balance_errors: true,
|
||||
auto_refresh_enabled: true,
|
||||
auto_refresh_interval_seconds: 30,
|
||||
display_alert_events: true,
|
||||
display_openai_token_stats: true,
|
||||
}
|
||||
|
||||
const defaultMetricThresholds = {
|
||||
sla_percent_min: 99.5,
|
||||
ttft_p99_ms_max: 500,
|
||||
request_error_rate_percent_max: 5,
|
||||
upstream_error_rate_percent_max: 5,
|
||||
}
|
||||
|
||||
describe('OpsSettingsDialog', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mockGetAlertRuntimeSettings.mockResolvedValue(defaultRuntimeSettings)
|
||||
mockGetEmailNotificationConfig.mockResolvedValue(defaultEmailConfig)
|
||||
mockGetWebhookNotificationConfig.mockResolvedValue(defaultWebhookConfig)
|
||||
mockGetAdvancedSettings.mockResolvedValue(defaultAdvancedSettings)
|
||||
mockGetMetricThresholds.mockResolvedValue(defaultMetricThresholds)
|
||||
})
|
||||
|
||||
it('does not load settings when show is false', async () => {
|
||||
mount(OpsSettingsDialog, {
|
||||
props: { show: false },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: {
|
||||
template: '<div v-if="show"><slot /></div>',
|
||||
props: ['show'],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetAlertRuntimeSettings).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('loads settings when show changes from false to true', async () => {
|
||||
const wrapper = mount(OpsSettingsDialog, {
|
||||
props: { show: false },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: {
|
||||
template: '<div v-if="show"><slot /></div>',
|
||||
props: ['show'],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
await flushPromises()
|
||||
expect(mockGetAlertRuntimeSettings).not.toHaveBeenCalled()
|
||||
|
||||
await wrapper.setProps({ show: true })
|
||||
await flushPromises()
|
||||
|
||||
expect(mockGetAlertRuntimeSettings).toHaveBeenCalled()
|
||||
expect(mockGetWebhookNotificationConfig).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('handles API error on load gracefully', async () => {
|
||||
mockGetWebhookNotificationConfig.mockRejectedValue(new Error('Load failed'))
|
||||
|
||||
const wrapper = mount(OpsSettingsDialog, {
|
||||
props: { show: true },
|
||||
global: {
|
||||
stubs: {
|
||||
BaseDialog: {
|
||||
template: '<div v-if="show"><slot /></div>',
|
||||
props: ['show'],
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// Wait for watch to trigger and API call to complete
|
||||
await flushPromises()
|
||||
await flushPromises()
|
||||
|
||||
// Component should not crash
|
||||
expect(wrapper.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -12,6 +12,7 @@ export type {
|
||||
MetricType,
|
||||
Operator,
|
||||
EmailNotificationConfig,
|
||||
WebhookNotificationConfig,
|
||||
OpsDistributedLockSettings,
|
||||
OpsAlertRuntimeSettings,
|
||||
OpsMetricThresholds,
|
||||
|
||||
Reference in New Issue
Block a user