From 9de0a709446bfcc8156843eb5b25f69e2a26cc2e Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Wed, 17 Sep 2025 22:30:09 +0300 Subject: [PATCH 01/71] #24 implement agent --- .gitignore | 1 + Dockerfile | 2 +- Dockerfile-agent | 37 +++ Makefile | 29 +- buf.gen.yaml | 13 + buf.yaml | 18 ++ cmd/agent/config.go | 56 ++++ cmd/agent/main.go | 63 ++++ cmd/agent/start.go | 105 +++++++ cmd/agent/version.go | 19 ++ cmd/sentinel/config.go | 14 +- cmd/sentinel/main.go | 1 + cmd/sentinel/start.go | 12 +- cmd/testapi/main.go | 5 +- config-agent.template.yaml | 33 ++ config.template.yaml | 29 +- go.mod | 40 ++- go.sum | 74 +++-- internal/agent/agent.go | 39 +++ internal/agent/agentserver/server.go | 52 ++++ .../agent/api/sentinel/agent/v1/agent.pb.go | 238 +++++++++++++++ .../agent/v1/agentv1connect/agent.connect.go | 108 +++++++ .../agent/api/sentinel/common/v1/info.pb.go | 282 ++++++++++++++++++ internal/config/agent.go | 33 ++ internal/config/config.go | 75 ----- internal/config/hub.go | 124 ++++++++ internal/config/load.go | 136 ++------- internal/handlerutils/cors.go | 19 ++ internal/{web => models}/info.go | 2 +- internal/monitor/monitor.go | 4 +- internal/web/auth_test.go | 4 +- internal/web/dto.go | 17 +- internal/web/handlers.go | 9 +- internal/web/release_checker.go | 3 +- schema/sentinel/agent/v1/agent.proto | 24 ++ schema/sentinel/common/v1/info.proto | 25 ++ 36 files changed, 1475 insertions(+), 270 deletions(-) create mode 100644 Dockerfile-agent create mode 100644 buf.gen.yaml create mode 100644 buf.yaml create mode 100644 cmd/agent/config.go create mode 100644 cmd/agent/main.go create mode 100644 cmd/agent/start.go create mode 100644 cmd/agent/version.go create mode 100644 config-agent.template.yaml create mode 100644 internal/agent/agent.go create mode 100644 internal/agent/agentserver/server.go create mode 100644 internal/agent/api/sentinel/agent/v1/agent.pb.go create mode 100644 internal/agent/api/sentinel/agent/v1/agentv1connect/agent.connect.go create mode 100644 internal/agent/api/sentinel/common/v1/info.pb.go create mode 100644 internal/config/agent.go delete mode 100644 internal/config/config.go create mode 100644 internal/config/hub.go create mode 100644 internal/handlerutils/cors.go rename internal/{web => models}/info.go (96%) create mode 100644 schema/sentinel/agent/v1/agent.proto create mode 100644 schema/sentinel/common/v1/info.proto diff --git a/.gitignore b/.gitignore index 6b8e310..d1dca3b 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ go.work.sum data build config.yaml +config-agent.yaml dist diff --git a/Dockerfile b/Dockerfile index 0b6fc8c..45e898f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,7 @@ COPY frontend/ ./ RUN pnpm run build # Backend build stage -FROM golang:1.25.0-alpine AS backend-builder +FROM golang:1.25.1-alpine AS backend-builder # Define build arguments for version, commit, and date. ARG VERSION="unknown" diff --git a/Dockerfile-agent b/Dockerfile-agent new file mode 100644 index 0000000..a9c4918 --- /dev/null +++ b/Dockerfile-agent @@ -0,0 +1,37 @@ +# Backend build stage +FROM golang:1.25.1-alpine AS backend-builder + +# Define build arguments for version, commit, and date. +ARG VERSION="unknown" +ARG COMMIT_HASH="unknown" +ARG BUILD_DATE="unknown" + +# Install dependencies +RUN apk add --no-cache ca-certificates + +# Set working directory +WORKDIR /app + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build agent binary +RUN go build -trimpath -ldflags="-w -s -X 'main.version=${VERSION}' -X 'main.commitHash=${COMMIT_HASH}' -X 'main.buildDate=${BUILD_DATE}'" -o bin/sentinel_agent ./cmd/agent + +# Final stage +FROM alpine:latest + +# Install ca-certificates for HTTPS requests +RUN apk --no-cache add ca-certificates tzdata curl + +WORKDIR /root/ + +# Copy agent binary from builder stage +COPY --from=backend-builder /app/bin/sentinel_agent . + +# Run the binary +CMD ["./sentinel_agent", "start"] diff --git a/Makefile b/Makefile index a6c5b50..2aa8fd4 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,8 @@ # Variables BINARY_NAME=sentinel -MAIN_PATH=./cmd/sentinel +HUB_PATH=./cmd/sentinel +AGENT_PATH=./cmd/agent BUILD_DIR=./build VERSION?=dev LDFLAGS=-ldflags="-w -s -X main.version=${VERSION}" @@ -18,7 +19,10 @@ help: ## Show this help message # Development dev: ## Run in development mode with auto-reload - go run $(MAIN_PATH) start + go run $(HUB_PATH) start + +agent: ## Run in development mode with auto-reload + go run $(AGENT_PATH) start run: build ## Build and run the application ./$(BUILD_DIR)/$(BINARY_NAME) @@ -73,13 +77,20 @@ format: ## Format code go fmt ./... goimports -w . -docker-push: ## Build and push Docker image +docker-push-hub: ## Build and push Docker image docker buildx build --platform linux/amd64 --push \ --build-arg VERSION=`git describe --tags --abbrev=0 || echo "0.0.0"` \ --build-arg COMMIT=`git rev-parse --short HEAD` \ --build-arg DATE=`date -u +'%Y-%m-%dT%H:%M:%SZ'` \ -t sxwebdev/sentinel:latest . +docker-push-agent: ## Build and push Docker image + docker buildx build --platform linux/amd64 --push -f Dockerfile-agent \ + --build-arg VERSION=`git describe --tags --abbrev=0 || echo "0.0.0"` \ + --build-arg COMMIT=`git rev-parse --short HEAD` \ + --build-arg DATE=`date -u +'%Y-%m-%dT%H:%M:%SZ'` \ + -t sxwebdev/sentinel-agent:latest . + docker-run: ## Run Docker container docker run -d \ --name sentinel \ @@ -133,3 +144,15 @@ genswagger: rm -rf ./docs/* swag fmt -d ./internal/web swag init -o docs/docsv1 --dir ./internal/web -g handlers.go --parseDependency + +genenvs: + go run ./cmd/sentinel config genenvs + go run ./cmd/agent config genenvs + +genproto: ## Generate protobuf code + buf lint + rm -rf ./internal/agent/api/* + buf generate + +grpcui-agent: + grpcui --plaintext localhost:9000 diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..165975f --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,13 @@ +version: v2 +managed: + enabled: true + override: + - file_option: go_package_prefix + value: github.com/sxwebdev/sentinel/internal/agent/api +plugins: + - remote: buf.build/protocolbuffers/go:v1.36.9 + out: internal/agent/api + opt: paths=source_relative + - remote: buf.build/connectrpc/go:v1.18.1 + out: internal/agent/api + opt: paths=source_relative diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..4aa29f7 --- /dev/null +++ b/buf.yaml @@ -0,0 +1,18 @@ +version: v2 +modules: + - path: schema +lint: + use: + - COMMENT_SERVICE + - STANDARD + except: + - FIELD_NOT_REQUIRED + - PACKAGE_NO_IMPORT_CYCLE + disallow_comment_ignores: true + +breaking: + use: + - FILE + except: + - EXTENSION_NO_DELETE + - FIELD_SAME_DEFAULT diff --git a/cmd/agent/config.go b/cmd/agent/config.go new file mode 100644 index 0000000..42c0972 --- /dev/null +++ b/cmd/agent/config.go @@ -0,0 +1,56 @@ +package main + +import ( + "bytes" + "context" + "fmt" + "os" + + "github.com/goccy/go-yaml" + "github.com/sxwebdev/sentinel/internal/config" + "github.com/sxwebdev/xconfig" + "github.com/urfave/cli/v3" +) + +func cfgPathsFlag() *cli.StringSliceFlag { + return &cli.StringSliceFlag{ + Name: "config", + Aliases: []string{"c"}, + Value: []string{"config-agent.yaml"}, + Usage: "allows you to use your own paths to configuration files. by default it uses config-agent.yaml", + } +} + +func configCMD() *cli.Command { + return &cli.Command{ + Name: "config", + Usage: "validate, gen envs and flags for config", + Commands: []*cli.Command{ + { + Name: "genenvs", + Usage: "generate config yaml template", + Action: func(_ context.Context, _ *cli.Command) error { + conf := new(config.ConfigAgent) + _, err := xconfig.Load(conf, xconfig.WithEnvPrefix(envPrefix)) + if err != nil { + return fmt.Errorf("failed to generate markdown: %w", err) + } + + buf := bytes.NewBuffer(nil) + enc := yaml.NewEncoder(buf, yaml.Indent(2)) + defer enc.Close() + + if err := enc.Encode(conf); err != nil { + return fmt.Errorf("failed to encode yaml: %w", err) + } + + if err := os.WriteFile("config-agent.template.yaml", buf.Bytes(), 0o600); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil + }, + }, + }, + } +} diff --git a/cmd/agent/main.go b/cmd/agent/main.go new file mode 100644 index 0000000..8c4990a --- /dev/null +++ b/cmd/agent/main.go @@ -0,0 +1,63 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "runtime" + + mxsignal "github.com/tkcrm/mx/util/signal" + "github.com/urfave/cli/v3" + + "github.com/tkcrm/mx/logger" +) + +var ( + appName = "sentinel-agent" + version = "local" + commitHash = "unknown" + buildDate = "unknown" + envPrefix = "SENTINEL_AGENT_" +) + +func getBuildVersion() string { + return fmt.Sprintf( + "\nrelease: %s\ncommit hash: %s\nbuild date: %s\ngo version: %s", + version, + commitHash, + buildDate, + runtime.Version(), + ) +} + +func defaultLoggerOpts() []logger.Option { + return []logger.Option{ + logger.WithAppName(appName), + logger.WithAppVersion(version), + } +} + +func main() { + ctx, cancel := signal.NotifyContext(context.Background(), mxsignal.Shutdown()...) + defer cancel() + + l := logger.NewExtended(defaultLoggerOpts()...) + + app := &cli.Command{ + Name: appName, + Usage: "A CLI application for " + appName, + Version: getBuildVersion(), + Suggest: true, + Commands: []*cli.Command{ + startCMD(), + configCMD(), + versionCMD(), + }, + } + + // run cli runner + if err := app.Run(ctx, os.Args); err != nil { + l.Fatalf("failed to run cli runner: %s", err) + } +} diff --git a/cmd/agent/start.go b/cmd/agent/start.go new file mode 100644 index 0000000..abdce6f --- /dev/null +++ b/cmd/agent/start.go @@ -0,0 +1,105 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "runtime" + + "connectrpc.com/connect" + "github.com/sxwebdev/sentinel/internal/agent" + "github.com/sxwebdev/sentinel/internal/agent/agentserver" + "github.com/sxwebdev/sentinel/internal/config" + "github.com/sxwebdev/sentinel/internal/handlerutils" + "github.com/sxwebdev/sentinel/internal/models" + "github.com/tkcrm/mx/launcher" + "github.com/tkcrm/mx/logger" + "github.com/tkcrm/mx/service" + "github.com/tkcrm/mx/service/pingpong" + "github.com/tkcrm/mx/transport/connectrpc_transport" + "github.com/urfave/cli/v3" + "go.akshayshah.org/connectproto" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" + "google.golang.org/protobuf/encoding/protojson" +) + +func startCMD() *cli.Command { + return &cli.Command{ + Name: "start", + Usage: "start the server", + Flags: []cli.Flag{cfgPathsFlag()}, + Action: func(ctx context.Context, cl *cli.Command) error { + conf := new(config.ConfigAgent) + if err := config.Load(conf, envPrefix, cl.StringSlice("config")); err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + loggerOpts := append(defaultLoggerOpts(), logger.WithConfig(conf.Log)) + + l := logger.NewExtended(loggerOpts...) + defer func() { + _ = l.Sync() + }() + + // init launcher + ln := launcher.New( + launcher.WithVersion(version), + launcher.WithName(appName), + launcher.WithLogger(l), + launcher.WithContext(ctx), + launcher.WithRunnerServicesSequence(launcher.RunnerServicesSequenceFifo), + launcher.WithOpsConfig(conf.Ops), + launcher.WithAppStartStopLog(true), + ) + + serverInfo := models.ServerInfo{ + Version: version, + CommitHash: commitHash, + BuildDate: buildDate, + GoVersion: runtime.Version(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + } + + // init agent service + ag := agent.New(l, serverInfo) + + // init agent rpc server + agentServer := agentserver.New(serverInfo) + + rpcServer := connectrpc_transport.NewServer( + connectrpc_transport.WithLogger(l), + connectrpc_transport.WithConfig(conf.Server), + connectrpc_transport.WithServices(agentServer), + connectrpc_transport.WithServerHandlerWrapper( + func(h http.Handler) http.Handler { + return h2c.NewHandler( + handlerutils.WithCORS(h), + &http2.Server{}) + }, + ), + connectrpc_transport.WithReflection( + agentServer.Name(), + ), + connectrpc_transport.WithConnectRPCOptions( + connect.WithHandlerOptions( + connectproto.WithJSON( + protojson.MarshalOptions{EmitUnpopulated: true}, + protojson.UnmarshalOptions{DiscardUnknown: true}, + ), + ), + ), + ) + + // register services + ln.ServicesRunner().Register( + service.New(service.WithService(pingpong.New(l))), + service.New(service.WithService(ag)), + service.New(service.WithService(rpcServer)), + ) + + return ln.Run() + }, + } +} diff --git a/cmd/agent/version.go b/cmd/agent/version.go new file mode 100644 index 0000000..bbc5fa8 --- /dev/null +++ b/cmd/agent/version.go @@ -0,0 +1,19 @@ +package main + +import ( + "context" + "fmt" + + "github.com/urfave/cli/v3" +) + +func versionCMD() *cli.Command { + return &cli.Command{ + Name: "version", + Usage: "print current version", + Action: func(_ context.Context, _ *cli.Command) error { + fmt.Println(version) + return nil + }, + } +} diff --git a/cmd/sentinel/config.go b/cmd/sentinel/config.go index 5212652..84afabf 100644 --- a/cmd/sentinel/config.go +++ b/cmd/sentinel/config.go @@ -8,14 +8,15 @@ import ( "github.com/goccy/go-yaml" "github.com/sxwebdev/sentinel/internal/config" + "github.com/sxwebdev/xconfig" "github.com/urfave/cli/v3" ) -func cfgPathsFlag() *cli.StringFlag { - return &cli.StringFlag{ +func cfgPathsFlag() *cli.StringSliceFlag { + return &cli.StringSliceFlag{ Name: "config", Aliases: []string{"c"}, - Value: "config.yaml", + Value: []string{"config.yaml"}, Usage: "allows you to use your own paths to configuration files. by default it uses config.yaml", } } @@ -29,11 +30,10 @@ func configCMD() *cli.Command { Name: "genenvs", Usage: "generate config yaml template", Action: func(_ context.Context, _ *cli.Command) error { - conf := new(config.Config) - - conf, err := config.Load("") + conf := new(config.ConfigHub) + _, err := xconfig.Load(conf, xconfig.WithEnvPrefix(envPrefix)) if err != nil { - return fmt.Errorf("failed to load config: %w", err) + return fmt.Errorf("failed to generate markdown: %w", err) } buf := bytes.NewBuffer(nil) diff --git a/cmd/sentinel/main.go b/cmd/sentinel/main.go index 69ea018..78c2c2b 100644 --- a/cmd/sentinel/main.go +++ b/cmd/sentinel/main.go @@ -18,6 +18,7 @@ var ( version = "local" commitHash = "unknown" buildDate = "unknown" + envPrefix = "SENTINEL_" ) func getBuildVersion() string { diff --git a/cmd/sentinel/start.go b/cmd/sentinel/start.go index 5b3d6a7..e2973f3 100644 --- a/cmd/sentinel/start.go +++ b/cmd/sentinel/start.go @@ -7,6 +7,7 @@ import ( "time" "github.com/sxwebdev/sentinel/internal/config" + "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/monitor" "github.com/sxwebdev/sentinel/internal/notifier" "github.com/sxwebdev/sentinel/internal/receiver" @@ -27,8 +28,8 @@ func startCMD() *cli.Command { Usage: "start the server", Flags: []cli.Flag{cfgPathsFlag()}, Action: func(ctx context.Context, cl *cli.Command) error { - conf, err := config.Load(cl.String("config")) - if err != nil { + conf := new(config.ConfigHub) + if err := config.Load(conf, envPrefix, cl.StringSlice("config")); err != nil { return fmt.Errorf("failed to load config: %w", err) } @@ -51,6 +52,7 @@ func startCMD() *cli.Command { ) // set default timezone + var err error time.Local, err = time.LoadLocation(conf.Timezone) if err != nil { return fmt.Errorf("failed to set timezone: %w", err) @@ -93,7 +95,7 @@ func startCMD() *cli.Command { // Initialize scheduler sched := scheduler.New(l, monitorService, rc) - webServer, err := web.NewServer(l, conf, web.ServerInfo{ + serverInfo := models.ServerInfo{ Version: version, CommitHash: commitHash, BuildDate: buildDate, @@ -101,7 +103,9 @@ func startCMD() *cli.Command { SqliteVersion: sqliteVersion, OS: runtime.GOOS, Arch: runtime.GOARCH, - }, monitorService, store, rc, upgr) + } + + webServer, err := web.NewServer(l, conf, serverInfo, monitorService, store, rc, upgr) if err != nil { return fmt.Errorf("failed to initialize web server: %w", err) } diff --git a/cmd/testapi/main.go b/cmd/testapi/main.go index 2c08c5c..4ac2716 100644 --- a/cmd/testapi/main.go +++ b/cmd/testapi/main.go @@ -15,6 +15,7 @@ import ( "time" "github.com/sxwebdev/sentinel/internal/config" + "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/monitor" "github.com/sxwebdev/sentinel/internal/monitors" "github.com/sxwebdev/sentinel/internal/notifier" @@ -203,7 +204,7 @@ func setupTestSuite() (*TestSuite, error) { dbPath := filepath.Join(tmpDir, "test.db") // Load config - cfg := &config.Config{ + cfg := &config.ConfigHub{ Database: config.DatabaseConfig{ Path: dbPath, }, @@ -249,7 +250,7 @@ func setupTestSuite() (*TestSuite, error) { monitorService := monitor.NewMonitorService(stor, cfg, notif, rc) // Create web server - webServer, err := web.NewServer(l, cfg, web.ServerInfo{}, monitorService, stor, rc, upgr) + webServer, err := web.NewServer(l, cfg, models.ServerInfo{}, monitorService, stor, rc, upgr) if err != nil { return nil, fmt.Errorf("failed to create web server: %w", err) } diff --git a/config-agent.template.yaml b/config-agent.template.yaml new file mode 100644 index 0000000..9b84c00 --- /dev/null +++ b/config-agent.template.yaml @@ -0,0 +1,33 @@ +log: + format: json + level: info + console_colored: false + trace: fatal + with_caller: false + with_stack_trace: false +ops: + enabled: false + network: tcp + tracing_enabled: false + metrics: + enabled: false + path: /metrics + port: "10000" + basic_auth: + enabled: false + username: "" + password: "" + healthy: + enabled: false + path: /healthy + port: "10000" + profiler: + enabled: false + path: /debug/pprof + port: "10000" + write_timeout: 60 +server: + enabled: true + addr: :9000 + reflect_enabled: false +token: "" diff --git a/config.template.yaml b/config.template.yaml index 8f88de6..0fadd02 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -1,31 +1,31 @@ log: - format: "" - level: "" + format: json + level: info console_colored: false - trace: "" + trace: fatal with_caller: false with_stack_trace: false ops: enabled: false - network: "" + network: tcp tracing_enabled: false metrics: enabled: false - path: "" - port: "" + path: /metrics + port: "10000" basic_auth: enabled: false username: "" password: "" healthy: enabled: false - path: "" - port: "" + path: /healthy + port: "10000" profiler: enabled: false - path: "" - port: "" - write_timeout: 0 + path: /debug/pprof + port: "10000" + write_timeout: 60 server: port: 8080 host: 0.0.0.0 @@ -38,12 +38,15 @@ server: users: [] monitoring: global: - default_interval: 60s + default_interval: 1m0s default_timeout: 10s - default_retries: 5 + default_retries: 10 database: path: ./data/db.sqlite notifications: enabled: false urls: [] timezone: UTC +upgrader: + is_enabled: false + command: "" diff --git a/go.mod b/go.mod index d1c8a17..f391f9e 100644 --- a/go.mod +++ b/go.mod @@ -1,29 +1,40 @@ module github.com/sxwebdev/sentinel -go 1.25.0 +go 1.25.1 require ( + connectrpc.com/connect v1.18.1 + connectrpc.com/cors v0.1.0 github.com/Masterminds/semver/v3 v3.4.0 github.com/containrrr/shoutrrr v0.8.0 github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 - github.com/dromara/carbon/v2 v2.6.11 + github.com/dromara/carbon/v2 v2.6.12 github.com/go-playground/validator/v10 v10.27.0 github.com/goccy/go-yaml v1.18.0 github.com/gofiber/contrib/websocket v1.3.4 github.com/gofiber/fiber/v2 v2.52.9 - github.com/huandu/go-sqlbuilder v1.36.1 + github.com/huandu/go-sqlbuilder v1.37.0 github.com/oklog/ulid/v2 v2.1.1 github.com/puzpuzpuz/xsync/v3 v3.5.1 + github.com/rs/cors v1.11.1 github.com/stretchr/testify v1.11.1 github.com/swaggo/fiber-swagger v1.3.0 github.com/swaggo/swag v1.16.6 + github.com/sxwebdev/xconfig v0.0.0-20250917185517-9fc0b932f57a + github.com/sxwebdev/xconfig/decoders/xconfigdotenv v0.0.0-20250917185517-9fc0b932f57a + github.com/sxwebdev/xconfig/decoders/xconfigyaml v0.0.0-20250917185517-9fc0b932f57a github.com/tkcrm/mx v0.2.34 + github.com/tkcrm/mx/transport/connectrpc_transport v0.0.0-20250618055556-3f77aaa9ddbd github.com/urfave/cli/v3 v3.4.1 - google.golang.org/grpc v1.75.0 - modernc.org/sqlite v1.38.2 + go.akshayshah.org/connectproto v0.6.0 + golang.org/x/net v0.44.0 + google.golang.org/grpc v1.75.1 + google.golang.org/protobuf v1.36.9 + modernc.org/sqlite v1.39.0 ) require ( + connectrpc.com/grpcreflect v1.3.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/andybalholm/brotli v1.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect @@ -57,11 +68,13 @@ require ( github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect github.com/google/pprof v0.0.0-20250903194437-c28834ac2320 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/huandu/go-clone v1.7.3 // indirect github.com/huandu/xstrings v1.5.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect - github.com/mailru/easyjson v0.9.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect @@ -74,31 +87,28 @@ require ( github.com/prometheus/procfs v0.17.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 // indirect github.com/swaggo/files v1.0.1 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect - github.com/valyala/fasthttp v1.65.0 // indirect - go.opentelemetry.io/auto/sdk v1.1.0 // indirect + github.com/valyala/fasthttp v1.66.0 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/otel/trace v1.38.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.0 // indirect - go.yaml.in/yaml/v2 v2.4.2 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect golang.org/x/crypto v0.42.0 // indirect - golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect + golang.org/x/exp v0.0.0-20250911091902-df9299821621 // indirect golang.org/x/mod v0.28.0 // indirect - golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.17.0 // indirect golang.org/x/sys v0.36.0 // indirect golang.org/x/text v0.29.0 // indirect - golang.org/x/tools v0.36.0 // indirect + golang.org/x/tools v0.37.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 // indirect - google.golang.org/protobuf v1.36.9 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - modernc.org/libc v1.66.8 // indirect + modernc.org/libc v1.66.9 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 9148476..308d4c7 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,9 @@ +connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= +connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +connectrpc.com/cors v0.1.0 h1:f3gTXJyDZPrDIZCQ567jxfD9PAIpopHiRDnJRt3QuOQ= +connectrpc.com/cors v0.1.0/go.mod h1:v8SJZCPfHtGH1zsm+Ttajpozd4cYIUryl4dFB6QEpfg= +connectrpc.com/grpcreflect v1.3.0 h1:Y4V+ACf8/vOb1XOc251Qun7jMB75gCUNw6llvB9csXc= +connectrpc.com/grpcreflect v1.3.0/go.mod h1:nfloOtCS8VUQOQ1+GTdFzVg2CJo4ZGaat8JIovCtDYs= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= @@ -24,8 +30,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 h1:aQYWswi+hRL2zJqGacdCZx32XjKYV8ApXFGntw79XAM= github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= -github.com/dromara/carbon/v2 v2.6.11 h1:wnAWZ+sbza1uXw3r05hExNSCaBPFaarWfUvYAX86png= -github.com/dromara/carbon/v2 v2.6.11/go.mod h1:7GXqCUplwN1s1b4whGk2zX4+g4CMCoDIZzmjlyt0vLY= +github.com/dromara/carbon/v2 v2.6.12 h1:WOFEZUXEbsGCZ4Y3AWBrMfCySeQ0zi8NrlwqspJp1m0= +github.com/dromara/carbon/v2 v2.6.12/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= @@ -106,14 +112,19 @@ github.com/google/pprof v0.0.0-20250903194437-c28834ac2320/go.mod h1:I6V7YzU0XDp github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/huandu/go-assert v1.1.5/go.mod h1:yOLvuqZwmcHIC5rIzrBhT7D3Q9c3GFnd0JrPVhn/06U= github.com/huandu/go-assert v1.1.6 h1:oaAfYxq9KNDi9qswn/6aE0EydfxSa+tWZC1KabNitYs= github.com/huandu/go-assert v1.1.6/go.mod h1:JuIfbmYG9ykwvuxoJ3V8TB5QP+3+ajIA54Y44TmkMxs= -github.com/huandu/go-sqlbuilder v1.36.1 h1:4S17aR2BPW8L2PeotAD4iVhSMjwzk6CO8ABmp3tMEhY= -github.com/huandu/go-sqlbuilder v1.36.1/go.mod h1:59Zjq93ndlKI6O5kHmkXQpDgriBAPMKuhByEOy6xYK8= +github.com/huandu/go-clone v1.7.3 h1:rtQODA+ABThEn6J5LBTppJfKmZy/FwfpMUWa8d01TTQ= +github.com/huandu/go-clone v1.7.3/go.mod h1:ReGivhG6op3GYr+UY3lS6mxjKp7MIGTknuU5TbTVaXE= +github.com/huandu/go-sqlbuilder v1.37.0 h1:hXgk2rTnlgFgKsmFpizhe6g/oz1wxef4qk3ixFhK6a0= +github.com/huandu/go-sqlbuilder v1.37.0/go.mod h1:zdONH67liL+/TvoUMwnZP/sUYGSSvHh9psLe/HpXn8E= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= @@ -134,8 +145,8 @@ github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjS github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -178,6 +189,8 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 h1:qIQ0tWF9vxGtkJa24bR+2i53WBCz1nW/Pc47oVYauC4= github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= @@ -186,6 +199,7 @@ github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1 github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -198,8 +212,16 @@ github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2u github.com/swaggo/swag v1.8.1/go.mod h1:ugemnJsPZm/kRwFUnzBlbHRd0JY9zE1M4F+uy2pAaPQ= github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI= github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg= +github.com/sxwebdev/xconfig v0.0.0-20250917185517-9fc0b932f57a h1:3QsG+FAmZ9V7MI16p49yqhLoMDaI5k/Ci5pInO5xl5E= +github.com/sxwebdev/xconfig v0.0.0-20250917185517-9fc0b932f57a/go.mod h1:G0LSWAtUyWR4RdvDarnbgM1/LCYSB23hr4tm6HBhfUQ= +github.com/sxwebdev/xconfig/decoders/xconfigdotenv v0.0.0-20250917185517-9fc0b932f57a h1:F2SOCSeCMBDiprV1OkJ2COBK2XRLT8C3j8x5K1gHCEQ= +github.com/sxwebdev/xconfig/decoders/xconfigdotenv v0.0.0-20250917185517-9fc0b932f57a/go.mod h1:NTNcUc35LpkCP8dPdvOWTbq9SLGC7eS1FDAHqzwSMrk= +github.com/sxwebdev/xconfig/decoders/xconfigyaml v0.0.0-20250917185517-9fc0b932f57a h1:ZJ0xDxSDwo9GzhwfkNZl0ZdnBBqe7alWetKF+860Ohw= +github.com/sxwebdev/xconfig/decoders/xconfigyaml v0.0.0-20250917185517-9fc0b932f57a/go.mod h1:cGLysNNHvnNHKYFVsHfudRZ7BbjXlaDBjFQXiBN4B30= github.com/tkcrm/mx v0.2.34 h1:reTg836KS00FI+QMBTQIa2wh6/Z28PE7cHBtTw7Y5nQ= github.com/tkcrm/mx v0.2.34/go.mod h1:9N8UrILT8mg0IWb2MMtq2MqOMW1CQVIMmEh9ML37N+0= +github.com/tkcrm/mx/transport/connectrpc_transport v0.0.0-20250618055556-3f77aaa9ddbd h1:a2zSOXliAdXfGKPH9h6QJWpfh0Uv/zfShlp+JzB85Ns= +github.com/tkcrm/mx/transport/connectrpc_transport v0.0.0-20250618055556-3f77aaa9ddbd/go.mod h1:vGkbLZEJ5vhAumATg5oCXKDQwVmsSKsgps2qyLp+oT4= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM= github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= @@ -207,15 +229,19 @@ github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6Kllzaw github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/fasthttp v1.35.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxndDB4i4C0I= github.com/valyala/fasthttp v1.36.0/go.mod h1:t/G+3rLek+CyY9bnIE+YlMRddxVAAGjhxndDB4i4C0I= -github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8= -github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4= +github.com/valyala/fasthttp v1.66.0 h1:M87A0Z7EayeyNaV6pfO3tUTUiYO0dZfEJnRGXTVNuyU= +github.com/valyala/fasthttp v1.66.0/go.mod h1:Y4eC+zwoocmXSVCB1JmhNbYtS7tZPRI2ztPB72EVObs= github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= -go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.akshayshah.org/attest v1.0.0 h1:f66BDlh/xo2KjIfmtqOFlj5cpn6mvGrP1LXY3Tex4L0= +go.akshayshah.org/attest v1.0.0/go.mod h1:PnWzcW5j9dkyGwTlBmUsYpPnHG0AUPrs1RQ+HrldWO0= +go.akshayshah.org/connectproto v0.6.0 h1:tqmysQF2AfvUeYS03mRAAZTFpiQeXqhGIDnH1GO2D2U= +go.akshayshah.org/connectproto v0.6.0/go.mod h1:uA9TR/6MhBlLn0fh8VXRyL26EKTJlimWao4jbz7JHbA= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= @@ -234,16 +260,16 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -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= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI= golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8= -golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= -golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= +golang.org/x/exp v0.0.0-20250911091902-df9299821621 h1:2id6c1/gto0kaHYyrixvknJ8tUK/Qs5IsmBtrc+FtgU= +golang.org/x/exp v0.0.0-20250911091902-df9299821621/go.mod h1:TwQYMMnGpvZyc+JpB/UAuTNIsVJifOlSkrZkhcvpVUk= golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= @@ -258,8 +284,8 @@ golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= -golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I= +golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -295,8 +321,8 @@ golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.7/go.mod h1:LGqMHiF4EqQNHR1JncWGqT5BVaXmza+X+BDGol+dOxo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= -golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= +golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -304,8 +330,8 @@ gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090 h1:/OQuEa4YWtDt7uQWHd3q3sUMb+QOLQUg1xa8CEsRv5w= google.golang.org/genproto/googleapis/rpc v0.0.0-20250908214217-97024824d090/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= -google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= -google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= +google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -331,8 +357,8 @@ modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= -modernc.org/libc v1.66.8 h1:/awsvTnyN/sNjvJm6S3lb7KZw5WV4ly/sBEG7ZUzmIE= -modernc.org/libc v1.66.8/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k= +modernc.org/libc v1.66.9 h1:YkHp7E1EWrN2iyNav7JE/nHasmshPvlGkon1VxGqOw0= +modernc.org/libc v1.66.9/go.mod h1:aVdcY7udcawRqauu0HukYYxtBSizV+R80n/6aQe9D5k= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= @@ -341,8 +367,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= -modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek= -modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= +modernc.org/sqlite v1.39.0 h1:6bwu9Ooim0yVYA7IZn9demiQk/Ejp0BtTjBWFLymSeY= +modernc.org/sqlite v1.39.0/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E= modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..c416153 --- /dev/null +++ b/internal/agent/agent.go @@ -0,0 +1,39 @@ +package agent + +import ( + "context" + + "github.com/sxwebdev/sentinel/internal/agent/agentserver" + "github.com/sxwebdev/sentinel/internal/models" + "github.com/tkcrm/mx/logger" +) + +type Agent struct { + logger logger.Logger + server *agentserver.Server +} + +// New creates a new Agent instance +func New(l logger.Logger, serverInfo models.ServerInfo) *Agent { + return &Agent{ + logger: l, + server: agentserver.New(serverInfo), + } +} + +// Name returns the name of the agent +func (a *Agent) Name() string { + return "sentinel-agent" +} + +// Start starts the agent +func (a *Agent) Start(_ context.Context) error { + // Placeholder for starting agent logic + return nil +} + +// Stop stops the agent +func (a *Agent) Stop(_ context.Context) error { + // Placeholder for stopping agent logic + return nil +} diff --git a/internal/agent/agentserver/server.go b/internal/agent/agentserver/server.go new file mode 100644 index 0000000..70f5bf7 --- /dev/null +++ b/internal/agent/agentserver/server.go @@ -0,0 +1,52 @@ +package agentserver + +import ( + "context" + "net/http" + "time" + + "connectrpc.com/connect" + agentv1 "github.com/sxwebdev/sentinel/internal/agent/api/sentinel/agent/v1" + "github.com/sxwebdev/sentinel/internal/agent/api/sentinel/agent/v1/agentv1connect" + commonv1 "github.com/sxwebdev/sentinel/internal/agent/api/sentinel/common/v1" + "github.com/sxwebdev/sentinel/internal/models" + "github.com/tkcrm/mx/transport/connectrpc_transport" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type Server struct { + info models.ServerInfo + startedAt time.Time + + agentv1connect.UnimplementedAgentServiceHandler + connectrpc_transport.ConnectRPCService +} + +func New(info models.ServerInfo) *Server { + return &Server{ + info: info, + startedAt: time.Now(), + } +} + +func (s *Server) Name() string { return agentv1connect.AgentServiceName } + +func (s *Server) RegisterHandler(opts ...connect.HandlerOption) (string, http.Handler) { + return agentv1connect.NewAgentServiceHandler(s, opts...) +} + +// Info returns information about the agent +func (s *Server) Info(_ context.Context, _ *connect.Request[agentv1.InfoRequest]) (*connect.Response[agentv1.InfoResponse], error) { + return connect.NewResponse(&agentv1.InfoResponse{ + ServerInfo: &commonv1.ServerInfo{ + Version: s.info.Version, + CommitHash: s.info.CommitHash, + BuildDate: s.info.BuildDate, + GoVersion: s.info.GoVersion, + Os: s.info.OS, + Arch: s.info.Arch, + StartedAt: timestamppb.New(s.startedAt), + }, + Status: agentv1.AgentStatus_AGENT_STATUS_ACTIVE, + }), nil +} diff --git a/internal/agent/api/sentinel/agent/v1/agent.pb.go b/internal/agent/api/sentinel/agent/v1/agent.pb.go new file mode 100644 index 0000000..1ef7264 --- /dev/null +++ b/internal/agent/api/sentinel/agent/v1/agent.pb.go @@ -0,0 +1,238 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.9 +// protoc (unknown) +// source: sentinel/agent/v1/agent.proto + +package agentv1 + +import ( + v1 "github.com/sxwebdev/sentinel/internal/agent/api/sentinel/common/v1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type AgentStatus int32 + +const ( + AgentStatus_AGENT_STATUS_UNSPECIFIED AgentStatus = 0 + AgentStatus_AGENT_STATUS_ACTIVE AgentStatus = 1 + AgentStatus_AGENT_STATUS_INACTIVE AgentStatus = 2 +) + +// Enum value maps for AgentStatus. +var ( + AgentStatus_name = map[int32]string{ + 0: "AGENT_STATUS_UNSPECIFIED", + 1: "AGENT_STATUS_ACTIVE", + 2: "AGENT_STATUS_INACTIVE", + } + AgentStatus_value = map[string]int32{ + "AGENT_STATUS_UNSPECIFIED": 0, + "AGENT_STATUS_ACTIVE": 1, + "AGENT_STATUS_INACTIVE": 2, + } +) + +func (x AgentStatus) Enum() *AgentStatus { + p := new(AgentStatus) + *p = x + return p +} + +func (x AgentStatus) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (AgentStatus) Descriptor() protoreflect.EnumDescriptor { + return file_sentinel_agent_v1_agent_proto_enumTypes[0].Descriptor() +} + +func (AgentStatus) Type() protoreflect.EnumType { + return &file_sentinel_agent_v1_agent_proto_enumTypes[0] +} + +func (x AgentStatus) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use AgentStatus.Descriptor instead. +func (AgentStatus) EnumDescriptor() ([]byte, []int) { + return file_sentinel_agent_v1_agent_proto_rawDescGZIP(), []int{0} +} + +// InfoRequest is the request for Info method +type InfoRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InfoRequest) Reset() { + *x = InfoRequest{} + mi := &file_sentinel_agent_v1_agent_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InfoRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InfoRequest) ProtoMessage() {} + +func (x *InfoRequest) ProtoReflect() protoreflect.Message { + mi := &file_sentinel_agent_v1_agent_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InfoRequest.ProtoReflect.Descriptor instead. +func (*InfoRequest) Descriptor() ([]byte, []int) { + return file_sentinel_agent_v1_agent_proto_rawDescGZIP(), []int{0} +} + +// InfoResponse is the response for Info method +type InfoResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Status AgentStatus `protobuf:"varint,1,opt,name=status,proto3,enum=sentinel.agent.v1.AgentStatus" json:"status,omitempty"` + ServerInfo *v1.ServerInfo `protobuf:"bytes,2,opt,name=server_info,json=serverInfo,proto3" json:"server_info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InfoResponse) Reset() { + *x = InfoResponse{} + mi := &file_sentinel_agent_v1_agent_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InfoResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InfoResponse) ProtoMessage() {} + +func (x *InfoResponse) ProtoReflect() protoreflect.Message { + mi := &file_sentinel_agent_v1_agent_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InfoResponse.ProtoReflect.Descriptor instead. +func (*InfoResponse) Descriptor() ([]byte, []int) { + return file_sentinel_agent_v1_agent_proto_rawDescGZIP(), []int{1} +} + +func (x *InfoResponse) GetStatus() AgentStatus { + if x != nil { + return x.Status + } + return AgentStatus_AGENT_STATUS_UNSPECIFIED +} + +func (x *InfoResponse) GetServerInfo() *v1.ServerInfo { + if x != nil { + return x.ServerInfo + } + return nil +} + +var File_sentinel_agent_v1_agent_proto protoreflect.FileDescriptor + +const file_sentinel_agent_v1_agent_proto_rawDesc = "" + + "\n" + + "\x1dsentinel/agent/v1/agent.proto\x12\x11sentinel.agent.v1\x1a\x1dsentinel/common/v1/info.proto\"\r\n" + + "\vInfoRequest\"\x87\x01\n" + + "\fInfoResponse\x126\n" + + "\x06status\x18\x01 \x01(\x0e2\x1e.sentinel.agent.v1.AgentStatusR\x06status\x12?\n" + + "\vserver_info\x18\x02 \x01(\v2\x1e.sentinel.common.v1.ServerInfoR\n" + + "serverInfo*_\n" + + "\vAgentStatus\x12\x1c\n" + + "\x18AGENT_STATUS_UNSPECIFIED\x10\x00\x12\x17\n" + + "\x13AGENT_STATUS_ACTIVE\x10\x01\x12\x19\n" + + "\x15AGENT_STATUS_INACTIVE\x10\x022Y\n" + + "\fAgentService\x12I\n" + + "\x04Info\x12\x1e.sentinel.agent.v1.InfoRequest\x1a\x1f.sentinel.agent.v1.InfoResponse\"\x00B\xd4\x01\n" + + "\x15com.sentinel.agent.v1B\n" + + "AgentProtoP\x01ZIgithub.com/sxwebdev/sentinel/internal/agent/api/sentinel/agent/v1;agentv1\xa2\x02\x03SAX\xaa\x02\x11Sentinel.Agent.V1\xca\x02\x11Sentinel\\Agent\\V1\xe2\x02\x1dSentinel\\Agent\\V1\\GPBMetadata\xea\x02\x13Sentinel::Agent::V1b\x06proto3" + +var ( + file_sentinel_agent_v1_agent_proto_rawDescOnce sync.Once + file_sentinel_agent_v1_agent_proto_rawDescData []byte +) + +func file_sentinel_agent_v1_agent_proto_rawDescGZIP() []byte { + file_sentinel_agent_v1_agent_proto_rawDescOnce.Do(func() { + file_sentinel_agent_v1_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_sentinel_agent_v1_agent_proto_rawDesc), len(file_sentinel_agent_v1_agent_proto_rawDesc))) + }) + return file_sentinel_agent_v1_agent_proto_rawDescData +} + +var file_sentinel_agent_v1_agent_proto_enumTypes = make([]protoimpl.EnumInfo, 1) +var file_sentinel_agent_v1_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_sentinel_agent_v1_agent_proto_goTypes = []any{ + (AgentStatus)(0), // 0: sentinel.agent.v1.AgentStatus + (*InfoRequest)(nil), // 1: sentinel.agent.v1.InfoRequest + (*InfoResponse)(nil), // 2: sentinel.agent.v1.InfoResponse + (*v1.ServerInfo)(nil), // 3: sentinel.common.v1.ServerInfo +} +var file_sentinel_agent_v1_agent_proto_depIdxs = []int32{ + 0, // 0: sentinel.agent.v1.InfoResponse.status:type_name -> sentinel.agent.v1.AgentStatus + 3, // 1: sentinel.agent.v1.InfoResponse.server_info:type_name -> sentinel.common.v1.ServerInfo + 1, // 2: sentinel.agent.v1.AgentService.Info:input_type -> sentinel.agent.v1.InfoRequest + 2, // 3: sentinel.agent.v1.AgentService.Info:output_type -> sentinel.agent.v1.InfoResponse + 3, // [3:4] is the sub-list for method output_type + 2, // [2:3] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_sentinel_agent_v1_agent_proto_init() } +func file_sentinel_agent_v1_agent_proto_init() { + if File_sentinel_agent_v1_agent_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_sentinel_agent_v1_agent_proto_rawDesc), len(file_sentinel_agent_v1_agent_proto_rawDesc)), + NumEnums: 1, + NumMessages: 2, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_sentinel_agent_v1_agent_proto_goTypes, + DependencyIndexes: file_sentinel_agent_v1_agent_proto_depIdxs, + EnumInfos: file_sentinel_agent_v1_agent_proto_enumTypes, + MessageInfos: file_sentinel_agent_v1_agent_proto_msgTypes, + }.Build() + File_sentinel_agent_v1_agent_proto = out.File + file_sentinel_agent_v1_agent_proto_goTypes = nil + file_sentinel_agent_v1_agent_proto_depIdxs = nil +} diff --git a/internal/agent/api/sentinel/agent/v1/agentv1connect/agent.connect.go b/internal/agent/api/sentinel/agent/v1/agentv1connect/agent.connect.go new file mode 100644 index 0000000..89f794e --- /dev/null +++ b/internal/agent/api/sentinel/agent/v1/agentv1connect/agent.connect.go @@ -0,0 +1,108 @@ +// Code generated by protoc-gen-connect-go. DO NOT EDIT. +// +// Source: sentinel/agent/v1/agent.proto + +package agentv1connect + +import ( + connect "connectrpc.com/connect" + context "context" + errors "errors" + v1 "github.com/sxwebdev/sentinel/internal/agent/api/sentinel/agent/v1" + http "net/http" + strings "strings" +) + +// This is a compile-time assertion to ensure that this generated file and the connect package are +// compatible. If you get a compiler error that this constant is not defined, this code was +// generated with a version of connect newer than the one compiled into your binary. You can fix the +// problem by either regenerating this code with an older version of connect or updating the connect +// version compiled into your binary. +const _ = connect.IsAtLeastVersion1_13_0 + +const ( + // AgentServiceName is the fully-qualified name of the AgentService service. + AgentServiceName = "sentinel.agent.v1.AgentService" +) + +// These constants are the fully-qualified names of the RPCs defined in this package. They're +// exposed at runtime as Spec.Procedure and as the final two segments of the HTTP route. +// +// Note that these are different from the fully-qualified method names used by +// google.golang.org/protobuf/reflect/protoreflect. To convert from these constants to +// reflection-formatted method names, remove the leading slash and convert the remaining slash to a +// period. +const ( + // AgentServiceInfoProcedure is the fully-qualified name of the AgentService's Info RPC. + AgentServiceInfoProcedure = "/sentinel.agent.v1.AgentService/Info" +) + +// AgentServiceClient is a client for the sentinel.agent.v1.AgentService service. +type AgentServiceClient interface { + Info(context.Context, *connect.Request[v1.InfoRequest]) (*connect.Response[v1.InfoResponse], error) +} + +// NewAgentServiceClient constructs a client for the sentinel.agent.v1.AgentService service. By +// default, it uses the Connect protocol with the binary Protobuf Codec, asks for gzipped responses, +// and sends uncompressed requests. To use the gRPC or gRPC-Web protocols, supply the +// connect.WithGRPC() or connect.WithGRPCWeb() options. +// +// The URL supplied here should be the base URL for the Connect or gRPC server (for example, +// http://api.acme.com or https://acme.com/grpc). +func NewAgentServiceClient(httpClient connect.HTTPClient, baseURL string, opts ...connect.ClientOption) AgentServiceClient { + baseURL = strings.TrimRight(baseURL, "/") + agentServiceMethods := v1.File_sentinel_agent_v1_agent_proto.Services().ByName("AgentService").Methods() + return &agentServiceClient{ + info: connect.NewClient[v1.InfoRequest, v1.InfoResponse]( + httpClient, + baseURL+AgentServiceInfoProcedure, + connect.WithSchema(agentServiceMethods.ByName("Info")), + connect.WithClientOptions(opts...), + ), + } +} + +// agentServiceClient implements AgentServiceClient. +type agentServiceClient struct { + info *connect.Client[v1.InfoRequest, v1.InfoResponse] +} + +// Info calls sentinel.agent.v1.AgentService.Info. +func (c *agentServiceClient) Info(ctx context.Context, req *connect.Request[v1.InfoRequest]) (*connect.Response[v1.InfoResponse], error) { + return c.info.CallUnary(ctx, req) +} + +// AgentServiceHandler is an implementation of the sentinel.agent.v1.AgentService service. +type AgentServiceHandler interface { + Info(context.Context, *connect.Request[v1.InfoRequest]) (*connect.Response[v1.InfoResponse], error) +} + +// NewAgentServiceHandler builds an HTTP handler from the service implementation. It returns the +// path on which to mount the handler and the handler itself. +// +// By default, handlers support the Connect, gRPC, and gRPC-Web protocols with the binary Protobuf +// and JSON codecs. They also support gzip compression. +func NewAgentServiceHandler(svc AgentServiceHandler, opts ...connect.HandlerOption) (string, http.Handler) { + agentServiceMethods := v1.File_sentinel_agent_v1_agent_proto.Services().ByName("AgentService").Methods() + agentServiceInfoHandler := connect.NewUnaryHandler( + AgentServiceInfoProcedure, + svc.Info, + connect.WithSchema(agentServiceMethods.ByName("Info")), + connect.WithHandlerOptions(opts...), + ) + return "/sentinel.agent.v1.AgentService/", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case AgentServiceInfoProcedure: + agentServiceInfoHandler.ServeHTTP(w, r) + default: + http.NotFound(w, r) + } + }) +} + +// UnimplementedAgentServiceHandler returns CodeUnimplemented from all methods. +type UnimplementedAgentServiceHandler struct{} + +func (UnimplementedAgentServiceHandler) Info(context.Context, *connect.Request[v1.InfoRequest]) (*connect.Response[v1.InfoResponse], error) { + return nil, connect.NewError(connect.CodeUnimplemented, errors.New("sentinel.agent.v1.AgentService.Info is not implemented")) +} diff --git a/internal/agent/api/sentinel/common/v1/info.pb.go b/internal/agent/api/sentinel/common/v1/info.pb.go new file mode 100644 index 0000000..1e97619 --- /dev/null +++ b/internal/agent/api/sentinel/common/v1/info.pb.go @@ -0,0 +1,282 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.9 +// protoc (unknown) +// source: sentinel/common/v1/info.proto + +package commonv1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// AvailableUpdate represents information about an available update +type AvailableUpdate struct { + state protoimpl.MessageState `protogen:"open.v1"` + IsAvailableManual bool `protobuf:"varint,1,opt,name=is_available_manual,json=isAvailableManual,proto3" json:"is_available_manual,omitempty"` + TagName string `protobuf:"bytes,2,opt,name=tag_name,json=tagName,proto3" json:"tag_name,omitempty"` + Url string `protobuf:"bytes,3,opt,name=url,proto3" json:"url,omitempty"` + Description string `protobuf:"bytes,4,opt,name=description,proto3" json:"description,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AvailableUpdate) Reset() { + *x = AvailableUpdate{} + mi := &file_sentinel_common_v1_info_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AvailableUpdate) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AvailableUpdate) ProtoMessage() {} + +func (x *AvailableUpdate) ProtoReflect() protoreflect.Message { + mi := &file_sentinel_common_v1_info_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AvailableUpdate.ProtoReflect.Descriptor instead. +func (*AvailableUpdate) Descriptor() ([]byte, []int) { + return file_sentinel_common_v1_info_proto_rawDescGZIP(), []int{0} +} + +func (x *AvailableUpdate) GetIsAvailableManual() bool { + if x != nil { + return x.IsAvailableManual + } + return false +} + +func (x *AvailableUpdate) GetTagName() string { + if x != nil { + return x.TagName + } + return "" +} + +func (x *AvailableUpdate) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *AvailableUpdate) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +// ServerInfo represents information about the server +type ServerInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` + CommitHash string `protobuf:"bytes,2,opt,name=commit_hash,json=commitHash,proto3" json:"commit_hash,omitempty"` + BuildDate string `protobuf:"bytes,3,opt,name=build_date,json=buildDate,proto3" json:"build_date,omitempty"` + GoVersion string `protobuf:"bytes,4,opt,name=go_version,json=goVersion,proto3" json:"go_version,omitempty"` + SqliteVersion string `protobuf:"bytes,5,opt,name=sqlite_version,json=sqliteVersion,proto3" json:"sqlite_version,omitempty"` + Os string `protobuf:"bytes,6,opt,name=os,proto3" json:"os,omitempty"` + Arch string `protobuf:"bytes,7,opt,name=arch,proto3" json:"arch,omitempty"` + AvailableUpdate *AvailableUpdate `protobuf:"bytes,8,opt,name=available_update,json=availableUpdate,proto3,oneof" json:"available_update,omitempty"` + StartedAt *timestamppb.Timestamp `protobuf:"bytes,9,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ServerInfo) Reset() { + *x = ServerInfo{} + mi := &file_sentinel_common_v1_info_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ServerInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ServerInfo) ProtoMessage() {} + +func (x *ServerInfo) ProtoReflect() protoreflect.Message { + mi := &file_sentinel_common_v1_info_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ServerInfo.ProtoReflect.Descriptor instead. +func (*ServerInfo) Descriptor() ([]byte, []int) { + return file_sentinel_common_v1_info_proto_rawDescGZIP(), []int{1} +} + +func (x *ServerInfo) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *ServerInfo) GetCommitHash() string { + if x != nil { + return x.CommitHash + } + return "" +} + +func (x *ServerInfo) GetBuildDate() string { + if x != nil { + return x.BuildDate + } + return "" +} + +func (x *ServerInfo) GetGoVersion() string { + if x != nil { + return x.GoVersion + } + return "" +} + +func (x *ServerInfo) GetSqliteVersion() string { + if x != nil { + return x.SqliteVersion + } + return "" +} + +func (x *ServerInfo) GetOs() string { + if x != nil { + return x.Os + } + return "" +} + +func (x *ServerInfo) GetArch() string { + if x != nil { + return x.Arch + } + return "" +} + +func (x *ServerInfo) GetAvailableUpdate() *AvailableUpdate { + if x != nil { + return x.AvailableUpdate + } + return nil +} + +func (x *ServerInfo) GetStartedAt() *timestamppb.Timestamp { + if x != nil { + return x.StartedAt + } + return nil +} + +var File_sentinel_common_v1_info_proto protoreflect.FileDescriptor + +const file_sentinel_common_v1_info_proto_rawDesc = "" + + "\n" + + "\x1dsentinel/common/v1/info.proto\x12\x12sentinel.common.v1\x1a\x1fgoogle/protobuf/timestamp.proto\"\x90\x01\n" + + "\x0fAvailableUpdate\x12.\n" + + "\x13is_available_manual\x18\x01 \x01(\bR\x11isAvailableManual\x12\x19\n" + + "\btag_name\x18\x02 \x01(\tR\atagName\x12\x10\n" + + "\x03url\x18\x03 \x01(\tR\x03url\x12 \n" + + "\vdescription\x18\x04 \x01(\tR\vdescription\"\xf5\x02\n" + + "\n" + + "ServerInfo\x12\x18\n" + + "\aversion\x18\x01 \x01(\tR\aversion\x12\x1f\n" + + "\vcommit_hash\x18\x02 \x01(\tR\n" + + "commitHash\x12\x1d\n" + + "\n" + + "build_date\x18\x03 \x01(\tR\tbuildDate\x12\x1d\n" + + "\n" + + "go_version\x18\x04 \x01(\tR\tgoVersion\x12%\n" + + "\x0esqlite_version\x18\x05 \x01(\tR\rsqliteVersion\x12\x0e\n" + + "\x02os\x18\x06 \x01(\tR\x02os\x12\x12\n" + + "\x04arch\x18\a \x01(\tR\x04arch\x12S\n" + + "\x10available_update\x18\b \x01(\v2#.sentinel.common.v1.AvailableUpdateH\x00R\x0favailableUpdate\x88\x01\x01\x129\n" + + "\n" + + "started_at\x18\t \x01(\v2\x1a.google.protobuf.TimestampR\tstartedAtB\x13\n" + + "\x11_available_updateB\xda\x01\n" + + "\x16com.sentinel.common.v1B\tInfoProtoP\x01ZKgithub.com/sxwebdev/sentinel/internal/agent/api/sentinel/common/v1;commonv1\xa2\x02\x03SCX\xaa\x02\x12Sentinel.Common.V1\xca\x02\x12Sentinel\\Common\\V1\xe2\x02\x1eSentinel\\Common\\V1\\GPBMetadata\xea\x02\x14Sentinel::Common::V1b\x06proto3" + +var ( + file_sentinel_common_v1_info_proto_rawDescOnce sync.Once + file_sentinel_common_v1_info_proto_rawDescData []byte +) + +func file_sentinel_common_v1_info_proto_rawDescGZIP() []byte { + file_sentinel_common_v1_info_proto_rawDescOnce.Do(func() { + file_sentinel_common_v1_info_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_sentinel_common_v1_info_proto_rawDesc), len(file_sentinel_common_v1_info_proto_rawDesc))) + }) + return file_sentinel_common_v1_info_proto_rawDescData +} + +var file_sentinel_common_v1_info_proto_msgTypes = make([]protoimpl.MessageInfo, 2) +var file_sentinel_common_v1_info_proto_goTypes = []any{ + (*AvailableUpdate)(nil), // 0: sentinel.common.v1.AvailableUpdate + (*ServerInfo)(nil), // 1: sentinel.common.v1.ServerInfo + (*timestamppb.Timestamp)(nil), // 2: google.protobuf.Timestamp +} +var file_sentinel_common_v1_info_proto_depIdxs = []int32{ + 0, // 0: sentinel.common.v1.ServerInfo.available_update:type_name -> sentinel.common.v1.AvailableUpdate + 2, // 1: sentinel.common.v1.ServerInfo.started_at:type_name -> google.protobuf.Timestamp + 2, // [2:2] is the sub-list for method output_type + 2, // [2:2] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name +} + +func init() { file_sentinel_common_v1_info_proto_init() } +func file_sentinel_common_v1_info_proto_init() { + if File_sentinel_common_v1_info_proto != nil { + return + } + file_sentinel_common_v1_info_proto_msgTypes[1].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_sentinel_common_v1_info_proto_rawDesc), len(file_sentinel_common_v1_info_proto_rawDesc)), + NumEnums: 0, + NumMessages: 2, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_sentinel_common_v1_info_proto_goTypes, + DependencyIndexes: file_sentinel_common_v1_info_proto_depIdxs, + MessageInfos: file_sentinel_common_v1_info_proto_msgTypes, + }.Build() + File_sentinel_common_v1_info_proto = out.File + file_sentinel_common_v1_info_proto_goTypes = nil + file_sentinel_common_v1_info_proto_depIdxs = nil +} diff --git a/internal/config/agent.go b/internal/config/agent.go new file mode 100644 index 0000000..0391f3c --- /dev/null +++ b/internal/config/agent.go @@ -0,0 +1,33 @@ +package config + +import ( + "fmt" + + "github.com/tkcrm/mx/logger" + "github.com/tkcrm/mx/ops" + "github.com/tkcrm/mx/transport/connectrpc_transport" +) + +type ConfigAgent struct { + Log logger.Config + Ops ops.Config + Server connectrpc_transport.Config `yaml:"server"` + Token string `yaml:"token"` +} + +func (c *ConfigAgent) SetDefaults() error { + if c.Log.Level == "" { + c.Log.Level = "info" + } + if c.Server.Addr == "" { + c.Server.Addr = "localhost:9000" + } + return nil +} + +func (c *ConfigAgent) Validate() error { + if c.Token == "" { + return fmt.Errorf("token is required") + } + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go deleted file mode 100644 index 7b1e332..0000000 --- a/internal/config/config.go +++ /dev/null @@ -1,75 +0,0 @@ -package config - -import ( - "time" - - "github.com/tkcrm/mx/logger" - "github.com/tkcrm/mx/ops" -) - -// Config represents the main configuration structure -type Config struct { - Log logger.Config - Ops ops.Config - Server ServerConfig `yaml:"server"` - Monitoring MonitoringConfig `yaml:"monitoring"` - Database DatabaseConfig `yaml:"database"` - Notifications NotificationsConfig `yaml:"notifications"` - Timezone string `yaml:"timezone"` - Upgrader Upgrader `yaml:"upgrader"` -} - -// ServerConfig holds web server configuration -type ServerConfig struct { - Port int `yaml:"port"` - Host string `yaml:"host"` - BaseHost string `yaml:"base_host"` - Frontend FrontendConfig `yaml:"frontend"` - Auth AuthConfig `yaml:"auth"` -} - -// AuthConfig holds authentication settings -type AuthConfig struct { - Enabled bool `yaml:"enabled"` - Users []UserAuth `yaml:"users"` -} - -// UserAuth represents a user with basic auth credentials -type UserAuth struct { - Username string `yaml:"username"` - Password string `yaml:"password"` -} - -// FrontendConfig holds frontend-specific configuration -type FrontendConfig struct { - BaseURL string `yaml:"base_url"` - SocketURL string `yaml:"socket_url"` -} - -// MonitoringConfig holds global monitoring settings -type MonitoringConfig struct { - Global GlobalConfig `yaml:"global"` -} - -// GlobalConfig holds default monitoring parameters -type GlobalConfig struct { - DefaultInterval time.Duration `yaml:"default_interval"` - DefaultTimeout time.Duration `yaml:"default_timeout"` - DefaultRetries int `yaml:"default_retries"` -} - -// DatabaseConfig holds database settings -type DatabaseConfig struct { - Path string `yaml:"path"` -} - -// NotificationsConfig holds notification settings for multiple providers -type NotificationsConfig struct { - Enabled bool `yaml:"enabled"` - URLs []string `yaml:"urls"` -} - -type Upgrader struct { - IsEnabled bool `yaml:"is_enabled"` - Command string `yaml:"command"` -} diff --git a/internal/config/hub.go b/internal/config/hub.go new file mode 100644 index 0000000..6fb7664 --- /dev/null +++ b/internal/config/hub.go @@ -0,0 +1,124 @@ +package config + +import ( + "fmt" + "time" + + "github.com/tkcrm/mx/logger" + "github.com/tkcrm/mx/ops" +) + +// ConfigHub represents the main configuration structure +type ConfigHub struct { + Log logger.Config + Ops ops.Config + Server ServerConfig `yaml:"server"` + Monitoring MonitoringConfig `yaml:"monitoring"` + Database DatabaseConfig `yaml:"database"` + Notifications NotificationsConfig `yaml:"notifications"` + Timezone string `yaml:"timezone" default:"UTC"` + Upgrader Upgrader `yaml:"upgrader"` +} + +// ServerConfig holds web server configuration +type ServerConfig struct { + Port int `yaml:"port" default:"8080"` + Host string `yaml:"host" default:"0.0.0.0"` + BaseHost string `yaml:"base_host" default:"localhost:8080"` + Frontend FrontendConfig `yaml:"frontend"` + Auth AuthConfig `yaml:"auth"` +} + +// AuthConfig holds authentication settings +type AuthConfig struct { + Enabled bool `yaml:"enabled"` + Users []UserAuth `yaml:"users"` +} + +// UserAuth represents a user with basic auth credentials +type UserAuth struct { + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +// FrontendConfig holds frontend-specific configuration +type FrontendConfig struct { + BaseURL string `yaml:"base_url" default:"http://localhost:8080/api/v1"` + SocketURL string `yaml:"socket_url" default:"ws://localhost:8080/ws"` +} + +// MonitoringConfig holds global monitoring settings +type MonitoringConfig struct { + Global GlobalConfig `yaml:"global"` +} + +// GlobalConfig holds default monitoring parameters +type GlobalConfig struct { + DefaultInterval time.Duration `yaml:"default_interval" default:"1m"` + DefaultTimeout time.Duration `yaml:"default_timeout" default:"10s"` + DefaultRetries int `yaml:"default_retries" default:"10"` +} + +// DatabaseConfig holds database settings +type DatabaseConfig struct { + Path string `yaml:"path" default:"./data/db.sqlite"` +} + +// NotificationsConfig holds notification settings for multiple providers +type NotificationsConfig struct { + Enabled bool `yaml:"enabled"` + URLs []string `yaml:"urls"` +} + +type Upgrader struct { + IsEnabled bool `yaml:"is_enabled"` + Command string `yaml:"command"` +} + +// SetDefaults applies default values to configuration +func (c *ConfigHub) SetDefaults() error { + // Frontend defaults + if c.Server.Frontend.BaseURL == "" { + // Auto-detect protocol based on BaseHost or use HTTP as default + protocol := "http" + if c.Server.BaseHost != "localhost:8080" && c.Server.BaseHost != "127.0.0.1:8080" { + // For production domains, assume HTTPS + protocol = "https" + } + c.Server.Frontend.BaseURL = fmt.Sprintf("%s://%s/api/v1", protocol, c.Server.BaseHost) + } + if c.Server.Frontend.SocketURL == "" { + // Auto-detect protocol based on BaseHost or use WS as default + protocol := "ws" + if c.Server.BaseHost != "localhost:8080" && c.Server.BaseHost != "127.0.0.1:8080" { + // For production domains, assume WSS + protocol = "wss" + } + c.Server.Frontend.SocketURL = fmt.Sprintf("%s://%s/ws", protocol, c.Server.BaseHost) + } + + return nil +} + +// Validate checks if configuration is valid +func (c *ConfigHub) Validate() error { + // Validate notifications config if enabled + if c.Notifications.Enabled { + if len(c.Notifications.URLs) == 0 { + return fmt.Errorf("notification URLs are required when notifications are enabled") + } + + for i, url := range c.Notifications.URLs { + if url == "" { + return fmt.Errorf("notification URL at index %d cannot be empty", i) + } + } + } + + // Validate timezone + if _, err := time.LoadLocation(c.Timezone); err != nil { + return fmt.Errorf("invalid timezone: %w", err) + } + + return nil +} diff --git a/internal/config/load.go b/internal/config/load.go index 812cef9..ba5cc54 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -4,45 +4,45 @@ import ( "fmt" "os" "regexp" - "time" - "github.com/goccy/go-yaml" + "github.com/go-playground/validator/v10" + "github.com/sxwebdev/xconfig" + "github.com/sxwebdev/xconfig/decoders/xconfigdotenv" + "github.com/sxwebdev/xconfig/decoders/xconfigyaml" + "github.com/sxwebdev/xconfig/plugins/loader" + "github.com/sxwebdev/xconfig/plugins/validate" ) // Load reads and parses the configuration file -func Load(path string) (*Config, error) { - // Check if file exists - if _, err := os.Stat(path); os.IsNotExist(err) { - var cfg Config - if err := cfg.setDefaults(); err != nil { - return nil, fmt.Errorf("failed to set defaults: %w", err) - } - return &cfg, nil - } - - data, err := os.ReadFile(path) +func Load(conf any, envPrefix string, configPaths []string) error { + loader, err := loader.NewLoader(map[string]loader.Unmarshal{ + "env": xconfigdotenv.New().Unmarshal, + "yaml": xconfigyaml.New().Unmarshal, + }) if err != nil { - return nil, fmt.Errorf("failed to read config file: %w", err) - } - - // Expand environment variables - expanded := expandEnvVars(string(data)) - - var cfg Config - if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil { - return nil, fmt.Errorf("failed to parse config file: %w", err) + return fmt.Errorf("failed to create config loader: %w", err) } - // Apply defaults and validate - if err := cfg.setDefaults(); err != nil { - return nil, fmt.Errorf("failed to set defaults: %w", err) + for _, path := range configPaths { + if err := loader.AddFile(path, false); err != nil { + return fmt.Errorf("failed to add config file %q: %w", path, err) + } } - if err := cfg.validate(); err != nil { - return nil, fmt.Errorf("config validation failed: %w", err) + _, err = xconfig.Load(conf, + xconfig.WithEnvPrefix(envPrefix), + xconfig.WithLoader(loader), + xconfig.WithPlugins( + validate.New(func(a any) error { + return validator.New().Struct(a) + }), + ), + ) + if err != nil { + return err } - return &cfg, nil + return nil } // expandEnvVars replaces ${VAR} with environment variable values @@ -56,83 +56,3 @@ func expandEnvVars(s string) string { return match // Return original if env var not found }) } - -// setDefaults applies default values to configuration -func (c *Config) setDefaults() error { - // Server defaults - if c.Server.Host == "" { - c.Server.Host = "0.0.0.0" - } - if c.Server.Port == 0 { - c.Server.Port = 8080 - } - if c.Server.BaseHost == "" { - c.Server.BaseHost = "localhost:8080" - } - - // Frontend defaults - if c.Server.Frontend.BaseURL == "" { - // Auto-detect protocol based on BaseHost or use HTTP as default - protocol := "http" - if c.Server.BaseHost != "localhost:8080" && c.Server.BaseHost != "127.0.0.1:8080" { - // For production domains, assume HTTPS - protocol = "https" - } - c.Server.Frontend.BaseURL = fmt.Sprintf("%s://%s/api/v1", protocol, c.Server.BaseHost) - } - if c.Server.Frontend.SocketURL == "" { - // Auto-detect protocol based on BaseHost or use WS as default - protocol := "ws" - if c.Server.BaseHost != "localhost:8080" && c.Server.BaseHost != "127.0.0.1:8080" { - // For production domains, assume WSS - protocol = "wss" - } - c.Server.Frontend.SocketURL = fmt.Sprintf("%s://%s/ws", protocol, c.Server.BaseHost) - } - - // Monitoring defaults - if c.Monitoring.Global.DefaultInterval == 0 { - c.Monitoring.Global.DefaultInterval = time.Minute - } - if c.Monitoring.Global.DefaultTimeout == 0 { - c.Monitoring.Global.DefaultTimeout = 10 * time.Second - } - if c.Monitoring.Global.DefaultRetries == 0 { - c.Monitoring.Global.DefaultRetries = 5 - } - - // Database defaults - if c.Database.Path == "" { - c.Database.Path = "./data/db.sqlite" - } - - // Timezone defaults - if c.Timezone == "" { - c.Timezone = "UTC" - } - - return nil -} - -// validate checks if configuration is valid -func (c *Config) validate() error { - // Validate notifications config if enabled - if c.Notifications.Enabled { - if len(c.Notifications.URLs) == 0 { - return fmt.Errorf("notification URLs are required when notifications are enabled") - } - - for i, url := range c.Notifications.URLs { - if url == "" { - return fmt.Errorf("notification URL at index %d cannot be empty", i) - } - } - } - - // Validate timezone - if _, err := time.LoadLocation(c.Timezone); err != nil { - return fmt.Errorf("invalid timezone: %w", err) - } - - return nil -} diff --git a/internal/handlerutils/cors.go b/internal/handlerutils/cors.go new file mode 100644 index 0000000..8c71215 --- /dev/null +++ b/internal/handlerutils/cors.go @@ -0,0 +1,19 @@ +package handlerutils + +import ( + "net/http" + + connectcors "connectrpc.com/cors" + "github.com/rs/cors" +) + +func WithCORS(connectHandler http.Handler) http.Handler { + c := cors.New(cors.Options{ + AllowedOrigins: []string{"*"}, // Allow all origins + AllowedMethods: connectcors.AllowedMethods(), + AllowedHeaders: connectcors.AllowedHeaders(), + ExposedHeaders: connectcors.ExposedHeaders(), + MaxAge: 7200, // 2 hours in seconds, + }) + return c.Handler(connectHandler) +} diff --git a/internal/web/info.go b/internal/models/info.go similarity index 96% rename from internal/web/info.go rename to internal/models/info.go index a6a1d37..ea147c4 100644 --- a/internal/web/info.go +++ b/internal/models/info.go @@ -1,4 +1,4 @@ -package web +package models type ServerInfo struct { Version string diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index deb6455..45df471 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -18,13 +18,13 @@ import ( // MonitorService handles service monitoring type MonitorService struct { storage storage.Storage - config *config.Config + config *config.ConfigHub notifier *notifier.Notifier receiver *receiver.Receiver } // NewMonitorService creates a new monitor service -func NewMonitorService(storage storage.Storage, config *config.Config, notifier *notifier.Notifier, receiver *receiver.Receiver) *MonitorService { +func NewMonitorService(storage storage.Storage, config *config.ConfigHub, notifier *notifier.Notifier, receiver *receiver.Receiver) *MonitorService { return &MonitorService{ storage: storage, config: config, diff --git a/internal/web/auth_test.go b/internal/web/auth_test.go index 4e2061b..70f48e7 100644 --- a/internal/web/auth_test.go +++ b/internal/web/auth_test.go @@ -12,7 +12,7 @@ import ( func TestBasicAuth(t *testing.T) { // Create test config with auth enabled - cfg := &config.Config{ + cfg := &config.ConfigHub{ Server: config.ServerConfig{ Auth: config.AuthConfig{ Enabled: true, @@ -96,7 +96,7 @@ func TestBasicAuth(t *testing.T) { func TestWebSocketBypass(t *testing.T) { // Create test config with auth enabled - cfg := &config.Config{ + cfg := &config.ConfigHub{ Server: config.ServerConfig{ Auth: config.AuthConfig{ Enabled: true, diff --git a/internal/web/dto.go b/internal/web/dto.go index d2cd85d..396d7ee 100644 --- a/internal/web/dto.go +++ b/internal/web/dto.go @@ -3,6 +3,7 @@ package web import ( "time" + "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/monitors" "github.com/sxwebdev/sentinel/internal/storage" ) @@ -87,12 +88,12 @@ type ServiceDTO struct { } type ServerInfoResponse struct { - Version string `json:"version" example:"1.0.0"` - CommitHash string `json:"commit_hash" example:"abc123def456"` - BuildDate string `json:"build_date" example:"2023-10-01T12:00:00Z"` - GoVersion string `json:"go_version" example:"go1.24.4"` - SqliteVersion string `json:"sqlite_version" example:"3.50.1"` - OS string `json:"os" example:"linux"` - Arch string `json:"arch" example:"amd64"` - AvailableUpdate *AvailableUpdate `json:"available_update,omitempty"` + Version string `json:"version" example:"1.0.0"` + CommitHash string `json:"commit_hash" example:"abc123def456"` + BuildDate string `json:"build_date" example:"2023-10-01T12:00:00Z"` + GoVersion string `json:"go_version" example:"go1.24.4"` + SqliteVersion string `json:"sqlite_version" example:"3.50.1"` + OS string `json:"os" example:"linux"` + Arch string `json:"arch" example:"amd64"` + AvailableUpdate *models.AvailableUpdate `json:"available_update,omitempty"` } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 6871e63..9a3a07a 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -32,6 +32,7 @@ import ( "github.com/sxwebdev/sentinel/docs/docsv1" "github.com/sxwebdev/sentinel/frontend" "github.com/sxwebdev/sentinel/internal/config" + "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/monitor" "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/storage" @@ -45,9 +46,9 @@ import ( type Server struct { logger logger.Logger - serverInfo ServerInfo + serverInfo models.ServerInfo - config *config.Config + config *config.ConfigHub app *fiber.App wsConnections map[*websocket.Conn]bool wsMutex sync.Mutex @@ -62,8 +63,8 @@ type Server struct { // NewServer creates a new web server func NewServer( logger logger.Logger, - cfg *config.Config, - serverInfo ServerInfo, + cfg *config.ConfigHub, + serverInfo models.ServerInfo, monitorService *monitor.MonitorService, storage storage.Storage, receiver *receiver.Receiver, diff --git a/internal/web/release_checker.go b/internal/web/release_checker.go index a1e6995..e361e9e 100644 --- a/internal/web/release_checker.go +++ b/internal/web/release_checker.go @@ -9,6 +9,7 @@ import ( "time" "github.com/Masterminds/semver/v3" + "github.com/sxwebdev/sentinel/internal/models" ) // checkNewVersion checks if a new version is available from github releases @@ -146,7 +147,7 @@ func (s *Server) checkNewVersion() error { s.logger.Infof("Found %d newer version(s), latest: %s", len(newerReleases), latestRelease.TagName) - s.serverInfo.AvailableUpdate = &AvailableUpdate{ + s.serverInfo.AvailableUpdate = &models.AvailableUpdate{ IsAvailableManual: s.config.Upgrader.IsEnabled, TagName: latestRelease.TagName, URL: "https://github.com/sxwebdev/sentinel/releases/tag/" + latestRelease.TagName, diff --git a/schema/sentinel/agent/v1/agent.proto b/schema/sentinel/agent/v1/agent.proto new file mode 100644 index 0000000..67c7182 --- /dev/null +++ b/schema/sentinel/agent/v1/agent.proto @@ -0,0 +1,24 @@ +syntax = "proto3"; +package sentinel.agent.v1; + +import "sentinel/common/v1/info.proto"; + +// AgentService is the service for agent +service AgentService { + rpc Info(InfoRequest) returns (InfoResponse) {} +} + +enum AgentStatus { + AGENT_STATUS_UNSPECIFIED = 0; + AGENT_STATUS_ACTIVE = 1; + AGENT_STATUS_INACTIVE = 2; +} + +// InfoRequest is the request for Info method +message InfoRequest {} + +// InfoResponse is the response for Info method +message InfoResponse { + AgentStatus status = 1; + common.v1.ServerInfo server_info = 2; +} diff --git a/schema/sentinel/common/v1/info.proto b/schema/sentinel/common/v1/info.proto new file mode 100644 index 0000000..5d23853 --- /dev/null +++ b/schema/sentinel/common/v1/info.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; +package sentinel.common.v1; + +import "google/protobuf/timestamp.proto"; + +// AvailableUpdate represents information about an available update +message AvailableUpdate { + bool is_available_manual = 1; + string tag_name = 2; + string url = 3; + string description = 4; +} + +// ServerInfo represents information about the server +message ServerInfo { + string version = 1; + string commit_hash = 2; + string build_date = 3; + string go_version = 4; + string sqlite_version = 5; + string os = 6; + string arch = 7; + optional AvailableUpdate available_update = 8; + google.protobuf.Timestamp started_at = 9; +} From 77199911490421e28383a8e3ab1136d4bbbfc489 Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Wed, 17 Sep 2025 22:47:21 +0300 Subject: [PATCH 02/71] Refactor storage implementation: Remove SQLiteStorage, consolidate into a single Storage struct - Deleted the SQLiteStorage implementation and moved its functionality into a new Storage struct. - Updated the storage interface to reflect the new structure. - Adjusted service management methods to work with the new Storage struct. - Implemented service state management and incident handling directly within the Storage struct. - Updated database connection handling and migration logic. - Modified web handlers to accommodate changes in storage type. --- cmd/sentinel/start.go | 2 +- cmd/testapi/main.go | 4 +- internal/monitor/monitor.go | 4 +- internal/storage/factory.go | 23 --- internal/storage/incidents.go | 30 ++-- internal/storage/{orm.go => services.go} | 40 ++--- internal/storage/sqlite.go | 195 ----------------------- internal/storage/storage.go | 103 +++++++----- internal/web/handlers.go | 4 +- 9 files changed, 108 insertions(+), 297 deletions(-) delete mode 100644 internal/storage/factory.go rename internal/storage/{orm.go => services.go} (92%) delete mode 100644 internal/storage/sqlite.go diff --git a/cmd/sentinel/start.go b/cmd/sentinel/start.go index e2973f3..83a9bb6 100644 --- a/cmd/sentinel/start.go +++ b/cmd/sentinel/start.go @@ -59,7 +59,7 @@ func startCMD() *cli.Command { } // Initialize storage - store, err := storage.NewStorage(storage.StorageTypeSQLite, conf.Database.Path) + store, err := storage.New(conf.Database.Path) if err != nil { return fmt.Errorf("failed to initialize storage: %w", err) } diff --git a/cmd/testapi/main.go b/cmd/testapi/main.go index 4ac2716..b71dc45 100644 --- a/cmd/testapi/main.go +++ b/cmd/testapi/main.go @@ -31,7 +31,7 @@ type TestSuite struct { server *web.Server baseURL string client *http.Client - stor storage.Storage + stor *storage.Storage ctx context.Context services map[string]*web.ServiceDTO incidents map[string]*web.Incident @@ -226,7 +226,7 @@ func setupTestSuite() (*TestSuite, error) { l := logger.Default() // Initialize storage - stor, err := storage.NewStorage(storage.StorageTypeSQLite, dbPath) + stor, err := storage.New(dbPath) if err != nil { return nil, fmt.Errorf("failed to initialize storage: %w", err) } diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 45df471..6c0bdfe 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -17,14 +17,14 @@ import ( // MonitorService handles service monitoring type MonitorService struct { - storage storage.Storage + storage *storage.Storage config *config.ConfigHub notifier *notifier.Notifier receiver *receiver.Receiver } // NewMonitorService creates a new monitor service -func NewMonitorService(storage storage.Storage, config *config.ConfigHub, notifier *notifier.Notifier, receiver *receiver.Receiver) *MonitorService { +func NewMonitorService(storage *storage.Storage, config *config.ConfigHub, notifier *notifier.Notifier, receiver *receiver.Receiver) *MonitorService { return &MonitorService{ storage: storage, config: config, diff --git a/internal/storage/factory.go b/internal/storage/factory.go deleted file mode 100644 index aec89ff..0000000 --- a/internal/storage/factory.go +++ /dev/null @@ -1,23 +0,0 @@ -package storage - -import ( - "fmt" - "strings" -) - -// StorageType represents the type of storage backend -type StorageType string - -const ( - StorageTypeSQLite StorageType = "sqlite" -) - -// NewStorage creates a new storage instance based on the provided type and path -func NewStorage(storageType StorageType, dbPath string) (Storage, error) { - switch strings.ToLower(string(storageType)) { - case string(StorageTypeSQLite): - return NewSQLiteStorage(dbPath) - default: - return nil, fmt.Errorf("unsupported storage type: %s", storageType) - } -} diff --git a/internal/storage/incidents.go b/internal/storage/incidents.go index 47eb1c1..640898a 100644 --- a/internal/storage/incidents.go +++ b/internal/storage/incidents.go @@ -26,7 +26,7 @@ type IncidentRow struct { } // GetIncidentByID retrieves an incident by ID -func (o *ORMStorage) GetIncidentByID(ctx context.Context, id string) (*Incident, error) { +func (o *Storage) GetIncidentByID(ctx context.Context, id string) (*Incident, error) { if id == "" { return nil, fmt.Errorf("id is required") } @@ -120,7 +120,7 @@ func findIncidentsBuilder(params FindIncidentsParams, col ...string) *sqlbuilder } // FindIncidents finds incidents -func (o *ORMStorage) FindIncidents(ctx context.Context, params FindIncidentsParams) (dbutils.FindResponseWithCount[*Incident], error) { +func (o *Storage) FindIncidents(ctx context.Context, params FindIncidentsParams) (dbutils.FindResponseWithCount[*Incident], error) { sb := findIncidentsBuilder(params, "i.id", "i.service_id", @@ -192,7 +192,7 @@ func (o *ORMStorage) FindIncidents(ctx context.Context, params FindIncidentsPara } // FindIncidents finds incidents -func (o *ORMStorage) IncidentsCount(ctx context.Context, params FindIncidentsParams) (uint32, error) { +func (o *Storage) IncidentsCount(ctx context.Context, params FindIncidentsParams) (uint32, error) { // Get total count of incidents var totalCount uint32 countBuilder := findIncidentsBuilder(params, "COUNT(*)") @@ -207,7 +207,7 @@ func (o *ORMStorage) IncidentsCount(ctx context.Context, params FindIncidentsPar } // ResolveAllIncidents resolves all incidents for a service -func (o *ORMStorage) ResolveAllIncidents(ctx context.Context, serviceID string) ([]*Incident, error) { +func (o *Storage) ResolveAllIncidents(ctx context.Context, serviceID string) ([]*Incident, error) { if serviceID == "" { return nil, fmt.Errorf("serviceID is required") } @@ -267,14 +267,14 @@ func (o *ORMStorage) ResolveAllIncidents(ctx context.Context, serviceID string) return resolvedIncidents, nil } -// CreateIncident creates a new incident using ORM with retry logic -func (o *ORMStorage) CreateIncident(ctx context.Context, incident *Incident) error { +// SaveIncident creates a new incident using ORM with retry logic +func (o *Storage) SaveIncident(ctx context.Context, incident *Incident) error { ib := sqlbuilder.NewInsertBuilder() ib.InsertInto("incidents") ib.Cols("id", "service_id", "start_time", "end_time", "error", "duration_ns", "resolved") ib.Values( - incident.ID, + GenerateULID(), incident.ServiceID, incident.StartTime, incident.EndTime, @@ -293,7 +293,7 @@ func (o *ORMStorage) CreateIncident(ctx context.Context, incident *Incident) err } // UpdateIncident updates an existing incident using ORM with retry logic -func (o *ORMStorage) UpdateIncident(ctx context.Context, incident *Incident) error { +func (o *Storage) UpdateIncident(ctx context.Context, incident *Incident) error { ub := sqlbuilder.NewUpdateBuilder() ub.Update("incidents") ub.Set( @@ -317,7 +317,7 @@ func (o *ORMStorage) UpdateIncident(ctx context.Context, incident *Incident) err } // DeleteIncident deletes an incident by ID using ORM with retry logic -func (o *ORMStorage) DeleteIncident(ctx context.Context, incidentID string) error { +func (o *Storage) DeleteIncident(ctx context.Context, incidentID string) error { db := sqlbuilder.NewDeleteBuilder() db.DeleteFrom("incidents") db.Where(db.Equal("id", incidentID)) @@ -341,7 +341,7 @@ type GetIncidentsStatsByDateRangeItem struct { type GetIncidentsStatsByDateRangeData []GetIncidentsStatsByDateRangeItem // GetIncidentsStatsByDateRange retrieves the stats of incidents within a specific date range -func (o *ORMStorage) GetIncidentsStatsByDateRange(ctx context.Context, startTime, endTime time.Time) (GetIncidentsStatsByDateRangeData, error) { +func (o *Storage) GetIncidentsStatsByDateRange(ctx context.Context, startTime, endTime time.Time) (GetIncidentsStatsByDateRangeData, error) { // Generate date series for the range var result GetIncidentsStatsByDateRangeData @@ -392,3 +392,13 @@ func (o *ORMStorage) GetIncidentsStatsByDateRange(ctx context.Context, startTime return result, nil } + +// GetSQLiteVersion returns the SQLite version +func (o *Storage) GetSQLiteVersion(ctx context.Context) (string, error) { + var version string + err := o.db.QueryRowContext(ctx, "SELECT sqlite_version()").Scan(&version) + if err != nil { + return "", fmt.Errorf("failed to get SQLite version: %w", err) + } + return version, nil +} diff --git a/internal/storage/orm.go b/internal/storage/services.go similarity index 92% rename from internal/storage/orm.go rename to internal/storage/services.go index 5865dd4..82870c4 100644 --- a/internal/storage/orm.go +++ b/internal/storage/services.go @@ -14,18 +14,8 @@ import ( "github.com/sxwebdev/sentinel/pkg/dbutils" ) -// ORMStorage provides ORM-like functionality using go-sqlbuilder -type ORMStorage struct { - db *sql.DB -} - -// NewORMStorage creates a new ORM storage instance -func NewORMStorage(db *sql.DB) *ORMStorage { - return &ORMStorage{db: db} -} - -// GetServiceStatsWithORM calculates statistics for a service using ORM -func (o *ORMStorage) GetServiceStatsWithORM(ctx context.Context, params FindIncidentsParams) (*ServiceStats, error) { +// GetServiceStats calculates statistics for a service +func (o *Storage) GetServiceStats(ctx context.Context, params FindIncidentsParams) (*ServiceStats, error) { if params.ServiceID == "" || params.StartTime == nil { return nil, fmt.Errorf("service ID and start time are required for stats") } @@ -122,7 +112,7 @@ func (o *ORMStorage) GetServiceStatsWithORM(ctx context.Context, params FindInci } // rowToIncident converts an IncidentRow to Incident -func (o *ORMStorage) rowToIncident(row *IncidentRow) *Incident { +func (o *Storage) rowToIncident(row *IncidentRow) *Incident { incident := &Incident{ ID: row.ID, ServiceID: row.ServiceID, @@ -141,7 +131,7 @@ func (o *ORMStorage) rowToIncident(row *IncidentRow) *Incident { } // GetServiceByID finds a service by ID using ORM -func (o *ORMStorage) GetServiceByID(ctx context.Context, id string) (*Service, error) { +func (o *Storage) GetServiceByID(ctx context.Context, id string) (*Service, error) { sb := sqlbuilder.NewSelectBuilder() sb.Select( "s.id", @@ -268,7 +258,7 @@ type FindServicesParams struct { } // GetAllServices finds all services using ORM -func (o *ORMStorage) FindServices(ctx context.Context, params FindServicesParams) (dbutils.FindResponseWithCount[*Service], error) { +func (o *Storage) FindServices(ctx context.Context, params FindServicesParams) (dbutils.FindResponseWithCount[*Service], error) { sb := findServicesBuilder( params, "s.id", @@ -394,7 +384,7 @@ func (o *ORMStorage) FindServices(ctx context.Context, params FindServicesParams } // CreateService creates a new service using ORM with retry logic -func (o *ORMStorage) CreateService(ctx context.Context, service CreateUpdateServiceRequest) (*Service, error) { +func (o *Storage) CreateService(ctx context.Context, service CreateUpdateServiceRequest) (*Service, error) { ib := sqlbuilder.NewInsertBuilder() ib.InsertInto("services") ib.Cols("id", "name", "protocol", "interval", "timeout", "retries", "tags", "config", "is_enabled") @@ -456,7 +446,7 @@ func (o *ORMStorage) CreateService(ctx context.Context, service CreateUpdateServ } // UpdateService updates an existing service using ORM with retry logic -func (o *ORMStorage) UpdateService(ctx context.Context, id string, service CreateUpdateServiceRequest) (*Service, error) { +func (o *Storage) UpdateService(ctx context.Context, id string, service CreateUpdateServiceRequest) (*Service, error) { ub := sqlbuilder.NewUpdateBuilder() ub.Update("services") @@ -506,7 +496,7 @@ func (o *ORMStorage) UpdateService(ctx context.Context, id string, service Creat } // DeleteService deletes a service by ID -func (o *ORMStorage) DeleteService(ctx context.Context, id string) error { +func (o *Storage) DeleteService(ctx context.Context, id string) error { // Start transaction tx, err := o.db.BeginTx(ctx, nil) if err != nil { @@ -555,7 +545,7 @@ func (o *ORMStorage) DeleteService(ctx context.Context, id string) error { // Service state management methods // GetServiceState gets service state by service ID -func (o *ORMStorage) GetServiceState(ctx context.Context, serviceID string) (*ServiceStateRecord, error) { +func (o *Storage) GetServiceState(ctx context.Context, serviceID string) (*ServiceStateRecord, error) { query := ` SELECT id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time_ns, @@ -590,7 +580,7 @@ func (o *ORMStorage) GetServiceState(ctx context.Context, serviceID string) (*Se } // CreateServiceState creates a new service state -func (o *ORMStorage) CreateServiceState(ctx context.Context, tx *sql.Tx, state *ServiceStateRecord) error { +func (o *Storage) CreateServiceState(ctx context.Context, tx *sql.Tx, state *ServiceStateRecord) error { query := ` INSERT INTO service_states ( id, service_id, status, last_check, next_check, last_error, @@ -617,7 +607,7 @@ func (o *ORMStorage) CreateServiceState(ctx context.Context, tx *sql.Tx, state * } // UpdateServiceState updates or creates service state -func (o *ORMStorage) UpdateServiceState(ctx context.Context, params *ServiceStateRecord) error { +func (o *Storage) UpdateServiceState(ctx context.Context, params *ServiceStateRecord) error { ub := sqlbuilder.NewUpdateBuilder() ub.Update("service_states") ub.Set( @@ -643,7 +633,7 @@ func (o *ORMStorage) UpdateServiceState(ctx context.Context, params *ServiceStat } // GetAllServiceStates gets all service states -func (o *ORMStorage) GetAllServiceStates(ctx context.Context) ([]*ServiceStateRecord, error) { +func (o *Storage) GetAllServiceStates(ctx context.Context) ([]*ServiceStateRecord, error) { query := ` SELECT id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time_ns, @@ -680,7 +670,7 @@ func (o *ORMStorage) GetAllServiceStates(ctx context.Context) ([]*ServiceStateRe } // DeleteServiceState deletes service state by service ID -func (o *ORMStorage) DeleteServiceState(ctx context.Context, serviceID string) error { +func (o *Storage) DeleteServiceState(ctx context.Context, serviceID string) error { query := `DELETE FROM service_states WHERE service_id = ?` _, err := o.db.ExecContext(ctx, query, serviceID) if err != nil { @@ -689,7 +679,7 @@ func (o *ORMStorage) DeleteServiceState(ctx context.Context, serviceID string) e return nil } -func (o *ORMStorage) GetAllTags(ctx context.Context) ([]string, error) { +func (o *Storage) GetAllTags(ctx context.Context) ([]string, error) { sb := sqlbuilder.NewSelectBuilder() sb.Select("DISTINCT json_each.value") sb.From("services, json_each(tags)") @@ -718,7 +708,7 @@ func (o *ORMStorage) GetAllTags(ctx context.Context) ([]string, error) { return tags, nil } -func (o *ORMStorage) GetAllTagsWithCount(ctx context.Context) (map[string]int, error) { +func (o *Storage) GetAllTagsWithCount(ctx context.Context) (map[string]int, error) { sb := sqlbuilder.NewSelectBuilder() sb.Select("json_each.value, COUNT(*)") sb.From("services, json_each(tags)") diff --git a/internal/storage/sqlite.go b/internal/storage/sqlite.go deleted file mode 100644 index a3a6211..0000000 --- a/internal/storage/sqlite.go +++ /dev/null @@ -1,195 +0,0 @@ -package storage - -import ( - "context" - "database/sql" - "fmt" - "os" - "path/filepath" - "time" - - "github.com/sxwebdev/sentinel/pkg/dbutils" - _ "modernc.org/sqlite" -) - -// SQLiteStorage implements Storage interface using SQLite -type SQLiteStorage struct { - db *sql.DB - orm *ORMStorage -} - -var _ Storage = (*SQLiteStorage)(nil) - -// NewSQLiteStorage creates a new SQLite storage instance -func NewSQLiteStorage(dbPath string) (*SQLiteStorage, error) { - // Ensure directory exists - dir := filepath.Dir(dbPath) - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, fmt.Errorf("failed to create database directory: %w", err) - } - - // Open SQLite database with proper settings for concurrent access - db, err := sql.Open("sqlite", dbPath+"?_busy_timeout=30000&_journal_mode=WAL&_synchronous=NORMAL&_cache_size=10000&_foreign_keys=on") - if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) - } - - // Set connection pool settings - db.SetMaxOpenConns(1) - db.SetMaxIdleConns(1) - db.SetConnMaxLifetime(time.Hour) - - // Test connection - if err := db.PingContext(context.Background()); err != nil { - return nil, fmt.Errorf("failed to ping database: %w", err) - } - - storage := &SQLiteStorage{ - db: db, - orm: NewORMStorage(db), - } - - if err := runMigrations(db); err != nil { - db.Close() - return nil, fmt.Errorf("failed to migrate database: %w", err) - } - - return storage, nil -} - -// Name returns the storage type -func (s *SQLiteStorage) Name() string { - return "SQLite" -} - -// Start initializes the storage -func (s *SQLiteStorage) Start(_ context.Context) error { - if s.db == nil { - return fmt.Errorf("storage not initialized") - } - return nil -} - -// Stop closes the database connection -func (s *SQLiteStorage) Stop(_ context.Context) error { - if s.db != nil { - if err := s.db.Close(); err != nil { - return fmt.Errorf("failed to close database: %w", err) - } - s.db = nil - } - return nil -} - -// Incident methods - -// SaveIncident saves a new incident to the database -func (s *SQLiteStorage) SaveIncident(ctx context.Context, incident *Incident) error { - incident.ID = GenerateULID() - - return s.orm.CreateIncident(ctx, incident) -} - -// GetIncidentByID retrieves an incident by ID -func (s *SQLiteStorage) GetIncidentByID(ctx context.Context, id string) (*Incident, error) { - return s.orm.GetIncidentByID(ctx, id) -} - -// UpdateIncident updates an existing incident -func (s *SQLiteStorage) UpdateIncident(ctx context.Context, incident *Incident) error { - return s.orm.UpdateIncident(ctx, incident) -} - -// DeleteIncident deletes an incident by ID -func (s *SQLiteStorage) DeleteIncident(ctx context.Context, incidentID string) error { - return s.orm.DeleteIncident(ctx, incidentID) -} - -// FindIncidents retrieves all incidents -func (s *SQLiteStorage) FindIncidents(ctx context.Context, params FindIncidentsParams) (dbutils.FindResponseWithCount[*Incident], error) { - return s.orm.FindIncidents(ctx, params) -} - -// IncidentsCount retrieves the total count of incidents -func (s *SQLiteStorage) IncidentsCount(ctx context.Context, params FindIncidentsParams) (uint32, error) { - return s.orm.IncidentsCount(ctx, params) -} - -// GetServiceStats calculates statistics for a service -func (s *SQLiteStorage) GetServiceStats(ctx context.Context, params FindIncidentsParams) (*ServiceStats, error) { - return s.orm.GetServiceStatsWithORM(ctx, params) -} - -// ResolveAllIncidents resolves all incidents for a service -func (s *SQLiteStorage) ResolveAllIncidents(ctx context.Context, serviceID string) ([]*Incident, error) { - return s.orm.ResolveAllIncidents(ctx, serviceID) -} - -// GetIncidentsStatsByDateRange retrieves incident stats by date range -func (s *SQLiteStorage) GetIncidentsStatsByDateRange(ctx context.Context, startTime, endTime time.Time) (GetIncidentsStatsByDateRangeData, error) { - return s.orm.GetIncidentsStatsByDateRange(ctx, startTime, endTime) -} - -// Service methods - -// CreateService saves a new service to the database -func (s *SQLiteStorage) CreateService(ctx context.Context, service CreateUpdateServiceRequest) (*Service, error) { - return s.orm.CreateService(ctx, service) -} - -// GetService retrieves a service by ID -func (s *SQLiteStorage) GetServiceByID(ctx context.Context, id string) (*Service, error) { - return s.orm.GetServiceByID(ctx, id) -} - -// FindServices retrieves all services -func (s *SQLiteStorage) FindServices(ctx context.Context, params FindServicesParams) (dbutils.FindResponseWithCount[*Service], error) { - return s.orm.FindServices(ctx, params) -} - -// UpdateService updates an existing service -func (s *SQLiteStorage) UpdateService(ctx context.Context, id string, service CreateUpdateServiceRequest) (*Service, error) { - return s.orm.UpdateService(ctx, id, service) -} - -// DeleteService deletes a service by ID -func (s *SQLiteStorage) DeleteService(ctx context.Context, id string) error { - return s.orm.DeleteService(ctx, id) -} - -// Service state management methods - -// GetServiceState gets service state -func (s *SQLiteStorage) GetServiceState(ctx context.Context, serviceID string) (*ServiceStateRecord, error) { - return s.orm.GetServiceState(ctx, serviceID) -} - -// UpdateServiceState updates service state -func (s *SQLiteStorage) UpdateServiceState(ctx context.Context, state *ServiceStateRecord) error { - return s.orm.UpdateServiceState(ctx, state) -} - -// GetAllServiceStates gets all service states -func (s *SQLiteStorage) GetAllServiceStates(ctx context.Context) ([]*ServiceStateRecord, error) { - return s.orm.GetAllServiceStates(ctx) -} - -// GetAllTags retrieves all unique tags across services -func (s *SQLiteStorage) GetAllTags(ctx context.Context) ([]string, error) { - return s.orm.GetAllTags(ctx) -} - -// GetAllTagsWithCount retrieves all unique tags with their usage count -func (s *SQLiteStorage) GetAllTagsWithCount(ctx context.Context) (map[string]int, error) { - return s.orm.GetAllTagsWithCount(ctx) -} - -// GetSQLiteVersion returns the SQLite version -func (s *SQLiteStorage) GetSQLiteVersion(ctx context.Context) (string, error) { - var version string - err := s.db.QueryRowContext(ctx, "SELECT sqlite_version()").Scan(&version) - if err != nil { - return "", fmt.Errorf("failed to get SQLite version: %w", err) - } - return version, nil -} diff --git a/internal/storage/storage.go b/internal/storage/storage.go index dd41cf5..b7bc090 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -2,45 +2,74 @@ package storage import ( "context" + "database/sql" + "fmt" + "os" + "path/filepath" "time" - "github.com/sxwebdev/sentinel/pkg/dbutils" - "github.com/tkcrm/mx/service" + _ "modernc.org/sqlite" ) -// Storage defines the interface for incident storage -type Storage interface { - service.IService - - // Incident management - GetIncidentByID(ctx context.Context, id string) (*Incident, error) - SaveIncident(ctx context.Context, incident *Incident) error - UpdateIncident(ctx context.Context, incident *Incident) error - DeleteIncident(ctx context.Context, incidentID string) error - FindIncidents(ctx context.Context, params FindIncidentsParams) (dbutils.FindResponseWithCount[*Incident], error) - IncidentsCount(ctx context.Context, params FindIncidentsParams) (uint32, error) - ResolveAllIncidents(ctx context.Context, serviceID string) ([]*Incident, error) - GetIncidentsStatsByDateRange(ctx context.Context, startTime, endTime time.Time) (GetIncidentsStatsByDateRangeData, error) - - // Service management - CreateService(ctx context.Context, request CreateUpdateServiceRequest) (*Service, error) - GetServiceByID(ctx context.Context, id string) (*Service, error) - FindServices(ctx context.Context, params FindServicesParams) (dbutils.FindResponseWithCount[*Service], error) - UpdateService(ctx context.Context, id string, request CreateUpdateServiceRequest) (*Service, error) - DeleteService(ctx context.Context, id string) error - - // Service state management - GetServiceState(ctx context.Context, serviceID string) (*ServiceStateRecord, error) - UpdateServiceState(ctx context.Context, state *ServiceStateRecord) error - GetAllServiceStates(ctx context.Context) ([]*ServiceStateRecord, error) - - // Tags - GetAllTags(ctx context.Context) ([]string, error) - GetAllTagsWithCount(ctx context.Context) (map[string]int, error) - - // Statistics - GetServiceStats(ctx context.Context, params FindIncidentsParams) (*ServiceStats, error) - - // SQLite specific methods - GetSQLiteVersion(ctx context.Context) (string, error) +// Storage implements Storage interface using SQLite +type Storage struct { + db *sql.DB +} + +// New creates a new SQLite storage instance +func New(dbPath string) (*Storage, error) { + // Ensure directory exists + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create database directory: %w", err) + } + + // Open SQLite database with proper settings for concurrent access + db, err := sql.Open("sqlite", dbPath+"?_busy_timeout=30000&_journal_mode=WAL&_synchronous=NORMAL&_cache_size=10000&_foreign_keys=on") + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Set connection pool settings + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(time.Hour) + + // Test connection + if err := db.PingContext(context.Background()); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + if err := runMigrations(db); err != nil { + db.Close() + return nil, fmt.Errorf("failed to migrate database: %w", err) + } + + return &Storage{ + db: db, + }, nil +} + +// Name returns the storage type +func (s *Storage) Name() string { + return "storage_sqlite" +} + +// Start initializes the storage +func (s *Storage) Start(_ context.Context) error { + if s.db == nil { + return fmt.Errorf("storage not initialized") + } + return nil +} + +// Stop closes the database connection +func (s *Storage) Stop(_ context.Context) error { + if s.db != nil { + if err := s.db.Close(); err != nil { + return fmt.Errorf("failed to close database: %w", err) + } + s.db = nil + } + return nil } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 9a3a07a..4ec5069 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -54,7 +54,7 @@ type Server struct { wsMutex sync.Mutex validator *validator.Validate - storage storage.Storage + storage *storage.Storage monitorService *monitor.MonitorService receiver *receiver.Receiver upgrader *upgrader.Upgrader @@ -66,7 +66,7 @@ func NewServer( cfg *config.ConfigHub, serverInfo models.ServerInfo, monitorService *monitor.MonitorService, - storage storage.Storage, + storage *storage.Storage, receiver *receiver.Receiver, upgrader *upgrader.Upgrader, ) (*Server, error) { From c074a6225faf60940e4816e7d4485e3ecbac8141 Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Thu, 18 Sep 2025 12:42:51 +0300 Subject: [PATCH 03/71] feat: Implement getFingerprint method to generate and save agent fingerprint --- cmd/agent/start.go | 12 ++----- cmd/sentinel/start.go | 12 ++----- cmd/testapi/main.go | 2 +- config-agent.template.yaml | 1 + config.template.yaml | 1 + go.mod | 8 +++++ go.sum | 20 +++++++++++ internal/agent/agent.go | 17 ++++++++-- internal/agent/agentserver/server.go | 4 +-- internal/agent/fingerprint.go | 40 ++++++++++++++++++++++ internal/config/agent.go | 28 +++------------ internal/config/hub.go | 1 + internal/models/info.go | 19 ----------- internal/models/system_info.go | 51 ++++++++++++++++++++++++++++ internal/web/handlers.go | 4 +-- 15 files changed, 150 insertions(+), 70 deletions(-) create mode 100644 internal/agent/fingerprint.go delete mode 100644 internal/models/info.go create mode 100644 internal/models/system_info.go diff --git a/cmd/agent/start.go b/cmd/agent/start.go index abdce6f..b5cb720 100644 --- a/cmd/agent/start.go +++ b/cmd/agent/start.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "net/http" - "runtime" "connectrpc.com/connect" "github.com/sxwebdev/sentinel/internal/agent" @@ -53,17 +52,10 @@ func startCMD() *cli.Command { launcher.WithAppStartStopLog(true), ) - serverInfo := models.ServerInfo{ - Version: version, - CommitHash: commitHash, - BuildDate: buildDate, - GoVersion: runtime.Version(), - OS: runtime.GOOS, - Arch: runtime.GOARCH, - } + serverInfo := models.GetSystemInfo(version, commitHash, buildDate) // init agent service - ag := agent.New(l, serverInfo) + ag := agent.New(l, conf, serverInfo) // init agent rpc server agentServer := agentserver.New(serverInfo) diff --git a/cmd/sentinel/start.go b/cmd/sentinel/start.go index 83a9bb6..09d589c 100644 --- a/cmd/sentinel/start.go +++ b/cmd/sentinel/start.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "runtime" "time" "github.com/sxwebdev/sentinel/internal/config" @@ -95,15 +94,8 @@ func startCMD() *cli.Command { // Initialize scheduler sched := scheduler.New(l, monitorService, rc) - serverInfo := models.ServerInfo{ - Version: version, - CommitHash: commitHash, - BuildDate: buildDate, - GoVersion: runtime.Version(), - SqliteVersion: sqliteVersion, - OS: runtime.GOOS, - Arch: runtime.GOARCH, - } + serverInfo := models.GetSystemInfo(version, commitHash, buildDate) + serverInfo.SqliteVersion = sqliteVersion webServer, err := web.NewServer(l, conf, serverInfo, monitorService, store, rc, upgr) if err != nil { diff --git a/cmd/testapi/main.go b/cmd/testapi/main.go index b71dc45..f468df8 100644 --- a/cmd/testapi/main.go +++ b/cmd/testapi/main.go @@ -250,7 +250,7 @@ func setupTestSuite() (*TestSuite, error) { monitorService := monitor.NewMonitorService(stor, cfg, notif, rc) // Create web server - webServer, err := web.NewServer(l, cfg, models.ServerInfo{}, monitorService, stor, rc, upgr) + webServer, err := web.NewServer(l, cfg, models.SystemInfo{}, monitorService, stor, rc, upgr) if err != nil { return nil, fmt.Errorf("failed to create web server: %w", err) } diff --git a/config-agent.template.yaml b/config-agent.template.yaml index 9b84c00..4af8dd5 100644 --- a/config-agent.template.yaml +++ b/config-agent.template.yaml @@ -26,6 +26,7 @@ ops: path: /debug/pprof port: "10000" write_timeout: 60 +data_dir: ./data-agent server: enabled: true addr: :9000 diff --git a/config.template.yaml b/config.template.yaml index 0fadd02..dc9bebb 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -26,6 +26,7 @@ ops: path: /debug/pprof port: "10000" write_timeout: 60 +data_dir: ./data server: port: 8080 host: 0.0.0.0 diff --git a/go.mod b/go.mod index f391f9e..5eb6030 100644 --- a/go.mod +++ b/go.mod @@ -17,6 +17,7 @@ require ( github.com/oklog/ulid/v2 v2.1.1 github.com/puzpuzpuz/xsync/v3 v3.5.1 github.com/rs/cors v1.11.1 + github.com/shirou/gopsutil/v4 v4.25.8 github.com/stretchr/testify v1.11.1 github.com/swaggo/fiber-swagger v1.3.0 github.com/swaggo/swag v1.16.6 @@ -42,12 +43,14 @@ require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/dustin/go-humanize v1.0.1 // indirect + github.com/ebitengine/purego v0.8.4 // indirect github.com/fasthttp/websocket v1.5.12 // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/gabriel-vasile/mimetype v1.4.10 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-ole/go-ole v1.2.6 // indirect github.com/go-openapi/jsonpointer v0.22.0 // indirect github.com/go-openapi/jsonreference v0.21.1 // indirect github.com/go-openapi/spec v0.21.0 // indirect @@ -74,6 +77,7 @@ require ( github.com/josharian/intern v1.0.0 // indirect github.com/klauspost/compress v1.18.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect + github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -81,6 +85,7 @@ require ( github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // 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 @@ -89,8 +94,11 @@ require ( github.com/rivo/uniseg v0.4.7 // indirect github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 // indirect github.com/swaggo/files v1.0.1 // indirect + github.com/tklauser/go-sysconf v0.3.15 // indirect + github.com/tklauser/numcpus v0.10.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasthttp v1.66.0 // indirect + github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel v1.38.0 // indirect diff --git a/go.sum b/go.sum index 308d4c7..e352095 100644 --- a/go.sum +++ b/go.sum @@ -34,6 +34,8 @@ github.com/dromara/carbon/v2 v2.6.12 h1:WOFEZUXEbsGCZ4Y3AWBrMfCySeQ0zi8NrlwqspJp github.com/dromara/carbon/v2 v2.6.12/go.mod h1:NGo3reeV5vhWCYWcSqbJRZm46MEwyfYI5EJRdVFoLJo= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/fasthttp/websocket v1.5.12 h1:e4RGPpWW2HTbL3zV0Y/t7g0ub294LkiuXXUuTOUInlE= github.com/fasthttp/websocket v1.5.12/go.mod h1:I+liyL7/4moHojiOgUOIKEWm9EIxHqxZChS+aMFltyg= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= @@ -48,6 +50,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= +github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM= @@ -105,6 +109,7 @@ github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5 github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/pprof v0.0.0-20250903194437-c28834ac2320 h1:c7ayAhbRP9HnEl/hg/WQOM9s0snWztfW6feWXZbGHw0= @@ -142,6 +147,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= +github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.7.6/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= @@ -172,6 +179,8 @@ github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 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= @@ -194,6 +203,8 @@ github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 h1:qIQ0tWF9vxGtkJa24bR+2i53WBCz1nW/Pc47oVYauC4= github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= +github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= +github.com/shirou/gopsutil/v4 v4.25.8/go.mod h1:q9QdMmfAOVIw7a+eF86P7ISEU6ka+NLgkUxlopV4RwI= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= @@ -222,6 +233,10 @@ github.com/tkcrm/mx v0.2.34 h1:reTg836KS00FI+QMBTQIa2wh6/Z28PE7cHBtTw7Y5nQ= github.com/tkcrm/mx v0.2.34/go.mod h1:9N8UrILT8mg0IWb2MMtq2MqOMW1CQVIMmEh9ML37N+0= github.com/tkcrm/mx/transport/connectrpc_transport v0.0.0-20250618055556-3f77aaa9ddbd h1:a2zSOXliAdXfGKPH9h6QJWpfh0Uv/zfShlp+JzB85Ns= github.com/tkcrm/mx/transport/connectrpc_transport v0.0.0-20250618055556-3f77aaa9ddbd/go.mod h1:vGkbLZEJ5vhAumATg5oCXKDQwVmsSKsgps2qyLp+oT4= +github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= +github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= +github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= +github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= github.com/urfave/cli/v2 v2.3.0/go.mod h1:LJmUH05zAU44vOAcrfzZQKsZbVcdbOG8rtL3/XcUArI= github.com/urfave/cli/v3 v3.4.1 h1:1M9UOCy5bLmGnuu1yn3t3CB4rG79Rtoxuv1sPhnm6qM= github.com/urfave/cli/v3 v3.4.1/go.mod h1:FJSKtM/9AiiTOJL4fJ6TbMUkxBXn7GO9guZqoZtpYpo= @@ -236,6 +251,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.4.0/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.akshayshah.org/attest v1.0.0 h1:f66BDlh/xo2KjIfmtqOFlj5cpn6mvGrP1LXY3Tex4L0= go.akshayshah.org/attest v1.0.0/go.mod h1:PnWzcW5j9dkyGwTlBmUsYpPnHG0AUPrs1RQ+HrldWO0= go.akshayshah.org/connectproto v0.6.0 h1:tqmysQF2AfvUeYS03mRAAZTFpiQeXqhGIDnH1GO2D2U= @@ -293,7 +310,9 @@ golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210420072515-93ed5bcd2bfe/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -325,6 +344,7 @@ golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= diff --git a/internal/agent/agent.go b/internal/agent/agent.go index c416153..47d920e 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -4,20 +4,31 @@ import ( "context" "github.com/sxwebdev/sentinel/internal/agent/agentserver" + "github.com/sxwebdev/sentinel/internal/config" "github.com/sxwebdev/sentinel/internal/models" "github.com/tkcrm/mx/logger" ) type Agent struct { logger logger.Logger + + config *config.ConfigAgent + systemInfo models.SystemInfo + server *agentserver.Server } // New creates a new Agent instance -func New(l logger.Logger, serverInfo models.ServerInfo) *Agent { +func New( + l logger.Logger, + config *config.ConfigAgent, + systemInfo models.SystemInfo, +) *Agent { return &Agent{ - logger: l, - server: agentserver.New(serverInfo), + logger: l, + config: config, + systemInfo: systemInfo, + server: agentserver.New(systemInfo), } } diff --git a/internal/agent/agentserver/server.go b/internal/agent/agentserver/server.go index 70f5bf7..b42827b 100644 --- a/internal/agent/agentserver/server.go +++ b/internal/agent/agentserver/server.go @@ -15,14 +15,14 @@ import ( ) type Server struct { - info models.ServerInfo + info models.SystemInfo startedAt time.Time agentv1connect.UnimplementedAgentServiceHandler connectrpc_transport.ConnectRPCService } -func New(info models.ServerInfo) *Server { +func New(info models.SystemInfo) *Server { return &Server{ info: info, startedAt: time.Now(), diff --git a/internal/agent/fingerprint.go b/internal/agent/fingerprint.go new file mode 100644 index 0000000..0732936 --- /dev/null +++ b/internal/agent/fingerprint.go @@ -0,0 +1,40 @@ +package agent + +import ( + "crypto/sha256" + "encoding/hex" + "log/slog" + "os" + "path/filepath" + + "github.com/shirou/gopsutil/v4/host" +) + +func (a *Agent) getFingerprint() string { + // first look for a fingerprint in the data directory + if a.config.DataDir != "" { + if fp, err := os.ReadFile(filepath.Join(a.config.DataDir, "fingerprint")); err == nil { + return string(fp) + } + } + + // if no fingerprint is found, generate one + fingerprint, err := host.HostID() + if err != nil || fingerprint == "" { + fingerprint = a.systemInfo.Hostname + a.systemInfo.CpuModel + } + + // hash fingerprint + sum := sha256.Sum256([]byte(fingerprint)) + fingerprint = hex.EncodeToString(sum[:24]) + + // save fingerprint to data directory + if a.config.DataDir != "" { + err = os.WriteFile(filepath.Join(a.config.DataDir, "fingerprint"), []byte(fingerprint), 0o644) + if err != nil { + slog.Warn("Failed to save fingerprint", "err", err) + } + } + + return fingerprint +} diff --git a/internal/config/agent.go b/internal/config/agent.go index 0391f3c..1a3f074 100644 --- a/internal/config/agent.go +++ b/internal/config/agent.go @@ -1,33 +1,15 @@ package config import ( - "fmt" - "github.com/tkcrm/mx/logger" "github.com/tkcrm/mx/ops" "github.com/tkcrm/mx/transport/connectrpc_transport" ) type ConfigAgent struct { - Log logger.Config - Ops ops.Config - Server connectrpc_transport.Config `yaml:"server"` - Token string `yaml:"token"` -} - -func (c *ConfigAgent) SetDefaults() error { - if c.Log.Level == "" { - c.Log.Level = "info" - } - if c.Server.Addr == "" { - c.Server.Addr = "localhost:9000" - } - return nil -} - -func (c *ConfigAgent) Validate() error { - if c.Token == "" { - return fmt.Errorf("token is required") - } - return nil + Log logger.Config + Ops ops.Config + DataDir string `yaml:"data_dir" default:"./data-agent"` + Server connectrpc_transport.Config `yaml:"server"` + Token string `yaml:"token" validate:"required"` } diff --git a/internal/config/hub.go b/internal/config/hub.go index 6fb7664..380322f 100644 --- a/internal/config/hub.go +++ b/internal/config/hub.go @@ -12,6 +12,7 @@ import ( type ConfigHub struct { Log logger.Config Ops ops.Config + DataDir string `yaml:"data_dir" default:"./data"` Server ServerConfig `yaml:"server"` Monitoring MonitoringConfig `yaml:"monitoring"` Database DatabaseConfig `yaml:"database"` diff --git a/internal/models/info.go b/internal/models/info.go deleted file mode 100644 index ea147c4..0000000 --- a/internal/models/info.go +++ /dev/null @@ -1,19 +0,0 @@ -package models - -type ServerInfo struct { - Version string - CommitHash string - BuildDate string - GoVersion string - SqliteVersion string - OS string - Arch string - AvailableUpdate *AvailableUpdate -} - -type AvailableUpdate struct { - IsAvailableManual bool `json:"is_available_manual"` - TagName string `json:"tag_name"` - URL string `json:"url"` - Description string `json:"description,omitempty"` -} diff --git a/internal/models/system_info.go b/internal/models/system_info.go new file mode 100644 index 0000000..b7dc097 --- /dev/null +++ b/internal/models/system_info.go @@ -0,0 +1,51 @@ +package models + +import ( + "os" + "runtime" + + "github.com/shirou/gopsutil/v4/cpu" + "github.com/shirou/gopsutil/v4/host" +) + +type SystemInfo struct { + Version string + CommitHash string + BuildDate string + GoVersion string + SqliteVersion string + OS string + Arch string + Hostname string + KernelVersion string + CpuModel string + AvailableUpdate *AvailableUpdate +} + +type AvailableUpdate struct { + IsAvailableManual bool `json:"is_available_manual"` + TagName string `json:"tag_name"` + URL string `json:"url"` + Description string `json:"description,omitempty"` +} + +func GetSystemInfo(version, commitHash, buildDate string) SystemInfo { + info := SystemInfo{ + Version: version, + CommitHash: commitHash, + BuildDate: buildDate, + GoVersion: runtime.Version(), + OS: runtime.GOOS, + Arch: runtime.GOARCH, + } + + info.Hostname, _ = os.Hostname() + + if cpuInfo, err := cpu.Info(); err == nil && len(cpuInfo) > 0 { + info.CpuModel = cpuInfo[0].ModelName + } + + info.KernelVersion, _ = host.KernelVersion() + + return info +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 4ec5069..62c0ddd 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -46,7 +46,7 @@ import ( type Server struct { logger logger.Logger - serverInfo models.ServerInfo + serverInfo models.SystemInfo config *config.ConfigHub app *fiber.App @@ -64,7 +64,7 @@ type Server struct { func NewServer( logger logger.Logger, cfg *config.ConfigHub, - serverInfo models.ServerInfo, + serverInfo models.SystemInfo, monitorService *monitor.MonitorService, storage *storage.Storage, receiver *receiver.Receiver, From 49379f4f50d386a9d4de8a2fc48ec43ea6c8e204 Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Thu, 18 Sep 2025 13:14:05 +0300 Subject: [PATCH 04/71] refactor: Consolidate agent and hub commands, remove agent-specific files and configurations --- .gitignore | 1 + Dockerfile | 2 +- Dockerfile-agent | 37 ---------- Makefile | 17 ++--- cmd/agent/config.go | 56 --------------- cmd/agent/main.go | 63 ----------------- cmd/agent/start.go | 97 -------------------------- cmd/agent/version.go | 19 ----- cmd/sentinel/agent.go | 111 ++++++++++++++++++++++++++++++ cmd/sentinel/config.go | 45 ++++++++---- cmd/sentinel/{start.go => hub.go} | 15 +++- cmd/sentinel/main.go | 14 ++-- cmd/testapi/main.go | 3 - config.template.yaml | 2 - docker-compose.local.yml | 2 + docker-compose.yml | 2 + internal/agent/agent.go | 1 + internal/config/hub.go | 18 ++--- 18 files changed, 182 insertions(+), 323 deletions(-) delete mode 100644 Dockerfile-agent delete mode 100644 cmd/agent/config.go delete mode 100644 cmd/agent/main.go delete mode 100644 cmd/agent/start.go delete mode 100644 cmd/agent/version.go create mode 100644 cmd/sentinel/agent.go rename cmd/sentinel/{start.go => hub.go} (87%) diff --git a/.gitignore b/.gitignore index d1dca3b..d282a2b 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ go.work.sum # .vscode/ data +data-agent build config.yaml config-agent.yaml diff --git a/Dockerfile b/Dockerfile index 45e898f..4eddaa0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -54,4 +54,4 @@ WORKDIR /root/ COPY --from=backend-builder /app/bin/sentinel . # Run the binary -CMD ["./sentinel", "start"] +ENTRYPOINT ["./sentinel"] diff --git a/Dockerfile-agent b/Dockerfile-agent deleted file mode 100644 index a9c4918..0000000 --- a/Dockerfile-agent +++ /dev/null @@ -1,37 +0,0 @@ -# Backend build stage -FROM golang:1.25.1-alpine AS backend-builder - -# Define build arguments for version, commit, and date. -ARG VERSION="unknown" -ARG COMMIT_HASH="unknown" -ARG BUILD_DATE="unknown" - -# Install dependencies -RUN apk add --no-cache ca-certificates - -# Set working directory -WORKDIR /app - -# Copy go mod files -COPY go.mod go.sum ./ -RUN go mod download - -# Copy source code -COPY . . - -# Build agent binary -RUN go build -trimpath -ldflags="-w -s -X 'main.version=${VERSION}' -X 'main.commitHash=${COMMIT_HASH}' -X 'main.buildDate=${BUILD_DATE}'" -o bin/sentinel_agent ./cmd/agent - -# Final stage -FROM alpine:latest - -# Install ca-certificates for HTTPS requests -RUN apk --no-cache add ca-certificates tzdata curl - -WORKDIR /root/ - -# Copy agent binary from builder stage -COPY --from=backend-builder /app/bin/sentinel_agent . - -# Run the binary -CMD ["./sentinel_agent", "start"] diff --git a/Makefile b/Makefile index 2aa8fd4..aef95ab 100644 --- a/Makefile +++ b/Makefile @@ -4,8 +4,7 @@ # Variables BINARY_NAME=sentinel -HUB_PATH=./cmd/sentinel -AGENT_PATH=./cmd/agent +SENTINEL_PATH=./cmd/sentinel BUILD_DIR=./build VERSION?=dev LDFLAGS=-ldflags="-w -s -X main.version=${VERSION}" @@ -19,10 +18,10 @@ help: ## Show this help message # Development dev: ## Run in development mode with auto-reload - go run $(HUB_PATH) start + go run $(SENTINEL_PATH) start -c ./config.yaml agent: ## Run in development mode with auto-reload - go run $(AGENT_PATH) start + go run $(SENTINEL_PATH) agent start -c ./config-agent.yaml run: build ## Build and run the application ./$(BUILD_DIR)/$(BINARY_NAME) @@ -77,20 +76,13 @@ format: ## Format code go fmt ./... goimports -w . -docker-push-hub: ## Build and push Docker image +docker-push: ## Build and push Docker image docker buildx build --platform linux/amd64 --push \ --build-arg VERSION=`git describe --tags --abbrev=0 || echo "0.0.0"` \ --build-arg COMMIT=`git rev-parse --short HEAD` \ --build-arg DATE=`date -u +'%Y-%m-%dT%H:%M:%SZ'` \ -t sxwebdev/sentinel:latest . -docker-push-agent: ## Build and push Docker image - docker buildx build --platform linux/amd64 --push -f Dockerfile-agent \ - --build-arg VERSION=`git describe --tags --abbrev=0 || echo "0.0.0"` \ - --build-arg COMMIT=`git rev-parse --short HEAD` \ - --build-arg DATE=`date -u +'%Y-%m-%dT%H:%M:%SZ'` \ - -t sxwebdev/sentinel-agent:latest . - docker-run: ## Run Docker container docker run -d \ --name sentinel \ @@ -147,7 +139,6 @@ genswagger: genenvs: go run ./cmd/sentinel config genenvs - go run ./cmd/agent config genenvs genproto: ## Generate protobuf code buf lint diff --git a/cmd/agent/config.go b/cmd/agent/config.go deleted file mode 100644 index 42c0972..0000000 --- a/cmd/agent/config.go +++ /dev/null @@ -1,56 +0,0 @@ -package main - -import ( - "bytes" - "context" - "fmt" - "os" - - "github.com/goccy/go-yaml" - "github.com/sxwebdev/sentinel/internal/config" - "github.com/sxwebdev/xconfig" - "github.com/urfave/cli/v3" -) - -func cfgPathsFlag() *cli.StringSliceFlag { - return &cli.StringSliceFlag{ - Name: "config", - Aliases: []string{"c"}, - Value: []string{"config-agent.yaml"}, - Usage: "allows you to use your own paths to configuration files. by default it uses config-agent.yaml", - } -} - -func configCMD() *cli.Command { - return &cli.Command{ - Name: "config", - Usage: "validate, gen envs and flags for config", - Commands: []*cli.Command{ - { - Name: "genenvs", - Usage: "generate config yaml template", - Action: func(_ context.Context, _ *cli.Command) error { - conf := new(config.ConfigAgent) - _, err := xconfig.Load(conf, xconfig.WithEnvPrefix(envPrefix)) - if err != nil { - return fmt.Errorf("failed to generate markdown: %w", err) - } - - buf := bytes.NewBuffer(nil) - enc := yaml.NewEncoder(buf, yaml.Indent(2)) - defer enc.Close() - - if err := enc.Encode(conf); err != nil { - return fmt.Errorf("failed to encode yaml: %w", err) - } - - if err := os.WriteFile("config-agent.template.yaml", buf.Bytes(), 0o600); err != nil { - return fmt.Errorf("failed to write file: %w", err) - } - - return nil - }, - }, - }, - } -} diff --git a/cmd/agent/main.go b/cmd/agent/main.go deleted file mode 100644 index 8c4990a..0000000 --- a/cmd/agent/main.go +++ /dev/null @@ -1,63 +0,0 @@ -package main - -import ( - "context" - "fmt" - "os" - "os/signal" - "runtime" - - mxsignal "github.com/tkcrm/mx/util/signal" - "github.com/urfave/cli/v3" - - "github.com/tkcrm/mx/logger" -) - -var ( - appName = "sentinel-agent" - version = "local" - commitHash = "unknown" - buildDate = "unknown" - envPrefix = "SENTINEL_AGENT_" -) - -func getBuildVersion() string { - return fmt.Sprintf( - "\nrelease: %s\ncommit hash: %s\nbuild date: %s\ngo version: %s", - version, - commitHash, - buildDate, - runtime.Version(), - ) -} - -func defaultLoggerOpts() []logger.Option { - return []logger.Option{ - logger.WithAppName(appName), - logger.WithAppVersion(version), - } -} - -func main() { - ctx, cancel := signal.NotifyContext(context.Background(), mxsignal.Shutdown()...) - defer cancel() - - l := logger.NewExtended(defaultLoggerOpts()...) - - app := &cli.Command{ - Name: appName, - Usage: "A CLI application for " + appName, - Version: getBuildVersion(), - Suggest: true, - Commands: []*cli.Command{ - startCMD(), - configCMD(), - versionCMD(), - }, - } - - // run cli runner - if err := app.Run(ctx, os.Args); err != nil { - l.Fatalf("failed to run cli runner: %s", err) - } -} diff --git a/cmd/agent/start.go b/cmd/agent/start.go deleted file mode 100644 index b5cb720..0000000 --- a/cmd/agent/start.go +++ /dev/null @@ -1,97 +0,0 @@ -package main - -import ( - "context" - "fmt" - "net/http" - - "connectrpc.com/connect" - "github.com/sxwebdev/sentinel/internal/agent" - "github.com/sxwebdev/sentinel/internal/agent/agentserver" - "github.com/sxwebdev/sentinel/internal/config" - "github.com/sxwebdev/sentinel/internal/handlerutils" - "github.com/sxwebdev/sentinel/internal/models" - "github.com/tkcrm/mx/launcher" - "github.com/tkcrm/mx/logger" - "github.com/tkcrm/mx/service" - "github.com/tkcrm/mx/service/pingpong" - "github.com/tkcrm/mx/transport/connectrpc_transport" - "github.com/urfave/cli/v3" - "go.akshayshah.org/connectproto" - "golang.org/x/net/http2" - "golang.org/x/net/http2/h2c" - "google.golang.org/protobuf/encoding/protojson" -) - -func startCMD() *cli.Command { - return &cli.Command{ - Name: "start", - Usage: "start the server", - Flags: []cli.Flag{cfgPathsFlag()}, - Action: func(ctx context.Context, cl *cli.Command) error { - conf := new(config.ConfigAgent) - if err := config.Load(conf, envPrefix, cl.StringSlice("config")); err != nil { - return fmt.Errorf("failed to load config: %w", err) - } - - loggerOpts := append(defaultLoggerOpts(), logger.WithConfig(conf.Log)) - - l := logger.NewExtended(loggerOpts...) - defer func() { - _ = l.Sync() - }() - - // init launcher - ln := launcher.New( - launcher.WithVersion(version), - launcher.WithName(appName), - launcher.WithLogger(l), - launcher.WithContext(ctx), - launcher.WithRunnerServicesSequence(launcher.RunnerServicesSequenceFifo), - launcher.WithOpsConfig(conf.Ops), - launcher.WithAppStartStopLog(true), - ) - - serverInfo := models.GetSystemInfo(version, commitHash, buildDate) - - // init agent service - ag := agent.New(l, conf, serverInfo) - - // init agent rpc server - agentServer := agentserver.New(serverInfo) - - rpcServer := connectrpc_transport.NewServer( - connectrpc_transport.WithLogger(l), - connectrpc_transport.WithConfig(conf.Server), - connectrpc_transport.WithServices(agentServer), - connectrpc_transport.WithServerHandlerWrapper( - func(h http.Handler) http.Handler { - return h2c.NewHandler( - handlerutils.WithCORS(h), - &http2.Server{}) - }, - ), - connectrpc_transport.WithReflection( - agentServer.Name(), - ), - connectrpc_transport.WithConnectRPCOptions( - connect.WithHandlerOptions( - connectproto.WithJSON( - protojson.MarshalOptions{EmitUnpopulated: true}, - protojson.UnmarshalOptions{DiscardUnknown: true}, - ), - ), - ), - ) - - // register services - ln.ServicesRunner().Register( - service.New(service.WithService(pingpong.New(l))), - service.New(service.WithService(ag)), - service.New(service.WithService(rpcServer)), - ) - - return ln.Run() - }, - } -} diff --git a/cmd/agent/version.go b/cmd/agent/version.go deleted file mode 100644 index bbc5fa8..0000000 --- a/cmd/agent/version.go +++ /dev/null @@ -1,19 +0,0 @@ -package main - -import ( - "context" - "fmt" - - "github.com/urfave/cli/v3" -) - -func versionCMD() *cli.Command { - return &cli.Command{ - Name: "version", - Usage: "print current version", - Action: func(_ context.Context, _ *cli.Command) error { - fmt.Println(version) - return nil - }, - } -} diff --git a/cmd/sentinel/agent.go b/cmd/sentinel/agent.go new file mode 100644 index 0000000..89566bd --- /dev/null +++ b/cmd/sentinel/agent.go @@ -0,0 +1,111 @@ +package main + +import ( + "context" + "fmt" + "net/http" + "os" + + "connectrpc.com/connect" + "github.com/sxwebdev/sentinel/internal/agent" + "github.com/sxwebdev/sentinel/internal/agent/agentserver" + "github.com/sxwebdev/sentinel/internal/config" + "github.com/sxwebdev/sentinel/internal/handlerutils" + "github.com/sxwebdev/sentinel/internal/models" + "github.com/tkcrm/mx/launcher" + "github.com/tkcrm/mx/logger" + "github.com/tkcrm/mx/service" + "github.com/tkcrm/mx/service/pingpong" + "github.com/tkcrm/mx/transport/connectrpc_transport" + "github.com/urfave/cli/v3" + "go.akshayshah.org/connectproto" + "golang.org/x/net/http2" + "golang.org/x/net/http2/h2c" + "google.golang.org/protobuf/encoding/protojson" +) + +func agentCMD() *cli.Command { + return &cli.Command{ + Name: "agent", + Usage: "agent commands", + Commands: []*cli.Command{ + { + Name: "start", + Usage: "start the agent", + Flags: []cli.Flag{cfgPathsFlag()}, + Action: func(ctx context.Context, cl *cli.Command) error { + conf := new(config.ConfigAgent) + if err := config.Load(conf, envAgentPrefix, cl.StringSlice("config")); err != nil { + return fmt.Errorf("failed to load config: %w", err) + } + + loggerOpts := append(defaultLoggerOpts(), logger.WithConfig(conf.Log)) + + l := logger.NewExtended(loggerOpts...) + defer func() { + _ = l.Sync() + }() + + // init launcher + ln := launcher.New( + launcher.WithVersion(version), + launcher.WithName(appName), + launcher.WithLogger(l), + launcher.WithContext(ctx), + launcher.WithRunnerServicesSequence(launcher.RunnerServicesSequenceFifo), + launcher.WithOpsConfig(conf.Ops), + launcher.WithAppStartStopLog(true), + ) + + // check if exists data dir, if not create it + if _, err := os.Stat(conf.DataDir); os.IsNotExist(err) { + if err := os.MkdirAll(conf.DataDir, 0o700); err != nil { + return fmt.Errorf("failed to create data dir: %w", err) + } + } + + serverInfo := models.GetSystemInfo(version, commitHash, buildDate) + + // init agent service + ag := agent.New(l, conf, serverInfo) + + // init agent rpc server + agentServer := agentserver.New(serverInfo) + + rpcServer := connectrpc_transport.NewServer( + connectrpc_transport.WithLogger(l), + connectrpc_transport.WithConfig(conf.Server), + connectrpc_transport.WithServices(agentServer), + connectrpc_transport.WithServerHandlerWrapper( + func(h http.Handler) http.Handler { + return h2c.NewHandler( + handlerutils.WithCORS(h), + &http2.Server{}) + }, + ), + connectrpc_transport.WithReflection( + agentServer.Name(), + ), + connectrpc_transport.WithConnectRPCOptions( + connect.WithHandlerOptions( + connectproto.WithJSON( + protojson.MarshalOptions{EmitUnpopulated: true}, + protojson.UnmarshalOptions{DiscardUnknown: true}, + ), + ), + ), + ) + + // register services + ln.ServicesRunner().Register( + service.New(service.WithService(pingpong.New(l))), + service.New(service.WithService(ag)), + service.New(service.WithService(rpcServer)), + ) + + return ln.Run() + }, + }, + }, + } +} diff --git a/cmd/sentinel/config.go b/cmd/sentinel/config.go index 84afabf..064cdbc 100644 --- a/cmd/sentinel/config.go +++ b/cmd/sentinel/config.go @@ -16,8 +16,7 @@ func cfgPathsFlag() *cli.StringSliceFlag { return &cli.StringSliceFlag{ Name: "config", Aliases: []string{"c"}, - Value: []string{"config.yaml"}, - Usage: "allows you to use your own paths to configuration files. by default it uses config.yaml", + Usage: "allows you to use your own paths to configuration files", } } @@ -30,22 +29,40 @@ func configCMD() *cli.Command { Name: "genenvs", Usage: "generate config yaml template", Action: func(_ context.Context, _ *cli.Command) error { - conf := new(config.ConfigHub) - _, err := xconfig.Load(conf, xconfig.WithEnvPrefix(envPrefix)) - if err != nil { - return fmt.Errorf("failed to generate markdown: %w", err) + data := []struct { + fileName string + envPrefix string + conf interface{} + }{ + { + fileName: "config.template.yaml", + envPrefix: envHubPrefix, + conf: new(config.ConfigHub), + }, + { + fileName: "config-agent.template.yaml", + envPrefix: envAgentPrefix, + conf: new(config.ConfigAgent), + }, } - buf := bytes.NewBuffer(nil) - enc := yaml.NewEncoder(buf, yaml.Indent(2)) - defer enc.Close() + for _, d := range data { + _, err := xconfig.Load(d.conf, xconfig.WithEnvPrefix(d.envPrefix)) + if err != nil { + return fmt.Errorf("failed to generate markdown: %w", err) + } - if err := enc.Encode(conf); err != nil { - return fmt.Errorf("failed to encode yaml: %w", err) - } + buf := bytes.NewBuffer(nil) + enc := yaml.NewEncoder(buf, yaml.Indent(2)) + defer enc.Close() + + if err := enc.Encode(d.conf); err != nil { + return fmt.Errorf("failed to encode yaml: %w", err) + } - if err := os.WriteFile("config.template.yaml", buf.Bytes(), 0o600); err != nil { - return fmt.Errorf("failed to write file: %w", err) + if err := os.WriteFile(d.fileName, buf.Bytes(), 0o600); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } } return nil diff --git a/cmd/sentinel/start.go b/cmd/sentinel/hub.go similarity index 87% rename from cmd/sentinel/start.go rename to cmd/sentinel/hub.go index 09d589c..d164b08 100644 --- a/cmd/sentinel/start.go +++ b/cmd/sentinel/hub.go @@ -3,6 +3,8 @@ package main import ( "context" "fmt" + "os" + "path/filepath" "time" "github.com/sxwebdev/sentinel/internal/config" @@ -21,14 +23,14 @@ import ( "github.com/urfave/cli/v3" ) -func startCMD() *cli.Command { +func hubStartCMD() *cli.Command { return &cli.Command{ Name: "start", Usage: "start the server", Flags: []cli.Flag{cfgPathsFlag()}, Action: func(ctx context.Context, cl *cli.Command) error { conf := new(config.ConfigHub) - if err := config.Load(conf, envPrefix, cl.StringSlice("config")); err != nil { + if err := config.Load(conf, envHubPrefix, cl.StringSlice("config")); err != nil { return fmt.Errorf("failed to load config: %w", err) } @@ -50,6 +52,13 @@ func startCMD() *cli.Command { launcher.WithAppStartStopLog(true), ) + // check if exists data dir, if not create it + if _, err := os.Stat(conf.DataDir); os.IsNotExist(err) { + if err := os.MkdirAll(conf.DataDir, 0o700); err != nil { + return fmt.Errorf("failed to create data dir: %w", err) + } + } + // set default timezone var err error time.Local, err = time.LoadLocation(conf.Timezone) @@ -58,7 +67,7 @@ func startCMD() *cli.Command { } // Initialize storage - store, err := storage.New(conf.Database.Path) + store, err := storage.New(filepath.Join(conf.DataDir, "db.sqlite")) if err != nil { return fmt.Errorf("failed to initialize storage: %w", err) } diff --git a/cmd/sentinel/main.go b/cmd/sentinel/main.go index 78c2c2b..a0454d1 100644 --- a/cmd/sentinel/main.go +++ b/cmd/sentinel/main.go @@ -14,11 +14,12 @@ import ( ) var ( - appName = "sentinel" - version = "local" - commitHash = "unknown" - buildDate = "unknown" - envPrefix = "SENTINEL_" + appName = "sentinel" + version = "local" + commitHash = "unknown" + buildDate = "unknown" + envHubPrefix = "SENTINEL_" + envAgentPrefix = "SENTINEL_AGENT_" ) func getBuildVersion() string { @@ -50,7 +51,8 @@ func main() { Version: getBuildVersion(), Suggest: true, Commands: []*cli.Command{ - startCMD(), + hubStartCMD(), + agentCMD(), configCMD(), versionCMD(), }, diff --git a/cmd/testapi/main.go b/cmd/testapi/main.go index f468df8..a270925 100644 --- a/cmd/testapi/main.go +++ b/cmd/testapi/main.go @@ -205,9 +205,6 @@ func setupTestSuite() (*TestSuite, error) { // Load config cfg := &config.ConfigHub{ - Database: config.DatabaseConfig{ - Path: dbPath, - }, Monitoring: config.MonitoringConfig{ Global: config.GlobalConfig{ DefaultInterval: time.Minute, diff --git a/config.template.yaml b/config.template.yaml index dc9bebb..4e845ca 100644 --- a/config.template.yaml +++ b/config.template.yaml @@ -42,8 +42,6 @@ monitoring: default_interval: 1m0s default_timeout: 10s default_retries: 10 -database: - path: ./data/db.sqlite notifications: enabled: false urls: [] diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 7bfb380..fd22436 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -1,5 +1,6 @@ services: sentinel: + container_name: sentinel build: context: . dockerfile: Dockerfile @@ -7,6 +8,7 @@ services: VERSION: local-dev COMMIT_HASH: local BUILD_DATE: local + command: ["start", "--config", "./config.yaml"] ports: - "8080:8080" volumes: diff --git a/docker-compose.yml b/docker-compose.yml index 3349c6c..f81fabc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: sentinel: + container_name: sentinel image: sxwebdev/sentinel:latest + command: ["start", "--config", "./config.yaml"] ports: - "8080:8080" volumes: diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 47d920e..11de077 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -40,6 +40,7 @@ func (a *Agent) Name() string { // Start starts the agent func (a *Agent) Start(_ context.Context) error { // Placeholder for starting agent logic + _ = a.getFingerprint() return nil } diff --git a/internal/config/hub.go b/internal/config/hub.go index 380322f..cbf9926 100644 --- a/internal/config/hub.go +++ b/internal/config/hub.go @@ -10,12 +10,12 @@ import ( // ConfigHub represents the main configuration structure type ConfigHub struct { - Log logger.Config - Ops ops.Config - DataDir string `yaml:"data_dir" default:"./data"` - Server ServerConfig `yaml:"server"` - Monitoring MonitoringConfig `yaml:"monitoring"` - Database DatabaseConfig `yaml:"database"` + Log logger.Config + Ops ops.Config + DataDir string `yaml:"data_dir" default:"./data"` + Server ServerConfig `yaml:"server"` + Monitoring MonitoringConfig `yaml:"monitoring"` + // Database DatabaseConfig `yaml:"database"` Notifications NotificationsConfig `yaml:"notifications"` Timezone string `yaml:"timezone" default:"UTC"` Upgrader Upgrader `yaml:"upgrader"` @@ -61,9 +61,9 @@ type GlobalConfig struct { } // DatabaseConfig holds database settings -type DatabaseConfig struct { - Path string `yaml:"path" default:"./data/db.sqlite"` -} +// type DatabaseConfig struct { +// Path string `yaml:"path" default:"./data/db.sqlite"` +// } // NotificationsConfig holds notification settings for multiple providers type NotificationsConfig struct { From 9ad0bb93fd70b1c887146a4cd7d599cf9bd8ccc6 Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Fri, 19 Sep 2025 00:22:48 +0300 Subject: [PATCH 05/71] feat: added migrations library, use sqlc and pgxgen, generate repositories, rewrited services and added agents repo --- Makefile | 14 ++ cmd/sentinel/config.go | 2 +- cmd/sentinel/hub.go | 31 +++- cmd/sentinel/main.go | 2 + cmd/sentinel/migrations.go | 18 ++ cmd/testapi/extended_tests.go | 24 +-- cmd/testapi/main.go | 2 +- cmd/testapi/model_tests.go | 42 ++--- go.mod | 2 + go.sum | 28 ++++ internal/config/hub.go | 2 +- internal/models/models_gen.go | 69 ++++++++ internal/models/service.go | 61 +++++++ internal/monitor/monitor.go | 139 ++++++++-------- internal/monitors/config.go | 19 ++- internal/monitors/grpc.go | 13 +- internal/monitors/http.go | 19 ++- internal/monitors/monitor.go | 24 +-- internal/monitors/tcp.go | 14 +- internal/receiver/receiver.go | 6 +- internal/scheduler/job.go | 22 +++ internal/scheduler/scheduler.go | 59 +++---- internal/services/baseservices/base.go | 27 +++ internal/services/service/methods.go | 156 ++++++++++++++++++ internal/services/service/service.go | 18 ++ internal/storage/incidents.go | 2 +- internal/storage/migrations.go | 149 ----------------- internal/storage/models.go | 7 - internal/storage/services.go | 4 +- internal/storage/storage.go | 28 +++- internal/store/repos/options.go | 24 +++ .../store/repos/repo_agents/agents_gen.sql.go | 137 +++++++++++++++ .../store/repos/repo_agents/constants_gen.go | 75 +++++++++ internal/store/repos/repo_agents/db.go | 31 ++++ internal/store/repos/repo_agents/querier.go | 20 +++ .../repos/repo_incidents/constants_gen.go | 65 ++++++++ internal/store/repos/repo_incidents/db.go | 31 ++++ .../repos/repo_incidents/incidents.sql.go | 19 +++ .../repos/repo_incidents/incidents_gen.sql.go | 121 ++++++++++++++ .../store/repos/repo_incidents/querier.go | 21 +++ .../repo_service_states/constants_gen.go | 71 ++++++++ .../store/repos/repo_service_states/db.go | 31 ++++ .../repos/repo_service_states/querier.go | 20 +++ .../repo_service_states/service_states.sql.go | 19 +++ .../service_states_gen.sql.go | 96 +++++++++++ .../repos/repo_services/constants_gen.go | 69 ++++++++ internal/store/repos/repo_services/custom.go | 37 +++++ internal/store/repos/repo_services/db.go | 31 ++++ internal/store/repos/repo_services/find.go | 154 +++++++++++++++++ internal/store/repos/repo_services/get.go | 62 +++++++ internal/store/repos/repo_services/querier.go | 20 +++ .../store/repos/repo_services/services.sql.go | 21 +++ .../repos/repo_services/services_gen.sql.go | 92 +++++++++++ internal/store/repos/repo_services/types.go | 95 +++++++++++ internal/store/repos/repo_services/update.go | 71 ++++++++ internal/store/repos/repos.go | 70 ++++++++ internal/store/store.go | 22 +++ internal/store/storecmn/errors.go | 8 + internal/utils/ulid.go | 8 + internal/web/dto.go | 77 +++++---- internal/web/handlers.go | 92 ++++++++--- internal/web/helpers.go | 14 +- pgxgen.yaml | 105 ++++++++++++ pkg/dbutils/duration.go | 58 +++++++ pkg/dbutils/json.go | 34 ++++ pkg/dbutils/tx.go | 21 +++ pkg/migrations/apply.go | 67 ++++++++ pkg/migrations/cli.go | 92 +++++++++++ pkg/migrations/db.go | 17 ++ pkg/migrations/down.go | 45 +++++ pkg/migrations/migrations.go | 107 ++++++++++++ pkg/migrations/schema.go | 21 +++ pkg/migrations/types.go | 13 ++ pkg/migrations/up.go | 54 ++++++ pkg/migrations/utils.go | 32 ++++ pkg/sqlite/sqlite.go | 49 ++++++ pkg/sqlite/svc.go | 20 +++ pkg/sqlite/version.go | 16 ++ sql/emded.go | 10 ++ sql/migrations/1_init_repo.down.sql | 3 + sql/migrations/1_init_repo.up.sql | 58 +++++++ sql/migrations/2_agents.down.sql | 1 + sql/migrations/2_agents.up.sql | 23 +++ sql/queries/agents/agents_gen.sql | 14 ++ sql/queries/incidents/incidents.sql | 2 + sql/queries/incidents/incidents_gen.sql | 14 ++ sql/queries/service_states/service_states.sql | 2 + .../service_states/service_states_gen.sql | 11 ++ sql/queries/services/services.sql | 2 + sql/queries/services/services_gen.sql | 11 ++ sqlc.yaml | 105 ++++++++++++ 91 files changed, 3321 insertions(+), 413 deletions(-) create mode 100644 cmd/sentinel/migrations.go create mode 100644 internal/models/models_gen.go create mode 100644 internal/models/service.go create mode 100644 internal/scheduler/job.go create mode 100644 internal/services/baseservices/base.go create mode 100644 internal/services/service/methods.go create mode 100644 internal/services/service/service.go delete mode 100644 internal/storage/migrations.go create mode 100644 internal/store/repos/options.go create mode 100644 internal/store/repos/repo_agents/agents_gen.sql.go create mode 100755 internal/store/repos/repo_agents/constants_gen.go create mode 100644 internal/store/repos/repo_agents/db.go create mode 100644 internal/store/repos/repo_agents/querier.go create mode 100755 internal/store/repos/repo_incidents/constants_gen.go create mode 100644 internal/store/repos/repo_incidents/db.go create mode 100644 internal/store/repos/repo_incidents/incidents.sql.go create mode 100644 internal/store/repos/repo_incidents/incidents_gen.sql.go create mode 100644 internal/store/repos/repo_incidents/querier.go create mode 100755 internal/store/repos/repo_service_states/constants_gen.go create mode 100644 internal/store/repos/repo_service_states/db.go create mode 100644 internal/store/repos/repo_service_states/querier.go create mode 100644 internal/store/repos/repo_service_states/service_states.sql.go create mode 100644 internal/store/repos/repo_service_states/service_states_gen.sql.go create mode 100755 internal/store/repos/repo_services/constants_gen.go create mode 100644 internal/store/repos/repo_services/custom.go create mode 100644 internal/store/repos/repo_services/db.go create mode 100644 internal/store/repos/repo_services/find.go create mode 100644 internal/store/repos/repo_services/get.go create mode 100644 internal/store/repos/repo_services/querier.go create mode 100644 internal/store/repos/repo_services/services.sql.go create mode 100644 internal/store/repos/repo_services/services_gen.sql.go create mode 100644 internal/store/repos/repo_services/types.go create mode 100644 internal/store/repos/repo_services/update.go create mode 100644 internal/store/repos/repos.go create mode 100644 internal/store/store.go create mode 100644 internal/store/storecmn/errors.go create mode 100644 internal/utils/ulid.go create mode 100644 pgxgen.yaml create mode 100644 pkg/dbutils/duration.go create mode 100644 pkg/dbutils/json.go create mode 100644 pkg/dbutils/tx.go create mode 100644 pkg/migrations/apply.go create mode 100644 pkg/migrations/cli.go create mode 100644 pkg/migrations/db.go create mode 100644 pkg/migrations/down.go create mode 100644 pkg/migrations/migrations.go create mode 100644 pkg/migrations/schema.go create mode 100644 pkg/migrations/types.go create mode 100644 pkg/migrations/up.go create mode 100644 pkg/migrations/utils.go create mode 100644 pkg/sqlite/sqlite.go create mode 100644 pkg/sqlite/svc.go create mode 100644 pkg/sqlite/version.go create mode 100644 sql/emded.go create mode 100644 sql/migrations/1_init_repo.down.sql create mode 100644 sql/migrations/1_init_repo.up.sql create mode 100644 sql/migrations/2_agents.down.sql create mode 100644 sql/migrations/2_agents.up.sql create mode 100755 sql/queries/agents/agents_gen.sql create mode 100755 sql/queries/incidents/incidents.sql create mode 100755 sql/queries/incidents/incidents_gen.sql create mode 100755 sql/queries/service_states/service_states.sql create mode 100755 sql/queries/service_states/service_states_gen.sql create mode 100644 sql/queries/services/services.sql create mode 100755 sql/queries/services/services_gen.sql create mode 100644 sqlc.yaml diff --git a/Makefile b/Makefile index aef95ab..26d29fa 100644 --- a/Makefile +++ b/Makefile @@ -8,6 +8,7 @@ SENTINEL_PATH=./cmd/sentinel BUILD_DIR=./build VERSION?=dev LDFLAGS=-ldflags="-w -s -X main.version=${VERSION}" +MIGRATIONS_DIR = ./sql/migrations/ # Default target help: ## Show this help message @@ -122,6 +123,12 @@ clean: ## Clean build artifacts init-db: ## Initialize database directory mkdir -p data +# db-create-migration: +# migrate create -ext sql -format unix -dir "$(MIGRATIONS_DIR)" $(filter-out $@,$(MAKECMDGOALS)) + +db-create-migration: + go run ./cmd/sentinel migrations create -path ./sql/migrations -name $(filter-out $@,$(MAKECMDGOALS)) + # Configuration init-config: ## Copy example configuration cp config.yaml.example config.yaml || echo "config.yaml already exists" @@ -140,6 +147,10 @@ genswagger: genenvs: go run ./cmd/sentinel config genenvs +gensql: + pgxgen crud + pgxgen sqlc generate + genproto: ## Generate protobuf code buf lint rm -rf ./internal/agent/api/* @@ -147,3 +158,6 @@ genproto: ## Generate protobuf code grpcui-agent: grpcui --plaintext localhost:9000 + +%: + @: diff --git a/cmd/sentinel/config.go b/cmd/sentinel/config.go index 064cdbc..b628e32 100644 --- a/cmd/sentinel/config.go +++ b/cmd/sentinel/config.go @@ -32,7 +32,7 @@ func configCMD() *cli.Command { data := []struct { fileName string envPrefix string - conf interface{} + conf any }{ { fileName: "config.template.yaml", diff --git a/cmd/sentinel/hub.go b/cmd/sentinel/hub.go index d164b08..9186791 100644 --- a/cmd/sentinel/hub.go +++ b/cmd/sentinel/hub.go @@ -13,9 +13,12 @@ import ( "github.com/sxwebdev/sentinel/internal/notifier" "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/scheduler" + "github.com/sxwebdev/sentinel/internal/services/baseservices" "github.com/sxwebdev/sentinel/internal/storage" + "github.com/sxwebdev/sentinel/internal/store" "github.com/sxwebdev/sentinel/internal/upgrader" "github.com/sxwebdev/sentinel/internal/web" + "github.com/sxwebdev/sentinel/pkg/sqlite" "github.com/tkcrm/mx/launcher" "github.com/tkcrm/mx/logger" "github.com/tkcrm/mx/service" @@ -66,14 +69,27 @@ func hubStartCMD() *cli.Command { return fmt.Errorf("failed to set timezone: %w", err) } + dbPath := filepath.Join(conf.DataDir, sqliteDBFile) + + // init sqlite + db, err := sqlite.New(ctx, dbPath) + if err != nil { + return fmt.Errorf("failed to initialize sqlite: %w", err) + } + + st, err := store.New(db.DB) + if err != nil { + return fmt.Errorf("failed to initialize store: %w", err) + } + // Initialize storage - store, err := storage.New(filepath.Join(conf.DataDir, "db.sqlite")) + storage, err := storage.New(l, dbPath) if err != nil { return fmt.Errorf("failed to initialize storage: %w", err) } // Print SQLite version if using SQLite storage - sqliteVersion, err := store.GetSQLiteVersion(ctx) + sqliteVersion, err := db.GetSQLiteVersion(ctx) if err != nil { return fmt.Errorf("failed to get SQLite version: %w", err) } @@ -97,16 +113,18 @@ func hubStartCMD() *cli.Command { return fmt.Errorf("failed to initialize upgrader: %w", err) } + baseServices := baseservices.New(st, rc) + // Create monitor service - monitorService := monitor.NewMonitorService(store, conf, notif, rc) + monitorService := monitor.NewMonitorService(st, storage, conf, notif, rc) // Initialize scheduler - sched := scheduler.New(l, monitorService, rc) + sched := scheduler.New(l, monitorService, rc, baseServices) serverInfo := models.GetSystemInfo(version, commitHash, buildDate) serverInfo.SqliteVersion = sqliteVersion - webServer, err := web.NewServer(l, conf, serverInfo, monitorService, store, rc, upgr) + webServer, err := web.NewServer(l, conf, serverInfo, baseServices, monitorService, storage, rc, upgr) if err != nil { return fmt.Errorf("failed to initialize web server: %w", err) } @@ -114,7 +132,8 @@ func hubStartCMD() *cli.Command { // register services ln.ServicesRunner().Register( service.New(service.WithService(pingpong.New(l))), - service.New(service.WithService(store)), + service.New(service.WithService(db)), + service.New(service.WithService(storage)), service.New(service.WithService(rc)), service.New(service.WithService(sched)), service.New(service.WithService(webServer)), diff --git a/cmd/sentinel/main.go b/cmd/sentinel/main.go index a0454d1..725cee4 100644 --- a/cmd/sentinel/main.go +++ b/cmd/sentinel/main.go @@ -20,6 +20,7 @@ var ( buildDate = "unknown" envHubPrefix = "SENTINEL_" envAgentPrefix = "SENTINEL_AGENT_" + sqliteDBFile = "db.sqlite" ) func getBuildVersion() string { @@ -54,6 +55,7 @@ func main() { hubStartCMD(), agentCMD(), configCMD(), + migrationsCMD(), versionCMD(), }, } diff --git a/cmd/sentinel/migrations.go b/cmd/sentinel/migrations.go new file mode 100644 index 0000000..2140a93 --- /dev/null +++ b/cmd/sentinel/migrations.go @@ -0,0 +1,18 @@ +package main + +import ( + "github.com/sxwebdev/sentinel/pkg/migrations" + "github.com/sxwebdev/sentinel/sql" + "github.com/tkcrm/mx/logger" + "github.com/urfave/cli/v3" +) + +func migrationsCMD() *cli.Command { + opts := append( + defaultLoggerOpts(), + logger.WithConsoleColored(true), + logger.WithLogFormat(logger.LoggerFormatConsole), + ) + l := logger.NewExtended(opts...) + return migrations.CliCmd(l, sql.MigrationsFS, sql.MigrationsPath) +} diff --git a/cmd/testapi/extended_tests.go b/cmd/testapi/extended_tests.go index dcb3a7e..de555e8 100644 --- a/cmd/testapi/extended_tests.go +++ b/cmd/testapi/extended_tests.go @@ -7,8 +7,8 @@ import ( "net/url" "time" + "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/monitors" - "github.com/sxwebdev/sentinel/internal/storage" "github.com/sxwebdev/sentinel/internal/web" "github.com/sxwebdev/sentinel/pkg/dbutils" ) @@ -55,7 +55,7 @@ func testAdvancedServiceFilters(s *TestSuite) error { } for _, service := range result.Items { - if service.Status != storage.StatusUp { + if service.Status != models.StatusUp { return fmt.Errorf("service %s status is not 'up'", service.Name) } } @@ -82,7 +82,7 @@ func testServiceCRUDCompleteFlow(s *TestSuite) error { // Create a new service with complex HTTP config complexHTTPService := web.CreateUpdateServiceRequest{ Name: "Complex HTTP Service", - Protocol: storage.ServiceProtocolTypeHTTP, + Protocol: models.ServiceProtocolTypeHTTP, Interval: 15000, // 15s Timeout: 10000, // 10s Retries: 2, @@ -277,7 +277,7 @@ func testAdvancedIncidentManagement(s *TestSuite) error { // Create a dedicated service for incident testing testService := web.CreateUpdateServiceRequest{ Name: "Incident Management Test Service", - Protocol: storage.ServiceProtocolTypeHTTP, + Protocol: models.ServiceProtocolTypeHTTP, Interval: 30000, Timeout: 5000, Retries: 3, @@ -434,7 +434,7 @@ func testCompleteProtocolConfigurations(s *TestSuite) error { // Test TCP service with advanced configuration tcpService := web.CreateUpdateServiceRequest{ Name: "Advanced TCP Service", - Protocol: storage.ServiceProtocolTypeTCP, + Protocol: models.ServiceProtocolTypeTCP, Interval: 20000, Timeout: 8000, Retries: 2, @@ -474,7 +474,7 @@ func testCompleteProtocolConfigurations(s *TestSuite) error { // Test gRPC service with advanced configuration grpcService := web.CreateUpdateServiceRequest{ Name: "Advanced gRPC Service", - Protocol: storage.ServiceProtocolTypeGRPC, + Protocol: models.ServiceProtocolTypeGRPC, Interval: 25000, Timeout: 10000, Retries: 3, @@ -519,10 +519,10 @@ func testCompleteProtocolConfigurations(s *TestSuite) error { // Test both services individually services := []struct { id string - protocol storage.ServiceProtocolType + protocol models.ServiceProtocolType }{ - {tcpServiceID, storage.ServiceProtocolTypeTCP}, - {grpcServiceID, storage.ServiceProtocolTypeGRPC}, + {tcpServiceID, models.ServiceProtocolTypeTCP}, + {grpcServiceID, models.ServiceProtocolTypeGRPC}, } for _, svc := range services { @@ -589,7 +589,7 @@ func testAdvancedPaginationAndSorting(s *TestSuite) error { for i := 0; i < 10; i++ { service := web.CreateUpdateServiceRequest{ Name: fmt.Sprintf("Pagination Test Service %02d", i), - Protocol: storage.ServiceProtocolTypeHTTP, + Protocol: models.ServiceProtocolTypeHTTP, Interval: 30000, Timeout: 5000, Retries: 3, @@ -786,7 +786,7 @@ func testAdvancedErrorScenarios(s *TestSuite) error { // UPDATE non-existent service updateReq := web.CreateUpdateServiceRequest{ Name: "Updated Service", - Protocol: storage.ServiceProtocolTypeHTTP, + Protocol: models.ServiceProtocolTypeHTTP, Interval: 60000, Timeout: 10000, Retries: 3, @@ -872,7 +872,7 @@ func testStatsWithDifferentParameters(s *TestSuite) error { // Create a dedicated service for stats testing testService := web.CreateUpdateServiceRequest{ Name: "Stats Test Service", - Protocol: storage.ServiceProtocolTypeHTTP, + Protocol: models.ServiceProtocolTypeHTTP, Interval: 30000, Timeout: 5000, Retries: 3, diff --git a/cmd/testapi/main.go b/cmd/testapi/main.go index a270925..7ab1aae 100644 --- a/cmd/testapi/main.go +++ b/cmd/testapi/main.go @@ -223,7 +223,7 @@ func setupTestSuite() (*TestSuite, error) { l := logger.Default() // Initialize storage - stor, err := storage.New(dbPath) + stor, err := storage.New(l, dbPath) if err != nil { return nil, fmt.Errorf("failed to initialize storage: %w", err) } diff --git a/cmd/testapi/model_tests.go b/cmd/testapi/model_tests.go index b0d8275..21de5d6 100644 --- a/cmd/testapi/model_tests.go +++ b/cmd/testapi/model_tests.go @@ -4,8 +4,8 @@ import ( "fmt" "reflect" + "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/monitors" - "github.com/sxwebdev/sentinel/internal/storage" "github.com/sxwebdev/sentinel/internal/web" "github.com/sxwebdev/sentinel/pkg/dbutils" ) @@ -37,7 +37,7 @@ func testModelsValidation(s *TestSuite) error { }, } - if err := httpConfig.Validate(storage.ServiceProtocolTypeHTTP); err != nil { + if err := httpConfig.Validate(models.ServiceProtocolTypeHTTP); err != nil { return fmt.Errorf("valid HTTP config should not fail validation: %w", err) } @@ -49,7 +49,7 @@ func testModelsValidation(s *TestSuite) error { }, } - if err := invalidHTTPConfig.Validate(storage.ServiceProtocolTypeHTTP); err == nil { + if err := invalidHTTPConfig.Validate(models.ServiceProtocolTypeHTTP); err == nil { return fmt.Errorf("HTTP config with empty endpoints should fail validation") } @@ -68,7 +68,7 @@ func testModelsValidation(s *TestSuite) error { }, } - if err := invalidEndpointConfig.Validate(storage.ServiceProtocolTypeHTTP); err == nil { + if err := invalidEndpointConfig.Validate(models.ServiceProtocolTypeHTTP); err == nil { return fmt.Errorf("HTTP config with invalid endpoint should fail validation") } @@ -81,7 +81,7 @@ func testModelsValidation(s *TestSuite) error { }, } - if err := tcpConfig.Validate(storage.ServiceProtocolTypeTCP); err != nil { + if err := tcpConfig.Validate(models.ServiceProtocolTypeTCP); err != nil { return fmt.Errorf("valid TCP config should not fail validation: %w", err) } @@ -92,7 +92,7 @@ func testModelsValidation(s *TestSuite) error { }, } - if err := invalidTCPConfig.Validate(storage.ServiceProtocolTypeTCP); err == nil { + if err := invalidTCPConfig.Validate(models.ServiceProtocolTypeTCP); err == nil { return fmt.Errorf("TCP config with empty endpoint should fail validation") } @@ -107,7 +107,7 @@ func testModelsValidation(s *TestSuite) error { }, } - if err := grpcConfig.Validate(storage.ServiceProtocolTypeGRPC); err != nil { + if err := grpcConfig.Validate(models.ServiceProtocolTypeGRPC); err != nil { return fmt.Errorf("valid gRPC config should not fail validation: %w", err) } @@ -119,7 +119,7 @@ func testModelsValidation(s *TestSuite) error { }, } - if err := invalidGRPCConfig.Validate(storage.ServiceProtocolTypeGRPC); err == nil { + if err := invalidGRPCConfig.Validate(models.ServiceProtocolTypeGRPC); err == nil { return fmt.Errorf("gRPC config with empty endpoint should fail validation") } @@ -138,7 +138,7 @@ func testModelsValidation(s *TestSuite) error { }, } - if err := httpConfigForTCP.Validate(storage.ServiceProtocolTypeTCP); err == nil { + if err := httpConfigForTCP.Validate(models.ServiceProtocolTypeTCP); err == nil { return fmt.Errorf("HTTP config should fail validation for TCP protocol") } @@ -149,7 +149,7 @@ func testServiceDTOFields(s *TestSuite) error { // Create a test service to validate DTO conversion testService := web.CreateUpdateServiceRequest{ Name: "DTO Test Service", - Protocol: storage.ServiceProtocolTypeHTTP, + Protocol: models.ServiceProtocolTypeHTTP, Interval: 30000, Timeout: 5000, Retries: 3, @@ -252,11 +252,11 @@ func testServiceDTOFields(s *TestSuite) error { } // Status should be one of the valid values - validStatuses := []storage.ServiceStatus{ - storage.StatusUnknown, - storage.StatusUp, - storage.StatusDown, - storage.StatusMaintenance, + validStatuses := []models.ServiceStatus{ + models.StatusUnknown, + models.StatusUp, + models.StatusDown, + models.StatusMaintenance, } isValidStatus := false for _, validStatus := range validStatuses { @@ -353,7 +353,7 @@ func testResponseModels(s *TestSuite) error { // Create a test service first to ensure we have a valid service ID createReq := web.CreateUpdateServiceRequest{ Name: "Test Service for Response Models", - Protocol: storage.ServiceProtocolTypeHTTP, + Protocol: models.ServiceProtocolTypeHTTP, Interval: 60000, Timeout: 10000, Retries: 3, @@ -446,10 +446,10 @@ func testResponseModels(s *TestSuite) error { // Protocols map should contain valid protocols for protocol, count := range stats.Protocols { - validProtocols := []storage.ServiceProtocolType{ - storage.ServiceProtocolTypeHTTP, - storage.ServiceProtocolTypeTCP, - storage.ServiceProtocolTypeGRPC, + validProtocols := []models.ServiceProtocolType{ + models.ServiceProtocolTypeHTTP, + models.ServiceProtocolTypeTCP, + models.ServiceProtocolTypeGRPC, } isValid := false for _, validProtocol := range validProtocols { @@ -473,7 +473,7 @@ func testServiceStatsModel(s *TestSuite) error { // Create a dedicated service for stats model testing testService := web.CreateUpdateServiceRequest{ Name: "Stats Model Test Service", - Protocol: storage.ServiceProtocolTypeHTTP, + Protocol: models.ServiceProtocolTypeHTTP, Interval: 30000, Timeout: 5000, Retries: 3, diff --git a/go.mod b/go.mod index 5eb6030..9fc7ece 100644 --- a/go.mod +++ b/go.mod @@ -9,7 +9,9 @@ require ( github.com/containrrr/shoutrrr v0.8.0 github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 github.com/dromara/carbon/v2 v2.6.12 + github.com/georgysavva/scany/v2 v2.1.4 github.com/go-playground/validator/v10 v10.27.0 + github.com/gobeam/stringy v0.0.7 github.com/goccy/go-yaml v1.18.0 github.com/gofiber/contrib/websocket v1.3.4 github.com/gofiber/fiber/v2 v2.52.9 diff --git a/go.sum b/go.sum index e352095..6cb455b 100644 --- a/go.sum +++ b/go.sum @@ -19,6 +19,8 @@ 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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs= +github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec= github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= @@ -44,6 +46,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0= github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/georgysavva/scany/v2 v2.1.4 h1:nrzHEJ4oQVRoiKmocRqA1IyGOmM/GQOEsg9UjMR5Ip4= +github.com/georgysavva/scany/v2 v2.1.4/go.mod h1:fqp9yHZzM/PFVa3/rYEC57VmDx+KDch0LoqrJzkvtos= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -100,6 +104,8 @@ github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TC github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gobeam/stringy v0.0.7 h1:TD8SfhedUoiANhW88JlJqfrMsihskIRpU/VTsHGnAps= +github.com/gobeam/stringy v0.0.7/go.mod h1:W3620X9dJHf2FSZF5fRnWekHcHQjwmCz8ZQ2d1qloqE= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gofiber/contrib/websocket v1.3.4 h1:tWeBdbJ8q0WFQXariLN4dBIbGH9KBU75s0s7YXplOSg= @@ -107,6 +113,12 @@ github.com/gofiber/contrib/websocket v1.3.4/go.mod h1:kTFBPC6YENCnKfKx0BoOFjgXxd github.com/gofiber/fiber/v2 v2.32.0/go.mod h1:CMy5ZLiXkn6qwthrl03YMyW1NLfj0rhxz2LKl4t7ZTY= github.com/gofiber/fiber/v2 v2.52.9 h1:YjKl5DOiyP3j0mO61u3NTmK7or8GzzWzCFzkboyP5cw= github.com/gofiber/fiber/v2 v2.52.9/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/gofrs/flock v0.8.1 h1:+gYjHKf32LDeiEEFhQaotPbLuUXjY5ZqxKgXy7n59aw= +github.com/gofrs/flock v0.8.1/go.mod h1:F1TvTiK9OcQqauNUHlbJvyl9Qa1QvF/gOUDKA14jxHU= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA= +github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= +github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A= +github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -126,6 +138,14 @@ github.com/huandu/go-sqlbuilder v1.37.0 h1:hXgk2rTnlgFgKsmFpizhe6g/oz1wxef4qk3ix github.com/huandu/go-sqlbuilder v1.37.0/go.mod h1:zdONH67liL+/TvoUMwnZP/sUYGSSvHh9psLe/HpXn8E= github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= +github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= +github.com/jackc/pgx/v5 v5.0.0 h1:3UdmB3yUeTnJtZ+nDv3Mxzd4GHHvHkl9XN3oboIbOrY= +github.com/jackc/pgx/v5 v5.0.0/go.mod h1:JBbvW3Hdw77jKl9uJrEDATUZIFM2VFPzRq4RWIhkF4o= +github.com/jackc/puddle/v2 v2.0.0 h1:Kwk/AlLigcnZsDssc3Zun1dk1tAtQNPaBBxBHWn0Mjc= +github.com/jackc/puddle/v2 v2.0.0/go.mod h1:itE7ZJY8xnoo0JqJEpSMprN0f+NQkMCuEV/N9j8h0oc= github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -147,6 +167,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lib/pq v1.10.0 h1:Zx5DJFEYQXio93kgXnQ09fXNiUKsqv4OUEu2UtGcB1E= +github.com/lib/pq v1.10.0/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= @@ -160,6 +182,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/microsoft/go-mssqldb v1.6.0 h1:mM3gYdVwEPFrlg/Dvr2DNVEgYFG7L42l+dGc67NNNpc= +github.com/microsoft/go-mssqldb v1.6.0/go.mod h1:00mDtPbeQCRGC1HwOOR5K/gr30P1NcEG0vx6Kbv2aJU= 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 v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= @@ -177,6 +201,8 @@ github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6 github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= github.com/otiai10/mint v1.3.3/go.mod h1:/yxELlJQ0ufhjUwhshSj+wFjZ78CnZ48/1wtmBH1OTc= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= @@ -209,6 +235,8 @@ github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeV github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/internal/config/hub.go b/internal/config/hub.go index cbf9926..b3d5808 100644 --- a/internal/config/hub.go +++ b/internal/config/hub.go @@ -57,7 +57,7 @@ type MonitoringConfig struct { type GlobalConfig struct { DefaultInterval time.Duration `yaml:"default_interval" default:"1m"` DefaultTimeout time.Duration `yaml:"default_timeout" default:"10s"` - DefaultRetries int `yaml:"default_retries" default:"10"` + DefaultRetries int64 `yaml:"default_retries" default:"10"` } // DatabaseConfig holds database settings diff --git a/internal/models/models_gen.go b/internal/models/models_gen.go new file mode 100644 index 0000000..aba15e8 --- /dev/null +++ b/internal/models/models_gen.go @@ -0,0 +1,69 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package models + +import ( + "time" + + "github.com/sxwebdev/sentinel/pkg/dbutils" +) + +type Agent struct { + ID string `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Description *string `db:"description" json:"description"` + Host *string `db:"host" json:"host"` + Port *int64 `db:"port" json:"port"` + TokenCt []byte `db:"token_ct" json:"token_ct"` + TokenNonce []byte `db:"token_nonce" json:"token_nonce"` + TokenHint *string `db:"token_hint" json:"token_hint"` + Fingerprint *string `db:"fingerprint" json:"fingerprint"` + IsActive bool `db:"is_active" json:"is_active"` + SystemInfo dbutils.JSONField `db:"system_info" json:"system_info"` + LastSeenAt *time.Time `db:"last_seen_at" json:"last_seen_at"` + CreatedAt *time.Time `db:"created_at" json:"created_at"` + UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` +} + +type Incident struct { + ID string `db:"id" json:"id"` + ServiceID string `db:"service_id" json:"service_id"` + StartTime time.Time `db:"start_time" json:"start_time"` + EndTime *time.Time `db:"end_time" json:"end_time"` + Error string `db:"error" json:"error"` + DurationNs *int64 `db:"duration_ns" json:"duration_ns"` + Resolved bool `db:"resolved" json:"resolved"` + CreatedAt *time.Time `db:"created_at" json:"created_at"` + UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` +} + +type Service struct { + ID string `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Protocol ServiceProtocolType `db:"protocol" json:"protocol"` + Interval dbutils.Duration `db:"interval" json:"interval"` + Timeout dbutils.Duration `db:"timeout" json:"timeout"` + Retries int64 `db:"retries" json:"retries"` + Tags dbutils.JSONField `db:"tags" json:"tags"` + Config dbutils.JSONField `db:"config" json:"config"` + IsEnabled bool `db:"is_enabled" json:"is_enabled"` + CreatedAt *time.Time `db:"created_at" json:"created_at"` + UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` +} + +type ServiceState struct { + ID string `db:"id" json:"id"` + ServiceID string `db:"service_id" json:"service_id"` + Status ServiceStatus `db:"status" json:"status"` + LastCheck *time.Time `db:"last_check" json:"last_check"` + NextCheck *time.Time `db:"next_check" json:"next_check"` + LastError *string `db:"last_error" json:"last_error"` + ConsecutiveFails int64 `db:"consecutive_fails" json:"consecutive_fails"` + ConsecutiveSuccess int64 `db:"consecutive_success" json:"consecutive_success"` + TotalChecks int64 `db:"total_checks" json:"total_checks"` + ResponseTimeNs *int64 `db:"response_time_ns" json:"response_time_ns"` + CreatedAt *time.Time `db:"created_at" json:"created_at"` + UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` +} diff --git a/internal/models/service.go b/internal/models/service.go new file mode 100644 index 0000000..bceeccd --- /dev/null +++ b/internal/models/service.go @@ -0,0 +1,61 @@ +package models + +import ( + "encoding/json" + "time" +) + +type ServiceProtocolType string + +const ( + ServiceProtocolTypeHTTP ServiceProtocolType = "http" + ServiceProtocolTypeTCP ServiceProtocolType = "tcp" + ServiceProtocolTypeGRPC ServiceProtocolType = "grpc" +) + +// ServiceStatus represents the current status of a service +type ServiceStatus string + +const ( + StatusUnknown ServiceStatus = "unknown" + StatusUp ServiceStatus = "up" + StatusDown ServiceStatus = "down" + StatusMaintenance ServiceStatus = "maintenance" +) + +func (s ServiceStatus) String() string { + return string(s) +} + +type ServiceFullView struct { + ID string `json:"id"` + Name string `json:"name"` + Protocol ServiceProtocolType `json:"protocol"` + Interval time.Duration `json:"interval" swaggertype:"primitive,integer"` + Timeout time.Duration `json:"timeout" swaggertype:"primitive,integer"` + Retries int `json:"retries"` + Tags []string `json:"tags"` + Config map[string]any `json:"config"` + IsEnabled bool `json:"is_enabled"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + ActiveIncidents int `json:"active_incidents,omitempty"` + TotalIncidents int `json:"total_incidents,omitempty"` + Status ServiceStatus `json:"status"` + LastCheck *time.Time `json:"last_check,omitempty"` + NextCheck *time.Time `json:"next_check,omitempty"` + LastError *string `json:"last_error,omitempty"` + ConsecutiveFails int `json:"consecutive_fails"` + ConsecutiveSuccess int `json:"consecutive_success"` + TotalChecks int `json:"total_checks"` + ResponseTime *time.Duration `json:"response_time" swaggertype:"primitive,integer"` +} + +// GetConfig returns config value by key or default if not set +func (s *Service) GetConfig() (map[string]any, error) { + var config map[string]any + if err := json.Unmarshal([]byte(s.Config), &config); err != nil { + return nil, err + } + return config, nil +} diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 6c0bdfe..6d057a4 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -4,19 +4,19 @@ import ( "context" "fmt" "log" - "slices" "time" "github.com/sxwebdev/sentinel/internal/config" "github.com/sxwebdev/sentinel/internal/notifier" "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/storage" + "github.com/sxwebdev/sentinel/internal/store" "github.com/sxwebdev/sentinel/internal/utils" - "github.com/sxwebdev/sentinel/pkg/dbutils" ) // MonitorService handles service monitoring type MonitorService struct { + store *store.Store storage *storage.Storage config *config.ConfigHub notifier *notifier.Notifier @@ -24,8 +24,9 @@ type MonitorService struct { } // NewMonitorService creates a new monitor service -func NewMonitorService(storage *storage.Storage, config *config.ConfigHub, notifier *notifier.Notifier, receiver *receiver.Receiver) *MonitorService { +func NewMonitorService(store *store.Store, storage *storage.Storage, config *config.ConfigHub, notifier *notifier.Notifier, receiver *receiver.Receiver) *MonitorService { return &MonitorService{ + store: store, storage: storage, config: config, notifier: notifier, @@ -34,75 +35,81 @@ func NewMonitorService(storage *storage.Storage, config *config.ConfigHub, notif } // FindServices loads all enabled services from storage and initializes monitoring -func (m *MonitorService) FindServices(ctx context.Context, params storage.FindServicesParams) (dbutils.FindResponseWithCount[*storage.Service], error) { - return m.storage.FindServices(ctx, params) -} +// func (m *MonitorService) FindServices(ctx context.Context, params storage.FindServicesParams) (dbutils.FindResponseWithCount[*storage.Service], error) { +// return m.storage.FindServices(ctx, params) +// } // CreateService adds a new service and starts monitoring it -func (m *MonitorService) CreateService(ctx context.Context, params storage.CreateUpdateServiceRequest) (*storage.Service, error) { - if len(params.Tags) > 0 { - slices.Sort(params.Tags) - } +// func (m *MonitorService) CreateService(ctx context.Context, params storage.CreateUpdateServiceRequest) (*storage.Service, error) { +// if len(params.Tags) > 0 { +// slices.Sort(params.Tags) +// } - // Save to storage - svc, err := m.storage.CreateService(ctx, params) - if err != nil { - return nil, fmt.Errorf("failed to create service: %w", err) - } +// // Save to storage +// svc, err := m.storage.CreateService(ctx, params) +// if err != nil { +// return nil, fmt.Errorf("failed to create service: %w", err) +// } - m.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( - receiver.TriggerServiceEventTypeCreated, - svc, - )) +// m.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( +// receiver.TriggerServiceEventTypeCreated, +// svc, +// )) - return svc, nil -} +// return svc, nil +// } // UpdateService updates an existing service -func (m *MonitorService) UpdateService(ctx context.Context, id string, params storage.CreateUpdateServiceRequest) (*storage.Service, error) { - if len(params.Tags) > 0 { - slices.Sort(params.Tags) - } - - // Update in storage - svc, err := m.storage.UpdateService(ctx, id, params) - if err != nil { - return nil, fmt.Errorf("failed to update service: %w", err) - } - - m.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( - receiver.TriggerServiceEventTypeUpdated, - svc, - )) - - return svc, nil -} +// func (m *MonitorService) UpdateService(ctx context.Context, id string, params storage.CreateUpdateServiceRequest) (*models.ServiceFullView, error) { +// if len(params.Tags) > 0 { +// slices.Sort(params.Tags) +// } + +// // Update in storage +// var err error +// _, err = m.storage.UpdateService(ctx, id, params) +// if err != nil { +// return nil, fmt.Errorf("failed to update service: %w", err) +// } + +// svc, err := m.store.Services().GetViewByID(ctx, id) +// if err != nil { +// return nil, fmt.Errorf("failed to get service: %w", err) +// } + +// m.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( +// receiver.TriggerServiceEventTypeUpdated, +// svc, +// )) + +// return svc, nil +// } // DeleteService removes a service and stops monitoring it -func (m *MonitorService) DeleteService(ctx context.Context, id string) error { - // Get service to find name for scheduler cleanup - svc, err := m.storage.GetServiceByID(ctx, id) - if err != nil { - return fmt.Errorf("failed to get service: %w", err) - } - - m.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( - receiver.TriggerServiceEventTypeDeleted, - svc, - )) - - // Delete from storage - if err := m.storage.DeleteService(ctx, id); err != nil { - return fmt.Errorf("failed to delete service: %w", err) - } - - return nil -} +// func (m *MonitorService) DeleteService(ctx context.Context, id string) error { +// // Get service to find name for scheduler cleanup +// svc, err := m.store.Services().GetViewByID(ctx, id) +// if err != nil { +// return fmt.Errorf("failed to get service: %w", err) +// } + +// m.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( +// receiver.TriggerServiceEventTypeDeleted, +// svc, +// )) + +// // Delete from storage +// if err := m.storage.DeleteService(ctx, id); err != nil { +// return fmt.Errorf("failed to delete service: %w", err) +// } + +// return nil +// } // GetServiceByID gets a service by ID -func (m *MonitorService) GetServiceByID(ctx context.Context, id string) (*storage.Service, error) { - return m.storage.GetServiceByID(ctx, id) -} +// func (m *MonitorService) GetServiceByID(ctx context.Context, id string) (*storage.Service, error) { +// return m.storage.GetServiceByID(ctx, id) +// } // RecordSuccess records a successful check for a service func (m *MonitorService) RecordSuccess(ctx context.Context, serviceID string, responseTime time.Duration) error { @@ -185,7 +192,7 @@ func (m *MonitorService) RecordFailure(ctx context.Context, serviceID string, ch // createIncident creates a new incident when a service goes down func (m *MonitorService) createIncident(ctx context.Context, svc *storage.Service, err error) error { incident := &storage.Incident{ - ID: storage.GenerateULID(), + ID: utils.GenerateULID(), ServiceID: svc.ID, StartTime: time.Now(), Error: err.Error(), @@ -258,11 +265,11 @@ func (m *MonitorService) GetServiceStats(ctx context.Context, serviceID string, } // TriggerCheck triggers a manual check for a service -func (m *MonitorService) TriggerCheck(ctx context.Context, serviceID string) error { +func (m *MonitorService) TriggerCheck(ctx context.Context, id string) error { // Get service to check if it exists - svc, err := m.GetServiceByID(ctx, serviceID) + svc, err := m.store.Services().GetViewByID(ctx, id) if err != nil { - return err + return fmt.Errorf("failed to get service: %w", err) } m.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( @@ -294,7 +301,7 @@ func (m *MonitorService) CheckService(ctx context.Context, service *storage.Serv // Initialize state if not exists if serviceState == nil { serviceState = &storage.ServiceStateRecord{ - ID: storage.GenerateULID(), + ID: utils.GenerateULID(), ServiceID: service.ID, Status: storage.StatusUnknown, ConsecutiveFails: 0, diff --git a/internal/monitors/config.go b/internal/monitors/config.go index c292e0b..e1f9c86 100644 --- a/internal/monitors/config.go +++ b/internal/monitors/config.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/go-playground/validator/v10" + "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/storage" ) @@ -15,12 +16,12 @@ type Config struct { } // convertFlatConfigToMonitorConfig converts JSON config object to proper MonitorConfig structure -func (s *Config) Validate(protocol storage.ServiceProtocolType) error { +func (s *Config) Validate(protocol models.ServiceProtocolType) error { v := validator.New(validator.WithRequiredStructEnabled()) // Validate and convert based on protocol switch protocol { - case storage.ServiceProtocolTypeHTTP: + case models.ServiceProtocolTypeHTTP: if s.HTTP == nil { return fmt.Errorf("HTTP config is required for HTTP protocol") } @@ -31,7 +32,7 @@ func (s *Config) Validate(protocol storage.ServiceProtocolType) error { } return nil - case storage.ServiceProtocolTypeTCP: + case models.ServiceProtocolTypeTCP: if s.TCP == nil { return fmt.Errorf("TCP config is required for TCP protocol") } @@ -42,7 +43,7 @@ func (s *Config) Validate(protocol storage.ServiceProtocolType) error { } return nil - case storage.ServiceProtocolTypeGRPC: + case models.ServiceProtocolTypeGRPC: if s.GRPC == nil { return fmt.Errorf("gRPC config is required for gRPC protocol") } @@ -58,7 +59,7 @@ func (s *Config) Validate(protocol storage.ServiceProtocolType) error { } } -func GetConfig[T any](cfg map[string]any, protocol storage.ServiceProtocolType) (T, error) { +func GetConfig[T any](cfg map[string]any, protocol models.ServiceProtocolType) (T, error) { var c T if cfg == nil { @@ -89,6 +90,14 @@ func (c *Config) ConvertToMap() map[string]any { } } +func (c *Config) ConvertToJSONRawMessage() (json.RawMessage, error) { + data, err := json.Marshal(c.ConvertToMap()) + if err != nil { + return nil, err + } + return json.RawMessage(data), nil +} + // ConvertFromMap converts a map[string]any to Config func ConvertFromMap(cfg map[string]any) (Config, error) { conf := Config{} diff --git a/internal/monitors/grpc.go b/internal/monitors/grpc.go index d3af8d9..93ef6aa 100644 --- a/internal/monitors/grpc.go +++ b/internal/monitors/grpc.go @@ -6,7 +6,7 @@ import ( "fmt" "time" - "github.com/sxwebdev/sentinel/internal/storage" + "github.com/sxwebdev/sentinel/internal/models" "google.golang.org/grpc" "google.golang.org/grpc/connectivity" "google.golang.org/grpc/credentials" @@ -31,12 +31,17 @@ type GRPCMonitor struct { } // NewGRPCMonitor creates a new gRPC monitor -func NewGRPCMonitor(cfg storage.Service) (*GRPCMonitor, error) { +func NewGRPCMonitor(svc *models.Service) (*GRPCMonitor, error) { monitor := &GRPCMonitor{ - BaseMonitor: NewBaseMonitor(cfg), + BaseMonitor: NewBaseMonitor(svc), } - conf, err := GetConfig[GRPCConfig](cfg.Config, storage.ServiceProtocolTypeGRPC) + svcConfig, err := svc.GetConfig() + if err != nil { + return nil, fmt.Errorf("failed to parse service config: %w", err) + } + + conf, err := GetConfig[GRPCConfig](svcConfig, models.ServiceProtocolTypeGRPC) if err != nil { return nil, fmt.Errorf("failed to get gRPC config: %w", err) } diff --git a/internal/monitors/http.go b/internal/monitors/http.go index 0ae3a64..1f3868f 100644 --- a/internal/monitors/http.go +++ b/internal/monitors/http.go @@ -11,7 +11,7 @@ import ( "time" "github.com/dop251/goja" - "github.com/sxwebdev/sentinel/internal/storage" + "github.com/sxwebdev/sentinel/internal/models" ) // HTTPConfig represents configuration for HTTP monitoring @@ -49,20 +49,25 @@ type EndpointResult struct { type HTTPMonitor struct { BaseMonitor conf HTTPConfig - retries int + retries int64 } // NewHTTPMonitor creates a new HTTP monitor -func NewHTTPMonitor(cfg storage.Service) (*HTTPMonitor, error) { - conf, err := GetConfig[HTTPConfig](cfg.Config, storage.ServiceProtocolTypeHTTP) +func NewHTTPMonitor(svc *models.Service) (*HTTPMonitor, error) { + svcConfig, err := svc.GetConfig() + if err != nil { + return nil, fmt.Errorf("failed to parse service config: %w", err) + } + + conf, err := GetConfig[HTTPConfig](svcConfig, models.ServiceProtocolTypeHTTP) if err != nil { return nil, fmt.Errorf("HTTP config not found") } monitor := &HTTPMonitor{ - BaseMonitor: NewBaseMonitor(cfg), + BaseMonitor: NewBaseMonitor(svc), conf: conf, - retries: cfg.Retries, + retries: svc.Retries, } return monitor, nil @@ -146,7 +151,7 @@ func (h *HTTPMonitor) checkEndpoint(ctx context.Context, endpoint EndpointConfig start := time.Now() client := &http.Client{} - client.Timeout = h.config.Timeout + client.Timeout = h.config.Timeout.ToDuration() req, err := http.NewRequestWithContext(ctx, endpoint.Method, endpoint.URL, strings.NewReader(endpoint.Body)) if err != nil { diff --git a/internal/monitors/monitor.go b/internal/monitors/monitor.go index f19bb5b..e076c7a 100644 --- a/internal/monitors/monitor.go +++ b/internal/monitors/monitor.go @@ -5,7 +5,7 @@ import ( "fmt" "io" - "github.com/sxwebdev/sentinel/internal/storage" + "github.com/sxwebdev/sentinel/internal/models" ) // ServiceMonitor defines the interface for all service monitors @@ -13,19 +13,19 @@ type ServiceMonitor interface { io.Closer Name() string - Protocol() storage.ServiceProtocolType + Protocol() models.ServiceProtocolType Check(ctx context.Context) error - Config() storage.Service + Config() *models.Service } // NewMonitor creates a new monitor based on the service configuration -func NewMonitor(cfg storage.Service) (ServiceMonitor, error) { +func NewMonitor(cfg *models.Service) (ServiceMonitor, error) { switch cfg.Protocol { - case storage.ServiceProtocolTypeHTTP: + case models.ServiceProtocolTypeHTTP: return NewHTTPMonitor(cfg) - case storage.ServiceProtocolTypeTCP: + case models.ServiceProtocolTypeTCP: return NewTCPMonitor(cfg) - case storage.ServiceProtocolTypeGRPC: + case models.ServiceProtocolTypeGRPC: return NewGRPCMonitor(cfg) default: return nil, fmt.Errorf("unsupported protocol: %s", cfg.Protocol) @@ -35,11 +35,11 @@ func NewMonitor(cfg storage.Service) (ServiceMonitor, error) { // BaseMonitor provides common functionality for all monitors type BaseMonitor struct { name string - protocol storage.ServiceProtocolType - config storage.Service + protocol models.ServiceProtocolType + config *models.Service } -func NewBaseMonitor(cfg storage.Service) BaseMonitor { +func NewBaseMonitor(cfg *models.Service) BaseMonitor { return BaseMonitor{ name: cfg.Name, protocol: cfg.Protocol, @@ -51,10 +51,10 @@ func (b *BaseMonitor) Name() string { return b.name } -func (b *BaseMonitor) Protocol() storage.ServiceProtocolType { +func (b *BaseMonitor) Protocol() models.ServiceProtocolType { return b.protocol } -func (b *BaseMonitor) Config() storage.Service { +func (b *BaseMonitor) Config() *models.Service { return b.config } diff --git a/internal/monitors/tcp.go b/internal/monitors/tcp.go index b405450..51f565d 100644 --- a/internal/monitors/tcp.go +++ b/internal/monitors/tcp.go @@ -8,7 +8,7 @@ import ( "strings" "time" - "github.com/sxwebdev/sentinel/internal/storage" + "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/utils" ) @@ -26,9 +26,13 @@ type TCPMonitor struct { } // NewTCPMonitor creates a new TCP monitor -func NewTCPMonitor(svc storage.Service) (*TCPMonitor, error) { - // Extract TCP config - conf, err := GetConfig[TCPConfig](svc.Config, storage.ServiceProtocolTypeTCP) +func NewTCPMonitor(svc *models.Service) (*TCPMonitor, error) { + svcConfig, err := svc.GetConfig() + if err != nil { + return nil, fmt.Errorf("failed to parse service config: %w", err) + } + + conf, err := GetConfig[TCPConfig](svcConfig, models.ServiceProtocolTypeTCP) if err != nil { return nil, fmt.Errorf("failed to get TCP config: %w", err) } @@ -49,7 +53,7 @@ func (t *TCPMonitor) Check(ctx context.Context) error { } endpoint := t.conf.Endpoint - timeout := t.config.Timeout + timeout := t.config.Timeout.ToDuration() if timeout <= 0 { timeout = 5 * time.Second } diff --git a/internal/receiver/receiver.go b/internal/receiver/receiver.go index 16fd89e..596b864 100644 --- a/internal/receiver/receiver.go +++ b/internal/receiver/receiver.go @@ -3,7 +3,7 @@ package receiver import ( "context" - "github.com/sxwebdev/sentinel/internal/storage" + "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/pkg/broker" ) @@ -38,12 +38,12 @@ func (e TriggerServiceEventType) String() string { type TriggerServiceData struct { EventType TriggerServiceEventType - Svc *storage.Service + Svc *models.ServiceFullView } func NewTriggerServiceData( eventType TriggerServiceEventType, - svc *storage.Service, + svc *models.ServiceFullView, ) *TriggerServiceData { return &TriggerServiceData{ EventType: eventType, diff --git a/internal/scheduler/job.go b/internal/scheduler/job.go new file mode 100644 index 0000000..f9eb8fa --- /dev/null +++ b/internal/scheduler/job.go @@ -0,0 +1,22 @@ +package scheduler + +import ( + "context" + "sync/atomic" + "time" +) + +// job represents a scheduled monitoring job for a service +type job struct { + serviceID string + serviceName string + interval time.Duration + timeout time.Duration + retries int + ticker *time.Ticker + stopChan chan struct{} + inProgress atomic.Bool + // Context and cancel function for canceling ongoing checks + checkCtx context.Context + checkCancel context.CancelFunc +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 3e3d090..a597d62 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -5,14 +5,15 @@ import ( "errors" "fmt" "sync" - "sync/atomic" "time" "github.com/puzpuzpuz/xsync/v3" + "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/monitor" "github.com/sxwebdev/sentinel/internal/monitors" "github.com/sxwebdev/sentinel/internal/receiver" - "github.com/sxwebdev/sentinel/internal/storage" + "github.com/sxwebdev/sentinel/internal/services/baseservices" + "github.com/sxwebdev/sentinel/internal/services/service" "github.com/tkcrm/mx/logger" ) @@ -23,39 +24,27 @@ var ErrServiceNotFound = fmt.Errorf("service not found") type Scheduler struct { logger logger.Logger - receiver *receiver.Receiver - monitorSvc *monitor.MonitorService + receiver *receiver.Receiver + monitorSvc *monitor.MonitorService + baseServices *baseservices.BaseServices jobs *xsync.MapOf[string, *job] wg sync.WaitGroup } -// job represents a scheduled monitoring job for a service -type job struct { - serviceID string - serviceName string - interval time.Duration - timeout time.Duration - retries int - ticker *time.Ticker - stopChan chan struct{} - inProgress atomic.Bool - // Context and cancel function for canceling ongoing checks - checkCtx context.Context - checkCancel context.CancelFunc -} - // New creates a new scheduler func New( l logger.Logger, monitorService *monitor.MonitorService, receiver *receiver.Receiver, + baseServices *baseservices.BaseServices, ) *Scheduler { return &Scheduler{ - logger: l, - monitorSvc: monitorService, - receiver: receiver, - jobs: xsync.NewMapOf[string, *job](), + logger: l, + monitorSvc: monitorService, + receiver: receiver, + baseServices: baseServices, + jobs: xsync.NewMapOf[string, *job](), } } @@ -66,7 +55,7 @@ func (s *Scheduler) Name() string { return "scheduler" } func (s *Scheduler) Start(ctx context.Context) error { // Load enabled services from storage isEnabled := true - services, err := s.monitorSvc.FindServices(ctx, storage.FindServicesParams{ + services, err := s.baseServices.Services().FindView(ctx, service.FindParams{ IsEnabled: &isEnabled, }) if err != nil { @@ -120,7 +109,7 @@ func (s *Scheduler) stopAll() { } // addService adds a service to be monitored -func (s *Scheduler) addService(ctx context.Context, svc *storage.Service) { +func (s *Scheduler) addService(ctx context.Context, svc *models.ServiceFullView) { // Only add enabled services to monitoring if !svc.IsEnabled { s.logger.Warnf("Skipping disabled service: %s (ID: %s)", svc.Name, svc.ID) @@ -217,13 +206,13 @@ func (s *Scheduler) performCheck(job *job) error { serviceName := job.serviceName // Get current service configuration from database - service, err := s.monitorSvc.GetServiceByID(job.checkCtx, job.serviceID) + service, err := s.baseServices.Services().GetByID(job.checkCtx, job.serviceID) if err != nil { - return fmt.Errorf("failed to get service config for %s: %w", serviceName, err) + return fmt.Errorf("failed to get service %s: %w", serviceName, err) } // Create monitor for this check - monitor, err := monitors.NewMonitor(*service) + monitor, err := monitors.NewMonitor(service) if err != nil { return fmt.Errorf("failed to create monitor for %s: %w", serviceName, err) } @@ -266,15 +255,15 @@ func (s *Scheduler) performCheck(job *job) error { s.logger.Debugf("service %s check successful (attempt %d/%d) in %v", serviceName, attempt, job.retries, attemptResponseTime) } - service, err := s.monitorSvc.GetServiceByID(job.checkCtx, job.serviceID) + svc, err := s.baseServices.Services().GetViewByID(job.checkCtx, job.serviceID) if err != nil { - return fmt.Errorf("failed to get service config for %s: %w", serviceName, err) + return fmt.Errorf("failed to get service view for %s: %w", serviceName, err) } // Publish update to receiver s.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( receiver.TriggerServiceEventTypeUpdatedState, - service, + svc, )) return nil @@ -302,15 +291,15 @@ func (s *Scheduler) performCheck(job *job) error { return fmt.Errorf("failed to record failure for %s: %w", serviceName, err) } - service, err = s.monitorSvc.GetServiceByID(job.checkCtx, job.serviceID) + svc, err := s.baseServices.Services().GetViewByID(job.checkCtx, job.serviceID) if err != nil { - return fmt.Errorf("failed to get service config for %s: %w", serviceName, err) + return fmt.Errorf("failed to get service view for %s: %w", serviceName, err) } // Publish update to receiver s.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( receiver.TriggerServiceEventTypeUpdatedState, - service, + svc, )) return nil @@ -356,7 +345,7 @@ func (s *Scheduler) removeJob(serviceID string) error { } // updateJob updates a service configuration dynamically -func (s *Scheduler) updateJob(ctx context.Context, svc *storage.Service) error { +func (s *Scheduler) updateJob(ctx context.Context, svc *models.ServiceFullView) error { s.addService(ctx, svc) return nil diff --git a/internal/services/baseservices/base.go b/internal/services/baseservices/base.go new file mode 100644 index 0000000..4a21a0b --- /dev/null +++ b/internal/services/baseservices/base.go @@ -0,0 +1,27 @@ +package baseservices + +import ( + "github.com/sxwebdev/sentinel/internal/receiver" + "github.com/sxwebdev/sentinel/internal/services/service" + "github.com/sxwebdev/sentinel/internal/store" +) + +type BaseServices struct { + services *service.Service +} + +func New( + st *store.Store, + receiver *receiver.Receiver, +) *BaseServices { + servicesService := service.New(st, receiver) + + return &BaseServices{ + services: servicesService, + } +} + +// Services returns services service +func (b *BaseServices) Services() *service.Service { + return b.services +} diff --git a/internal/services/service/methods.go b/internal/services/service/methods.go new file mode 100644 index 0000000..79f4353 --- /dev/null +++ b/internal/services/service/methods.go @@ -0,0 +1,156 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "fmt" + "slices" + "time" + + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/receiver" + "github.com/sxwebdev/sentinel/internal/store/repos" + "github.com/sxwebdev/sentinel/internal/store/repos/repo_service_states" + "github.com/sxwebdev/sentinel/internal/store/repos/repo_services" + "github.com/sxwebdev/sentinel/internal/store/storecmn" + "github.com/sxwebdev/sentinel/internal/utils" + "github.com/sxwebdev/sentinel/pkg/dbutils" +) + +type CreateParams = repo_services.CreateParams + +// Create new service +func (s *Service) Create(ctx context.Context, params CreateParams) (*models.ServiceFullView, error) { + if len(params.Tags) > 0 { + slices.Sort(params.Tags) + } + + params.ID = utils.GenerateULID() + + err := dbutils.WrapTx(ctx, s.store.SQLite(), func(tx *sql.Tx) error { + // Create service + _, err := s.store.Services(repos.WithTx(tx)).Create(ctx, params) + if err != nil { + return fmt.Errorf("failed to create service: %w", err) + } + + // Create initial service state + nextCheck := time.Now().Add(params.Interval.ToDuration()) + serviceState := &repo_service_states.CreateParams{ + ID: utils.GenerateULID(), + ServiceID: params.ID, + Status: models.StatusUnknown, + NextCheck: &nextCheck, + } + + _, err = s.store.ServiceStates(repos.WithTx(tx)).Create(ctx, *serviceState) + if err != nil { + return fmt.Errorf("failed to create initial service state: %w", err) + } + + return nil + }) + if err != nil { + return nil, fmt.Errorf("failed to create service in transaction: %w", err) + } + + svcView, err := s.GetViewByID(ctx, params.ID) + if err != nil { + return nil, fmt.Errorf("failed to get created service: %w", err) + } + + s.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( + receiver.TriggerServiceEventTypeCreated, + svcView, + )) + + return svcView, nil +} + +type UpdateParams = repo_services.UpdateServiceRequest + +// Update service +func (s *Service) Update(ctx context.Context, id string, params UpdateParams) (*models.ServiceFullView, error) { + item, err := s.store.Services().Update(ctx, id, params) + if err != nil { + return nil, err + } + + s.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( + receiver.TriggerServiceEventTypeUpdated, + item, + )) + + return item, nil +} + +// GetByID returns service by ID +func (s *Service) GetByID(ctx context.Context, id string) (*models.Service, error) { + svc, err := s.store.Services().GetByID(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, storecmn.ErrNotFound + } + return nil, err + } + + return svc, nil +} + +// GetViewByID returns service view by ID +func (s *Service) GetViewByID(ctx context.Context, id string) (*models.ServiceFullView, error) { + return s.store.Services().GetViewByID(ctx, id) +} + +type FindParams = repo_services.FindParams + +// FindView services by params +func (s *Service) FindView(ctx context.Context, params FindParams) (*dbutils.FindResponseWithCount[*models.ServiceFullView], error) { + return s.store.Services().FindView(ctx, params) +} + +// Delete service by ID +func (s *Service) Delete(ctx context.Context, id string) error { + // Get service to find name for scheduler cleanup + svc, err := s.store.Services().GetViewByID(ctx, id) + if err != nil { + return fmt.Errorf("failed to get service: %w", err) + } + + // Delete service and related data in a transaction + err = dbutils.WrapTx(ctx, s.store.SQLite(), func(tx *sql.Tx) error { + if err := s.store.ServiceStates(repos.WithTx(tx)).DeleteByServiceID(ctx, id); err != nil { + return fmt.Errorf("failed to delete service states: %w", err) + } + + if err := s.store.Incidents(repos.WithTx(tx)).DeleteByServiceID(ctx, id); err != nil { + return fmt.Errorf("failed to delete incidents: %w", err) + } + + if err := s.store.Services(repos.WithTx(tx)).Delete(ctx, id); err != nil { + return fmt.Errorf("failed to delete service: %w", err) + } + + return nil + }) + if err != nil { + return fmt.Errorf("failed to delete service in transaction: %w", err) + } + + s.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( + receiver.TriggerServiceEventTypeDeleted, + svc, + )) + + return nil +} + +// Exists checks if there are any services +func (s *Service) Exists(ctx context.Context, id string) (bool, error) { + res, err := s.store.Services().Exist(ctx, id) + if err != nil { + return false, fmt.Errorf("failed to check if services exist: %w", err) + } + return res > 0, nil +} diff --git a/internal/services/service/service.go b/internal/services/service/service.go new file mode 100644 index 0000000..5fa8a8a --- /dev/null +++ b/internal/services/service/service.go @@ -0,0 +1,18 @@ +package service + +import ( + "github.com/sxwebdev/sentinel/internal/receiver" + "github.com/sxwebdev/sentinel/internal/store" +) + +type Service struct { + store *store.Store + receiver *receiver.Receiver +} + +func New(store *store.Store, receiver *receiver.Receiver) *Service { + return &Service{ + store: store, + receiver: receiver, + } +} diff --git a/internal/storage/incidents.go b/internal/storage/incidents.go index 640898a..b6a6243 100644 --- a/internal/storage/incidents.go +++ b/internal/storage/incidents.go @@ -274,7 +274,7 @@ func (o *Storage) SaveIncident(ctx context.Context, incident *Incident) error { ib.Cols("id", "service_id", "start_time", "end_time", "error", "duration_ns", "resolved") ib.Values( - GenerateULID(), + utils.GenerateULID(), incident.ServiceID, incident.StartTime, incident.EndTime, diff --git a/internal/storage/migrations.go b/internal/storage/migrations.go deleted file mode 100644 index 3ed32d2..0000000 --- a/internal/storage/migrations.go +++ /dev/null @@ -1,149 +0,0 @@ -package storage - -import ( - "database/sql" - "fmt" -) - -// Migration represents a database migration -type Migration struct { - Version int - SQL string -} - -// migrations contains all database migrations in order -var migrations = []Migration{ - { - Version: 1, - SQL: ` - -- Create services table - CREATE TABLE IF NOT EXISTS services ( - id TEXT PRIMARY KEY, - name TEXT NOT NULL, - protocol TEXT NOT NULL, - interval TEXT NOT NULL, - timeout TEXT NOT NULL, - retries INTEGER NOT NULL DEFAULT 3, - tags jsonb NOT NULL DEFAULT '[]', - config jsonb NOT NULL DEFAULT '{}', - is_enabled BOOLEAN NOT NULL DEFAULT TRUE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - -- Create incidents table - CREATE TABLE IF NOT EXISTS incidents ( - id TEXT PRIMARY KEY, - service_id TEXT NOT NULL REFERENCES services(id), - start_time DATETIME NOT NULL, - end_time DATETIME, - error TEXT NOT NULL, - duration_ns INTEGER, - resolved BOOLEAN NOT NULL DEFAULT FALSE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP - ); - - -- Create service_states table for current service states - CREATE TABLE IF NOT EXISTS service_states ( - id TEXT PRIMARY KEY, - service_id TEXT NOT NULL REFERENCES services(id), - status TEXT NOT NULL DEFAULT 'unknown', - last_check DATETIME, - next_check DATETIME, - last_error TEXT, - consecutive_fails INTEGER NOT NULL DEFAULT 0, - consecutive_success INTEGER NOT NULL DEFAULT 0, - total_checks INTEGER NOT NULL DEFAULT 0, - response_time_ns INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - UNIQUE(service_id) - ); - - -- Create indexes for performance - CREATE INDEX IF NOT EXISTS idx_services_name ON services(name); - CREATE INDEX IF NOT EXISTS idx_services_enabled ON services(is_enabled); - CREATE INDEX IF NOT EXISTS idx_services_created_at ON services(created_at DESC); - - CREATE INDEX IF NOT EXISTS idx_incidents_service_id ON incidents(service_id); - CREATE INDEX IF NOT EXISTS idx_incidents_start_time_desc ON incidents(start_time DESC); - CREATE INDEX IF NOT EXISTS idx_incidents_resolved ON incidents(resolved); - - CREATE INDEX IF NOT EXISTS idx_service_states_service_id ON service_states(service_id); - CREATE INDEX IF NOT EXISTS idx_service_states_status ON service_states(status); - CREATE INDEX IF NOT EXISTS idx_service_states_last_check ON service_states(last_check DESC); - CREATE INDEX IF NOT EXISTS idx_service_states_next_check ON service_states(next_check); - `, - }, -} - -// schemaVersionTable creates the schema version tracking table -const schemaVersionTable = ` -CREATE TABLE IF NOT EXISTS schema_version ( - version INTEGER PRIMARY KEY, - applied_at DATETIME DEFAULT CURRENT_TIMESTAMP -); -` - -// runMigrations runs all pending database migrations -func runMigrations(db *sql.DB) error { - // Create schema version table if it doesn't exist - if _, err := db.Exec(schemaVersionTable); err != nil { - return fmt.Errorf("failed to create schema version table: %w", err) - } - - // Get current schema version - currentVersion, err := getCurrentSchemaVersion(db) - if err != nil { - return fmt.Errorf("failed to get current schema version: %w", err) - } - - // Run pending migrations - for _, migration := range migrations { - if migration.Version > currentVersion { - if err := runMigration(db, migration); err != nil { - return fmt.Errorf("failed to run migration %d: %w", migration.Version, err) - } - } - } - - return nil -} - -// getCurrentSchemaVersion gets the current schema version from the database -func getCurrentSchemaVersion(db *sql.DB) (int, error) { - var version int - err := db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_version").Scan(&version) - if err != nil { - return 0, err - } - return version, nil -} - -// runMigration runs a single migration -func runMigration(db *sql.DB, migration Migration) error { - // Start transaction - tx, err := db.Begin() - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback() - - // Execute migration SQL - if _, err := tx.Exec(migration.SQL); err != nil { - return fmt.Errorf("failed to execute migration SQL: %w", err) - } - - // Record migration version - if _, err := tx.Exec("INSERT INTO schema_version (version) VALUES (?)", migration.Version); err != nil { - return fmt.Errorf("failed to record migration version: %w", err) - } - - // Commit transaction - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit migration: %w", err) - } - - return nil -} diff --git a/internal/storage/models.go b/internal/storage/models.go index c5853ec..58d5c26 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -2,8 +2,6 @@ package storage import ( "time" - - "github.com/oklog/ulid/v2" ) type ServiceProtocolType string @@ -121,8 +119,3 @@ type ServiceStateRecord struct { CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` } - -// GenerateULID generates a new ULID -func GenerateULID() string { - return ulid.Make().String() -} diff --git a/internal/storage/services.go b/internal/storage/services.go index 82870c4..81c9611 100644 --- a/internal/storage/services.go +++ b/internal/storage/services.go @@ -399,7 +399,7 @@ func (o *Storage) CreateService(ctx context.Context, service CreateUpdateService return nil, fmt.Errorf("failed to marshal config: %w", err) } - serviceID := GenerateULID() + serviceID := utils.GenerateULID() ib.Values( serviceID, @@ -427,7 +427,7 @@ func (o *Storage) CreateService(ctx context.Context, service CreateUpdateService nextCheck := time.Now().Add(service.Interval) serviceState := &ServiceStateRecord{ - ID: GenerateULID(), + ID: utils.GenerateULID(), ServiceID: serviceID, Status: StatusUnknown, NextCheck: &nextCheck, diff --git a/internal/storage/storage.go b/internal/storage/storage.go index b7bc090..9b674d6 100644 --- a/internal/storage/storage.go +++ b/internal/storage/storage.go @@ -8,7 +8,11 @@ import ( "path/filepath" "time" + "github.com/sxwebdev/sentinel/pkg/migrations" + "github.com/tkcrm/mx/logger" _ "modernc.org/sqlite" + + emsql "github.com/sxwebdev/sentinel/sql" ) // Storage implements Storage interface using SQLite @@ -17,13 +21,19 @@ type Storage struct { } // New creates a new SQLite storage instance -func New(dbPath string) (*Storage, error) { +func New(l logger.Logger, dbPath string) (*Storage, error) { // Ensure directory exists dir := filepath.Dir(dbPath) if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("failed to create database directory: %w", err) } + // Run migrations + m := migrations.New(l, emsql.MigrationsFS, emsql.MigrationsPath) + if err := m.MigrateUpAll(dbPath); err != nil { + return nil, fmt.Errorf("failed to migrate database: %w", err) + } + // Open SQLite database with proper settings for concurrent access db, err := sql.Open("sqlite", dbPath+"?_busy_timeout=30000&_journal_mode=WAL&_synchronous=NORMAL&_cache_size=10000&_foreign_keys=on") if err != nil { @@ -40,11 +50,6 @@ func New(dbPath string) (*Storage, error) { return nil, fmt.Errorf("failed to ping database: %w", err) } - if err := runMigrations(db); err != nil { - db.Close() - return nil, fmt.Errorf("failed to migrate database: %w", err) - } - return &Storage{ db: db, }, nil @@ -52,13 +57,13 @@ func New(dbPath string) (*Storage, error) { // Name returns the storage type func (s *Storage) Name() string { - return "storage_sqlite" + return "store" } // Start initializes the storage func (s *Storage) Start(_ context.Context) error { if s.db == nil { - return fmt.Errorf("storage not initialized") + return fmt.Errorf("sqlite not initialized") } return nil } @@ -67,9 +72,14 @@ func (s *Storage) Start(_ context.Context) error { func (s *Storage) Stop(_ context.Context) error { if s.db != nil { if err := s.db.Close(); err != nil { - return fmt.Errorf("failed to close database: %w", err) + return fmt.Errorf("failed to close sqlite database: %w", err) } s.db = nil } return nil } + +// SQLiteDB returns the underlying sql.DB instance +func (s *Storage) SQLiteDB() *sql.DB { + return s.db +} diff --git a/internal/store/repos/options.go b/internal/store/repos/options.go new file mode 100644 index 0000000..647c25a --- /dev/null +++ b/internal/store/repos/options.go @@ -0,0 +1,24 @@ +package repos + +import "database/sql" + +type Option func(*Options) + +type Options struct { + Tx *sql.Tx +} + +func parseOptions(opts ...Option) Options { + options := Options{} + for _, opt := range opts { + opt(&options) + } + + return options +} + +func WithTx(tx *sql.Tx) Option { + return func(o *Options) { + o.Tx = tx + } +} diff --git a/internal/store/repos/repo_agents/agents_gen.sql.go b/internal/store/repos/repo_agents/agents_gen.sql.go new file mode 100644 index 0000000..fe41122 --- /dev/null +++ b/internal/store/repos/repo_agents/agents_gen.sql.go @@ -0,0 +1,137 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: agents_gen.sql + +package repo_agents + +import ( + "context" + + "github.com/sxwebdev/sentinel/internal/models" +) + +const create = `-- name: Create :one +INSERT INTO agents (id, name, description, host, port, token_ct, token_nonce, token_hint) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id, name, description, host, port, token_ct, token_nonce, token_hint, fingerprint, is_active, json(system_info), last_seen_at, created_at, updated_at +` + +type CreateParams struct { + ID string `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Description *string `db:"description" json:"description"` + Host *string `db:"host" json:"host"` + Port *int64 `db:"port" json:"port"` + TokenCt []byte `db:"token_ct" json:"token_ct"` + TokenNonce []byte `db:"token_nonce" json:"token_nonce"` + TokenHint *string `db:"token_hint" json:"token_hint"` +} + +func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.Agent, error) { + row := q.db.QueryRowContext(ctx, create, + arg.ID, + arg.Name, + arg.Description, + arg.Host, + arg.Port, + arg.TokenCt, + arg.TokenNonce, + arg.TokenHint, + ) + var i models.Agent + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Host, + &i.Port, + &i.TokenCt, + &i.TokenNonce, + &i.TokenHint, + &i.Fingerprint, + &i.IsActive, + &i.SystemInfo, + &i.LastSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const delete = `-- name: Delete :exec +DELETE FROM agents WHERE id=? +` + +func (q *Queries) Delete(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, delete, id) + return err +} + +const getAll = `-- name: GetAll :many +SELECT id, name, description, host, port, token_ct, token_nonce, token_hint, fingerprint, is_active, json(system_info), last_seen_at, created_at, updated_at FROM agents +` + +func (q *Queries) GetAll(ctx context.Context) ([]*models.Agent, error) { + rows, err := q.db.QueryContext(ctx, getAll) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*models.Agent{} + for rows.Next() { + var i models.Agent + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Host, + &i.Port, + &i.TokenCt, + &i.TokenNonce, + &i.TokenHint, + &i.Fingerprint, + &i.IsActive, + &i.SystemInfo, + &i.LastSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getByID = `-- name: GetByID :one +SELECT id, name, description, host, port, token_ct, token_nonce, token_hint, fingerprint, is_active, json(system_info), last_seen_at, created_at, updated_at FROM agents WHERE id=? LIMIT 1 +` + +func (q *Queries) GetByID(ctx context.Context, id string) (*models.Agent, error) { + row := q.db.QueryRowContext(ctx, getByID, id) + var i models.Agent + err := row.Scan( + &i.ID, + &i.Name, + &i.Description, + &i.Host, + &i.Port, + &i.TokenCt, + &i.TokenNonce, + &i.TokenHint, + &i.Fingerprint, + &i.IsActive, + &i.SystemInfo, + &i.LastSeenAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} diff --git a/internal/store/repos/repo_agents/constants_gen.go b/internal/store/repos/repo_agents/constants_gen.go new file mode 100755 index 0000000..2284222 --- /dev/null +++ b/internal/store/repos/repo_agents/constants_gen.go @@ -0,0 +1,75 @@ +// Code generated by pgxgen. DO NOT EDIT. +// versions: +// +// pgxgen v0.3.11 +package repo_agents + +import ( + "strings" + + "github.com/gobeam/stringy" +) + +type TableName string + +func (s TableName) String() string { return string(s) } + +const ( + TableNameAgents TableName = "agents" +) + +type ColumnName string + +func (s ColumnName) String() string { return string(s) } + +func (s ColumnName) StructName() string { + v := stringy.New(string(s)).CamelCase().Get() + v = stringy.New(v).UcFirst() + return strings.ReplaceAll(v, "Id", "ID") +} + +type ColumnNames []ColumnName + +func (s ColumnNames) Strings() []string { + res := make([]string, len(s)) + for idx, colName := range s { + res[idx] = colName.String() + } + return res +} + +const ( + ColumnNameAgentsId ColumnName = "id" + ColumnNameAgentsName ColumnName = "name" + ColumnNameAgentsDescription ColumnName = "description" + ColumnNameAgentsHost ColumnName = "host" + ColumnNameAgentsPort ColumnName = "port" + ColumnNameAgentsTokenCt ColumnName = "token_ct" + ColumnNameAgentsTokenNonce ColumnName = "token_nonce" + ColumnNameAgentsTokenHint ColumnName = "token_hint" + ColumnNameAgentsFingerprint ColumnName = "fingerprint" + ColumnNameAgentsIsActive ColumnName = "is_active" + ColumnNameAgentsSystemInfo ColumnName = "system_info" + ColumnNameAgentsLastSeenAt ColumnName = "last_seen_at" + ColumnNameAgentsCreatedAt ColumnName = "created_at" + ColumnNameAgentsUpdatedAt ColumnName = "updated_at" +) + +func AgentsColumnNames() ColumnNames { + return ColumnNames{ + ColumnNameAgentsId, + ColumnNameAgentsName, + ColumnNameAgentsDescription, + ColumnNameAgentsHost, + ColumnNameAgentsPort, + ColumnNameAgentsTokenCt, + ColumnNameAgentsTokenNonce, + ColumnNameAgentsTokenHint, + ColumnNameAgentsFingerprint, + ColumnNameAgentsIsActive, + ColumnNameAgentsSystemInfo, + ColumnNameAgentsLastSeenAt, + ColumnNameAgentsCreatedAt, + ColumnNameAgentsUpdatedAt, + } +} diff --git a/internal/store/repos/repo_agents/db.go b/internal/store/repos/repo_agents/db.go new file mode 100644 index 0000000..cb62d9d --- /dev/null +++ b/internal/store/repos/repo_agents/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repo_agents + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/store/repos/repo_agents/querier.go b/internal/store/repos/repo_agents/querier.go new file mode 100644 index 0000000..969251d --- /dev/null +++ b/internal/store/repos/repo_agents/querier.go @@ -0,0 +1,20 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repo_agents + +import ( + "context" + + "github.com/sxwebdev/sentinel/internal/models" +) + +type Querier interface { + Create(ctx context.Context, arg CreateParams) (*models.Agent, error) + Delete(ctx context.Context, id string) error + GetAll(ctx context.Context) ([]*models.Agent, error) + GetByID(ctx context.Context, id string) (*models.Agent, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/store/repos/repo_incidents/constants_gen.go b/internal/store/repos/repo_incidents/constants_gen.go new file mode 100755 index 0000000..7a0af43 --- /dev/null +++ b/internal/store/repos/repo_incidents/constants_gen.go @@ -0,0 +1,65 @@ +// Code generated by pgxgen. DO NOT EDIT. +// versions: +// +// pgxgen v0.3.11 +package repo_incidents + +import ( + "strings" + + "github.com/gobeam/stringy" +) + +type TableName string + +func (s TableName) String() string { return string(s) } + +const ( + TableNameIncidents TableName = "incidents" +) + +type ColumnName string + +func (s ColumnName) String() string { return string(s) } + +func (s ColumnName) StructName() string { + v := stringy.New(string(s)).CamelCase().Get() + v = stringy.New(v).UcFirst() + return strings.ReplaceAll(v, "Id", "ID") +} + +type ColumnNames []ColumnName + +func (s ColumnNames) Strings() []string { + res := make([]string, len(s)) + for idx, colName := range s { + res[idx] = colName.String() + } + return res +} + +const ( + ColumnNameIncidentsId ColumnName = "id" + ColumnNameIncidentsServiceId ColumnName = "service_id" + ColumnNameIncidentsStartTime ColumnName = "start_time" + ColumnNameIncidentsEndTime ColumnName = "end_time" + ColumnNameIncidentsError ColumnName = "error" + ColumnNameIncidentsDurationNs ColumnName = "duration_ns" + ColumnNameIncidentsResolved ColumnName = "resolved" + ColumnNameIncidentsCreatedAt ColumnName = "created_at" + ColumnNameIncidentsUpdatedAt ColumnName = "updated_at" +) + +func IncidentsColumnNames() ColumnNames { + return ColumnNames{ + ColumnNameIncidentsId, + ColumnNameIncidentsServiceId, + ColumnNameIncidentsStartTime, + ColumnNameIncidentsEndTime, + ColumnNameIncidentsError, + ColumnNameIncidentsDurationNs, + ColumnNameIncidentsResolved, + ColumnNameIncidentsCreatedAt, + ColumnNameIncidentsUpdatedAt, + } +} diff --git a/internal/store/repos/repo_incidents/db.go b/internal/store/repos/repo_incidents/db.go new file mode 100644 index 0000000..c1d2e25 --- /dev/null +++ b/internal/store/repos/repo_incidents/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repo_incidents + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/store/repos/repo_incidents/incidents.sql.go b/internal/store/repos/repo_incidents/incidents.sql.go new file mode 100644 index 0000000..2ddc7bf --- /dev/null +++ b/internal/store/repos/repo_incidents/incidents.sql.go @@ -0,0 +1,19 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: incidents.sql + +package repo_incidents + +import ( + "context" +) + +const deleteByServiceID = `-- name: DeleteByServiceID :exec +DELETE FROM incidents WHERE service_id=? +` + +func (q *Queries) DeleteByServiceID(ctx context.Context, serviceID string) error { + _, err := q.db.ExecContext(ctx, deleteByServiceID, serviceID) + return err +} diff --git a/internal/store/repos/repo_incidents/incidents_gen.sql.go b/internal/store/repos/repo_incidents/incidents_gen.sql.go new file mode 100644 index 0000000..30bb715 --- /dev/null +++ b/internal/store/repos/repo_incidents/incidents_gen.sql.go @@ -0,0 +1,121 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: incidents_gen.sql + +package repo_incidents + +import ( + "context" + "time" + + "github.com/sxwebdev/sentinel/internal/models" +) + +const create = `-- name: Create :one +INSERT INTO incidents (id, service_id, start_time, end_time, error, duration_ns, resolved) + VALUES (?, ?, ?, ?, ?, ?, ?) + RETURNING id, service_id, start_time, end_time, error, duration_ns, resolved, created_at, updated_at +` + +type CreateParams struct { + ID string `db:"id" json:"id"` + ServiceID string `db:"service_id" json:"service_id"` + StartTime time.Time `db:"start_time" json:"start_time"` + EndTime *time.Time `db:"end_time" json:"end_time"` + Error string `db:"error" json:"error"` + DurationNs *int64 `db:"duration_ns" json:"duration_ns"` + Resolved bool `db:"resolved" json:"resolved"` +} + +func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.Incident, error) { + row := q.db.QueryRowContext(ctx, create, + arg.ID, + arg.ServiceID, + arg.StartTime, + arg.EndTime, + arg.Error, + arg.DurationNs, + arg.Resolved, + ) + var i models.Incident + err := row.Scan( + &i.ID, + &i.ServiceID, + &i.StartTime, + &i.EndTime, + &i.Error, + &i.DurationNs, + &i.Resolved, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const delete = `-- name: Delete :exec +DELETE FROM incidents WHERE id=? +` + +func (q *Queries) Delete(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, delete, id) + return err +} + +const getAll = `-- name: GetAll :many +SELECT id, service_id, start_time, end_time, error, duration_ns, resolved, created_at, updated_at FROM incidents +` + +func (q *Queries) GetAll(ctx context.Context) ([]*models.Incident, error) { + rows, err := q.db.QueryContext(ctx, getAll) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*models.Incident{} + for rows.Next() { + var i models.Incident + if err := rows.Scan( + &i.ID, + &i.ServiceID, + &i.StartTime, + &i.EndTime, + &i.Error, + &i.DurationNs, + &i.Resolved, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getByID = `-- name: GetByID :one +SELECT id, service_id, start_time, end_time, error, duration_ns, resolved, created_at, updated_at FROM incidents WHERE id=? LIMIT 1 +` + +func (q *Queries) GetByID(ctx context.Context, id string) (*models.Incident, error) { + row := q.db.QueryRowContext(ctx, getByID, id) + var i models.Incident + err := row.Scan( + &i.ID, + &i.ServiceID, + &i.StartTime, + &i.EndTime, + &i.Error, + &i.DurationNs, + &i.Resolved, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} diff --git a/internal/store/repos/repo_incidents/querier.go b/internal/store/repos/repo_incidents/querier.go new file mode 100644 index 0000000..19c4e9f --- /dev/null +++ b/internal/store/repos/repo_incidents/querier.go @@ -0,0 +1,21 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repo_incidents + +import ( + "context" + + "github.com/sxwebdev/sentinel/internal/models" +) + +type Querier interface { + Create(ctx context.Context, arg CreateParams) (*models.Incident, error) + Delete(ctx context.Context, id string) error + DeleteByServiceID(ctx context.Context, serviceID string) error + GetAll(ctx context.Context) ([]*models.Incident, error) + GetByID(ctx context.Context, id string) (*models.Incident, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/store/repos/repo_service_states/constants_gen.go b/internal/store/repos/repo_service_states/constants_gen.go new file mode 100755 index 0000000..0baf26d --- /dev/null +++ b/internal/store/repos/repo_service_states/constants_gen.go @@ -0,0 +1,71 @@ +// Code generated by pgxgen. DO NOT EDIT. +// versions: +// +// pgxgen v0.3.11 +package repo_service_states + +import ( + "strings" + + "github.com/gobeam/stringy" +) + +type TableName string + +func (s TableName) String() string { return string(s) } + +const ( + TableNameServiceStates TableName = "service_states" +) + +type ColumnName string + +func (s ColumnName) String() string { return string(s) } + +func (s ColumnName) StructName() string { + v := stringy.New(string(s)).CamelCase().Get() + v = stringy.New(v).UcFirst() + return strings.ReplaceAll(v, "Id", "ID") +} + +type ColumnNames []ColumnName + +func (s ColumnNames) Strings() []string { + res := make([]string, len(s)) + for idx, colName := range s { + res[idx] = colName.String() + } + return res +} + +const ( + ColumnNameServiceStatesId ColumnName = "id" + ColumnNameServiceStatesServiceId ColumnName = "service_id" + ColumnNameServiceStatesStatus ColumnName = "status" + ColumnNameServiceStatesLastCheck ColumnName = "last_check" + ColumnNameServiceStatesNextCheck ColumnName = "next_check" + ColumnNameServiceStatesLastError ColumnName = "last_error" + ColumnNameServiceStatesConsecutiveFails ColumnName = "consecutive_fails" + ColumnNameServiceStatesConsecutiveSuccess ColumnName = "consecutive_success" + ColumnNameServiceStatesTotalChecks ColumnName = "total_checks" + ColumnNameServiceStatesResponseTimeNs ColumnName = "response_time_ns" + ColumnNameServiceStatesCreatedAt ColumnName = "created_at" + ColumnNameServiceStatesUpdatedAt ColumnName = "updated_at" +) + +func ServiceStatesColumnNames() ColumnNames { + return ColumnNames{ + ColumnNameServiceStatesId, + ColumnNameServiceStatesServiceId, + ColumnNameServiceStatesStatus, + ColumnNameServiceStatesLastCheck, + ColumnNameServiceStatesNextCheck, + ColumnNameServiceStatesLastError, + ColumnNameServiceStatesConsecutiveFails, + ColumnNameServiceStatesConsecutiveSuccess, + ColumnNameServiceStatesTotalChecks, + ColumnNameServiceStatesResponseTimeNs, + ColumnNameServiceStatesCreatedAt, + ColumnNameServiceStatesUpdatedAt, + } +} diff --git a/internal/store/repos/repo_service_states/db.go b/internal/store/repos/repo_service_states/db.go new file mode 100644 index 0000000..2904e8f --- /dev/null +++ b/internal/store/repos/repo_service_states/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repo_service_states + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/store/repos/repo_service_states/querier.go b/internal/store/repos/repo_service_states/querier.go new file mode 100644 index 0000000..3c9c4c9 --- /dev/null +++ b/internal/store/repos/repo_service_states/querier.go @@ -0,0 +1,20 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repo_service_states + +import ( + "context" + + "github.com/sxwebdev/sentinel/internal/models" +) + +type Querier interface { + Create(ctx context.Context, arg CreateParams) (*models.ServiceState, error) + Delete(ctx context.Context, id string) error + DeleteByServiceID(ctx context.Context, serviceID string) error + GetByID(ctx context.Context, id string) (*models.ServiceState, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/store/repos/repo_service_states/service_states.sql.go b/internal/store/repos/repo_service_states/service_states.sql.go new file mode 100644 index 0000000..43a8cde --- /dev/null +++ b/internal/store/repos/repo_service_states/service_states.sql.go @@ -0,0 +1,19 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: service_states.sql + +package repo_service_states + +import ( + "context" +) + +const deleteByServiceID = `-- name: DeleteByServiceID :exec +DELETE FROM service_states WHERE service_id=? +` + +func (q *Queries) DeleteByServiceID(ctx context.Context, serviceID string) error { + _, err := q.db.ExecContext(ctx, deleteByServiceID, serviceID) + return err +} diff --git a/internal/store/repos/repo_service_states/service_states_gen.sql.go b/internal/store/repos/repo_service_states/service_states_gen.sql.go new file mode 100644 index 0000000..bdd0f84 --- /dev/null +++ b/internal/store/repos/repo_service_states/service_states_gen.sql.go @@ -0,0 +1,96 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: service_states_gen.sql + +package repo_service_states + +import ( + "context" + "time" + + "github.com/sxwebdev/sentinel/internal/models" +) + +const create = `-- name: Create :one +INSERT INTO service_states (id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time_ns) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time_ns, created_at, updated_at +` + +type CreateParams struct { + ID string `db:"id" json:"id"` + ServiceID string `db:"service_id" json:"service_id"` + Status models.ServiceStatus `db:"status" json:"status"` + LastCheck *time.Time `db:"last_check" json:"last_check"` + NextCheck *time.Time `db:"next_check" json:"next_check"` + LastError *string `db:"last_error" json:"last_error"` + ConsecutiveFails int64 `db:"consecutive_fails" json:"consecutive_fails"` + ConsecutiveSuccess int64 `db:"consecutive_success" json:"consecutive_success"` + TotalChecks int64 `db:"total_checks" json:"total_checks"` + ResponseTimeNs *int64 `db:"response_time_ns" json:"response_time_ns"` +} + +func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.ServiceState, error) { + row := q.db.QueryRowContext(ctx, create, + arg.ID, + arg.ServiceID, + arg.Status, + arg.LastCheck, + arg.NextCheck, + arg.LastError, + arg.ConsecutiveFails, + arg.ConsecutiveSuccess, + arg.TotalChecks, + arg.ResponseTimeNs, + ) + var i models.ServiceState + err := row.Scan( + &i.ID, + &i.ServiceID, + &i.Status, + &i.LastCheck, + &i.NextCheck, + &i.LastError, + &i.ConsecutiveFails, + &i.ConsecutiveSuccess, + &i.TotalChecks, + &i.ResponseTimeNs, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const delete = `-- name: Delete :exec +DELETE FROM service_states WHERE id=? +` + +func (q *Queries) Delete(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, delete, id) + return err +} + +const getByID = `-- name: GetByID :one +SELECT id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time_ns, created_at, updated_at FROM service_states WHERE id=? LIMIT 1 +` + +func (q *Queries) GetByID(ctx context.Context, id string) (*models.ServiceState, error) { + row := q.db.QueryRowContext(ctx, getByID, id) + var i models.ServiceState + err := row.Scan( + &i.ID, + &i.ServiceID, + &i.Status, + &i.LastCheck, + &i.NextCheck, + &i.LastError, + &i.ConsecutiveFails, + &i.ConsecutiveSuccess, + &i.TotalChecks, + &i.ResponseTimeNs, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} diff --git a/internal/store/repos/repo_services/constants_gen.go b/internal/store/repos/repo_services/constants_gen.go new file mode 100755 index 0000000..91193fb --- /dev/null +++ b/internal/store/repos/repo_services/constants_gen.go @@ -0,0 +1,69 @@ +// Code generated by pgxgen. DO NOT EDIT. +// versions: +// +// pgxgen v0.3.11 +package repo_services + +import ( + "strings" + + "github.com/gobeam/stringy" +) + +type TableName string + +func (s TableName) String() string { return string(s) } + +const ( + TableNameServices TableName = "services" +) + +type ColumnName string + +func (s ColumnName) String() string { return string(s) } + +func (s ColumnName) StructName() string { + v := stringy.New(string(s)).CamelCase().Get() + v = stringy.New(v).UcFirst() + return strings.ReplaceAll(v, "Id", "ID") +} + +type ColumnNames []ColumnName + +func (s ColumnNames) Strings() []string { + res := make([]string, len(s)) + for idx, colName := range s { + res[idx] = colName.String() + } + return res +} + +const ( + ColumnNameServicesId ColumnName = "id" + ColumnNameServicesName ColumnName = "name" + ColumnNameServicesProtocol ColumnName = "protocol" + ColumnNameServicesInterval ColumnName = "interval" + ColumnNameServicesTimeout ColumnName = "timeout" + ColumnNameServicesRetries ColumnName = "retries" + ColumnNameServicesTags ColumnName = "tags" + ColumnNameServicesConfig ColumnName = "config" + ColumnNameServicesIsEnabled ColumnName = "is_enabled" + ColumnNameServicesCreatedAt ColumnName = "created_at" + ColumnNameServicesUpdatedAt ColumnName = "updated_at" +) + +func ServicesColumnNames() ColumnNames { + return ColumnNames{ + ColumnNameServicesId, + ColumnNameServicesName, + ColumnNameServicesProtocol, + ColumnNameServicesInterval, + ColumnNameServicesTimeout, + ColumnNameServicesRetries, + ColumnNameServicesTags, + ColumnNameServicesConfig, + ColumnNameServicesIsEnabled, + ColumnNameServicesCreatedAt, + ColumnNameServicesUpdatedAt, + } +} diff --git a/internal/store/repos/repo_services/custom.go b/internal/store/repos/repo_services/custom.go new file mode 100644 index 0000000..715bee1 --- /dev/null +++ b/internal/store/repos/repo_services/custom.go @@ -0,0 +1,37 @@ +package repo_services + +import ( + "context" + "database/sql" + + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/pkg/dbutils" +) + +type ICustomQuerier interface { + Querier + GetViewByID(ctx context.Context, id string) (*models.ServiceFullView, error) + Update(ctx context.Context, id string, service UpdateServiceRequest) (*models.ServiceFullView, error) + FindView(ctx context.Context, params FindParams) (*dbutils.FindResponseWithCount[*models.ServiceFullView], error) +} + +type CustomQueries struct { + *Queries + db DBTX +} + +func NewCustom(db DBTX) *CustomQueries { + return &CustomQueries{ + Queries: New(db), + db: db, + } +} + +func (s *CustomQueries) WithTx(tx *sql.Tx) *CustomQueries { + return &CustomQueries{ + Queries: New(tx), + db: tx, + } +} + +var _ ICustomQuerier = (*CustomQueries)(nil) diff --git a/internal/store/repos/repo_services/db.go b/internal/store/repos/repo_services/db.go new file mode 100644 index 0000000..8ece73c --- /dev/null +++ b/internal/store/repos/repo_services/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repo_services + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/store/repos/repo_services/find.go b/internal/store/repos/repo_services/find.go new file mode 100644 index 0000000..c9a70c0 --- /dev/null +++ b/internal/store/repos/repo_services/find.go @@ -0,0 +1,154 @@ +package repo_services + +import ( + "context" + "fmt" + "strings" + + "github.com/georgysavva/scany/v2/sqlscan" + "github.com/huandu/go-sqlbuilder" + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/pkg/dbutils" +) + +func findServicesBuilder(params FindParams, col ...string) *sqlbuilder.SelectBuilder { + sb := sqlbuilder.NewSelectBuilder() + sb.Select(col...) + sb.From("services s") + + if params.Name != "" { + sb.Where(sb.Like("s.name", "%"+params.Name+"%")) + } + + if params.Protocol != "" { + sb.Where(sb.Equal("s.protocol", params.Protocol)) + } + + if params.IsEnabled != nil { + sb.Where(sb.Equal("s.is_enabled", *params.IsEnabled)) + } + + if params.Status != "" { + switch params.Status { + case "up": + sb.Where(sb.Equal("ss.status", models.StatusUp)) + case "down": + sb.Where(sb.Equal("ss.status", models.StatusDown)) + } + } + + if len(params.Tags) > 0 { + var tagConditions []string + for _, tag := range params.Tags { + tagConditions = append(tagConditions, + fmt.Sprintf("EXISTS (SELECT 1 FROM json_each(s.tags) WHERE json_each.value = %s)", + sb.Args.Add(tag))) + } + + if len(tagConditions) > 0 { + sb.Where(fmt.Sprintf("(%s)", strings.Join(tagConditions, " AND "))) + } + } + + return sb +} + +type FindParams struct { + Name string + IsEnabled *bool + Protocol string + Tags []string + Status string // e.g. "up", "down" + OrderBy string + Page *uint32 + PageSize *uint32 +} + +// GetAllServices finds all services using ORM +func (s *CustomQueries) FindView(ctx context.Context, params FindParams) (*dbutils.FindResponseWithCount[*models.ServiceFullView], error) { + sb := findServicesBuilder( + params, + "s.id", + "s.name", + "s.protocol", + "s.interval", + "s.timeout", + "s.retries", + "s.tags", + "s.config", + "s.is_enabled", + "s.created_at", + "s.updated_at", + "count(incidents.id) as total_incidents", + "sum(case when incidents.resolved = 0 then 1 else 0 end) as active_incidents", + "ss.status", + "ss.last_check", + "ss.next_check", + "ss.last_error", + "ss.consecutive_fails", + "ss.consecutive_success", + "ss.total_checks", + "ss.response_time_ns", + ) + sb.JoinWithOption(sqlbuilder.LeftJoin, "incidents", "s.id = incidents.service_id") + sb.JoinWithOption(sqlbuilder.LeftJoin, "service_states ss", "s.id = ss.service_id") + sb.GroupBy("s.id") + + if params.OrderBy != "" { + // Add table prefix for common column names to avoid ambiguity + orderBy := params.OrderBy + switch orderBy { + case "created_at": + orderBy = "s.created_at" + case "updated_at": + orderBy = "s.updated_at" + case "name": + orderBy = "s.name" + case "protocol": + orderBy = "s.protocol" + case "status": + orderBy = "ss.status" + case "last_check": + orderBy = "ss.last_check" + } + sb.OrderBy(orderBy) + } else { + sb.OrderBy("s.name") + } + + limit, offset, err := dbutils.Pagination(params.Page, params.PageSize) + if err != nil { + return nil, err + } + sb.Limit(int(limit)).Offset(int(offset)) + + itemsRows := []serviceViewRow{} + sql, args := sb.Build() + if err := sqlscan.Select(ctx, s.db, &itemsRows, sql, args...); err != nil { + return nil, err + } + + // Get total count of services + countQuery := findServicesBuilder(params, "count(*)") + countQuery.JoinWithOption(sqlbuilder.LeftJoin, "service_states ss", "s.id = ss.service_id") + + var totalCount uint32 + countSQL, countArgs := countQuery.Build() + if err := sqlscan.Get(ctx, s.db, &totalCount, countSQL, countArgs...); err != nil { + return nil, err + } + + items := make([]*models.ServiceFullView, 0, len(itemsRows)) + for i := range itemsRows { + item, err := rowToService(&itemsRows[i]) + if err != nil { + return nil, fmt.Errorf("failed to convert row to service: %w", err) + } + items = append(items, item) + } + + return &dbutils.FindResponseWithCount[*models.ServiceFullView]{ + Count: totalCount, + Items: items, + }, nil +} diff --git a/internal/store/repos/repo_services/get.go b/internal/store/repos/repo_services/get.go new file mode 100644 index 0000000..77f9289 --- /dev/null +++ b/internal/store/repos/repo_services/get.go @@ -0,0 +1,62 @@ +package repo_services + +import ( + "context" + "database/sql" + "errors" + "fmt" + + "github.com/georgysavva/scany/v2/sqlscan" + "github.com/huandu/go-sqlbuilder" + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/storecmn" +) + +// GetViewByID finds service by ID +func (s *CustomQueries) GetViewByID(ctx context.Context, id string) (*models.ServiceFullView, error) { + sb := sqlbuilder.NewSelectBuilder() + sb.Select( + "s.id", + "s.name", + "s.protocol", + "s.interval", + "s.timeout", + "s.retries", + "s.tags", + "s.config", + "s.is_enabled", + "s.created_at", + "s.updated_at", + "count(incidents.id) as total_incidents", + "sum(case when incidents.resolved = 0 then 1 else 0 end) as active_incidents", + "ss.status", + "ss.last_check", + "ss.next_check", + "ss.last_error", + "ss.consecutive_fails", + "ss.consecutive_success", + "ss.total_checks", + "ss.response_time_ns", + ) + sb.From("services s") + sb.JoinWithOption(sqlbuilder.LeftJoin, "incidents", "s.id = incidents.service_id") + sb.JoinWithOption(sqlbuilder.LeftJoin, "service_states ss", "s.id = ss.service_id") + sb.Where(sb.Equal("s.id", id)) + sb.GroupBy("s.id") + + var itemRow serviceViewRow + query, args := sb.Build() + if err := sqlscan.Get(ctx, s.db, &itemRow, query, args...); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, storecmn.ErrNotFound + } + return nil, fmt.Errorf("failed to scan service: %w", err) + } + + item, err := rowToService(&itemRow) + if err != nil { + return nil, fmt.Errorf("failed to convert row to service: %w", err) + } + + return item, nil +} diff --git a/internal/store/repos/repo_services/querier.go b/internal/store/repos/repo_services/querier.go new file mode 100644 index 0000000..070e575 --- /dev/null +++ b/internal/store/repos/repo_services/querier.go @@ -0,0 +1,20 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repo_services + +import ( + "context" + + "github.com/sxwebdev/sentinel/internal/models" +) + +type Querier interface { + Create(ctx context.Context, arg CreateParams) (*models.Service, error) + Delete(ctx context.Context, id string) error + Exist(ctx context.Context, id string) (int64, error) + GetByID(ctx context.Context, id string) (*models.Service, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/store/repos/repo_services/services.sql.go b/internal/store/repos/repo_services/services.sql.go new file mode 100644 index 0000000..fd17c4e --- /dev/null +++ b/internal/store/repos/repo_services/services.sql.go @@ -0,0 +1,21 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: services.sql + +package repo_services + +import ( + "context" +) + +const exist = `-- name: Exist :one +SELECT EXISTS (SELECT 1 FROM services WHERE id = ? LIMIT 1) +` + +func (q *Queries) Exist(ctx context.Context, id string) (int64, error) { + row := q.db.QueryRowContext(ctx, exist, id) + var column_1 int64 + err := row.Scan(&column_1) + return column_1, err +} diff --git a/internal/store/repos/repo_services/services_gen.sql.go b/internal/store/repos/repo_services/services_gen.sql.go new file mode 100644 index 0000000..bbebe4b --- /dev/null +++ b/internal/store/repos/repo_services/services_gen.sql.go @@ -0,0 +1,92 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: services_gen.sql + +package repo_services + +import ( + "context" + + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/pkg/dbutils" +) + +const create = `-- name: Create :one +INSERT INTO services (id, name, protocol, interval, timeout, retries, tags, config, is_enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id, name, protocol, interval, timeout, retries, json(tags), json(config), is_enabled, created_at, updated_at +` + +type CreateParams struct { + ID string `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Protocol models.ServiceProtocolType `db:"protocol" json:"protocol"` + Interval dbutils.Duration `db:"interval" json:"interval"` + Timeout dbutils.Duration `db:"timeout" json:"timeout"` + Retries int64 `db:"retries" json:"retries"` + Tags dbutils.JSONField `db:"tags" json:"tags"` + Config dbutils.JSONField `db:"config" json:"config"` + IsEnabled bool `db:"is_enabled" json:"is_enabled"` +} + +func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.Service, error) { + row := q.db.QueryRowContext(ctx, create, + arg.ID, + arg.Name, + arg.Protocol, + arg.Interval, + arg.Timeout, + arg.Retries, + arg.Tags, + arg.Config, + arg.IsEnabled, + ) + var i models.Service + err := row.Scan( + &i.ID, + &i.Name, + &i.Protocol, + &i.Interval, + &i.Timeout, + &i.Retries, + &i.Tags, + &i.Config, + &i.IsEnabled, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const delete = `-- name: Delete :exec +DELETE FROM services WHERE id=? +` + +func (q *Queries) Delete(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, delete, id) + return err +} + +const getByID = `-- name: GetByID :one +SELECT id, name, protocol, interval, timeout, retries, json(tags), json(config), is_enabled, created_at, updated_at FROM services WHERE id=? LIMIT 1 +` + +func (q *Queries) GetByID(ctx context.Context, id string) (*models.Service, error) { + row := q.db.QueryRowContext(ctx, getByID, id) + var i models.Service + err := row.Scan( + &i.ID, + &i.Name, + &i.Protocol, + &i.Interval, + &i.Timeout, + &i.Retries, + &i.Tags, + &i.Config, + &i.IsEnabled, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} diff --git a/internal/store/repos/repo_services/types.go b/internal/store/repos/repo_services/types.go new file mode 100644 index 0000000..2f34900 --- /dev/null +++ b/internal/store/repos/repo_services/types.go @@ -0,0 +1,95 @@ +package repo_services + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/utils" +) + +type serviceViewRow struct { + ID string + Name string + Protocol string + Interval string + Timeout string + Retries int + Tags string + Config string + IsEnabled bool + CreatedAt time.Time + UpdatedAt time.Time + ActiveIncidents int + TotalIncidents int + Status models.ServiceStatus + LastCheck *time.Time + NextCheck *time.Time + LastError *string + ConsecutiveFails int + ConsecutiveSuccess int + TotalChecks int + ResponseTimeNS *int64 +} + +// rowToService converts a ServiceRow to Service +func rowToService(row *serviceViewRow) (*models.ServiceFullView, error) { + interval, err := time.ParseDuration(row.Interval) + if err != nil { + return nil, fmt.Errorf("failed to parse interval: %w", err) + } + + timeout, err := time.ParseDuration(row.Timeout) + if err != nil { + return nil, fmt.Errorf("failed to parse timeout: %w", err) + } + + var tags []string + if err := json.Unmarshal([]byte(row.Tags), &tags); err != nil { + return nil, fmt.Errorf("failed to unmarshal tags: %w", err) + } + + var config map[string]any + if err := json.Unmarshal([]byte(row.Config), &config); err != nil { + return nil, fmt.Errorf("failed to unmarshal config: %w", err) + } + + svc := &models.ServiceFullView{ + ID: row.ID, + Name: row.Name, + Protocol: models.ServiceProtocolType(row.Protocol), + Interval: interval, + Timeout: timeout, + Retries: row.Retries, + Tags: tags, + Config: config, + IsEnabled: row.IsEnabled, + CreatedAt: row.CreatedAt, + UpdatedAt: row.UpdatedAt, + TotalIncidents: row.TotalIncidents, + ActiveIncidents: row.ActiveIncidents, + Status: row.Status, + LastCheck: row.LastCheck, + NextCheck: row.NextCheck, + LastError: row.LastError, + ConsecutiveFails: row.ConsecutiveFails, + ConsecutiveSuccess: row.ConsecutiveSuccess, + TotalChecks: row.TotalChecks, + } + + if row.ResponseTimeNS != nil { + svc.ResponseTime = utils.Pointer(time.Duration(*row.ResponseTimeNS)) + } + + return svc, nil +} + +// durationToNS converts a duration pointer to nanoseconds +// func durationToNS(d *time.Duration) *int64 { +// if d == nil { +// return nil +// } +// ns := d.Nanoseconds() +// return &ns +// } diff --git a/internal/store/repos/repo_services/update.go b/internal/store/repos/repo_services/update.go new file mode 100644 index 0000000..8d52c79 --- /dev/null +++ b/internal/store/repos/repo_services/update.go @@ -0,0 +1,71 @@ +package repo_services + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/huandu/go-sqlbuilder" + "github.com/sxwebdev/sentinel/internal/models" +) + +type UpdateServiceRequest struct { + Name string `json:"name" yaml:"name"` + Protocol models.ServiceProtocolType `json:"protocol" yaml:"protocol"` + Interval time.Duration `json:"interval" yaml:"interval" swaggertype:"primitive,integer"` + Timeout time.Duration `json:"timeout" yaml:"timeout" swaggertype:"primitive,integer"` + Retries int64 `json:"retries" yaml:"retries"` + Tags []string `json:"tags" yaml:"tags"` + Config map[string]any `json:"config" yaml:"config"` + IsEnabled bool `json:"is_enabled" yaml:"is_enabled"` +} + +func (s *CustomQueries) Update(ctx context.Context, id string, service UpdateServiceRequest) (*models.ServiceFullView, error) { + ub := sqlbuilder.NewUpdateBuilder() + ub.Update("services") + + tagsJSON, err := json.Marshal(service.Tags) + if err != nil { + return nil, fmt.Errorf("failed to marshal tags: %w", err) + } + + configJSON, err := json.Marshal(service.Config) + if err != nil { + return nil, fmt.Errorf("failed to marshal config: %w", err) + } + + // Prepare all fields for update + assignments := []string{ + ub.Assign("name", service.Name), + ub.Assign("protocol", service.Protocol), + ub.Assign("interval", service.Interval.String()), + ub.Assign("timeout", service.Timeout.String()), + ub.Assign("retries", service.Retries), + ub.Assign("tags", string(tagsJSON)), + ub.Assign("config", string(configJSON)), + ub.Assign("is_enabled", service.IsEnabled), + ub.Assign("updated_at", time.Now()), + } + + // Set all assignments at once + ub.Set(assignments...) + ub.Where(ub.Equal("id", id)) + + sql, args := ub.Build() + result, err := s.db.ExecContext(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("failed to update service: %w", err) + } + + rowsAffected, err := result.RowsAffected() + if err != nil { + return nil, fmt.Errorf("failed to get rows affected: %w", err) + } + + if rowsAffected == 0 { + return nil, fmt.Errorf("service not found") + } + + return s.GetViewByID(ctx, id) +} diff --git a/internal/store/repos/repos.go b/internal/store/repos/repos.go new file mode 100644 index 0000000..427ae77 --- /dev/null +++ b/internal/store/repos/repos.go @@ -0,0 +1,70 @@ +package repos + +import ( + "database/sql" + + "github.com/sxwebdev/sentinel/internal/store/repos/repo_agents" + "github.com/sxwebdev/sentinel/internal/store/repos/repo_incidents" + "github.com/sxwebdev/sentinel/internal/store/repos/repo_service_states" + "github.com/sxwebdev/sentinel/internal/store/repos/repo_services" +) + +type Repos struct { + agents *repo_agents.Queries + services *repo_services.CustomQueries + serviceStates *repo_service_states.Queries + incidents *repo_incidents.Queries +} + +func New(sqlite *sql.DB) *Repos { + return &Repos{ + agents: repo_agents.New(sqlite), + services: repo_services.NewCustom(sqlite), + serviceStates: repo_service_states.New(sqlite), + incidents: repo_incidents.New(sqlite), + } +} + +// Agents returns repo for agents +func (s *Repos) Agents(opts ...Option) repo_agents.Querier { + options := parseOptions(opts...) + + if options.Tx != nil { + return s.agents.WithTx(options.Tx) + } + + return s.agents +} + +// Services returns repo for services +func (s *Repos) Services(opts ...Option) repo_services.ICustomQuerier { + options := parseOptions(opts...) + + if options.Tx != nil { + return s.services.WithTx(options.Tx) + } + + return s.services +} + +// ServiceStates returns repo for service states +func (s *Repos) ServiceStates(opts ...Option) repo_service_states.Querier { + options := parseOptions(opts...) + + if options.Tx != nil { + return s.serviceStates.WithTx(options.Tx) + } + + return s.serviceStates +} + +// Incidents returns repo for incidents +func (s *Repos) Incidents(opts ...Option) repo_incidents.Querier { + options := parseOptions(opts...) + + if options.Tx != nil { + return s.incidents.WithTx(options.Tx) + } + + return s.incidents +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..bc85cf4 --- /dev/null +++ b/internal/store/store.go @@ -0,0 +1,22 @@ +package store + +import ( + "database/sql" + + "github.com/sxwebdev/sentinel/internal/store/repos" +) + +type Store struct { + *repos.Repos + + sqlite *sql.DB +} + +func New(sqlite *sql.DB) (*Store, error) { + return &Store{ + Repos: repos.New(sqlite), + sqlite: sqlite, + }, nil +} + +func (s *Store) SQLite() *sql.DB { return s.sqlite } diff --git a/internal/store/storecmn/errors.go b/internal/store/storecmn/errors.go new file mode 100644 index 0000000..c7fe739 --- /dev/null +++ b/internal/store/storecmn/errors.go @@ -0,0 +1,8 @@ +package storecmn + +import "errors" + +var ( + ErrNotFound = errors.New("not found") + ErrAlreadyExists = errors.New("already exists") +) diff --git a/internal/utils/ulid.go b/internal/utils/ulid.go new file mode 100644 index 0000000..e730c5a --- /dev/null +++ b/internal/utils/ulid.go @@ -0,0 +1,8 @@ +package utils + +import "github.com/oklog/ulid/v2" + +// GenerateULID generates a new ULID +func GenerateULID() string { + return ulid.Make().String() +} diff --git a/internal/web/dto.go b/internal/web/dto.go index 396d7ee..8372fb4 100644 --- a/internal/web/dto.go +++ b/internal/web/dto.go @@ -5,24 +5,23 @@ import ( "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/monitors" - "github.com/sxwebdev/sentinel/internal/storage" ) // DashboardStats represents dashboard statistics // // @Description Dashboard statistics type DashboardStats struct { - TotalServices int `json:"total_services" example:"10"` - ServicesUp int `json:"services_up" example:"8"` - ServicesDown int `json:"services_down" example:"1"` - ServicesUnknown int `json:"services_unknown" example:"1"` - Protocols map[storage.ServiceProtocolType]int `json:"protocols"` - ActiveIncidents int `json:"active_incidents" example:"2"` - AvgResponseTime int64 `json:"avg_response_time" example:"150"` - TotalChecks int `json:"total_checks" example:"1000"` - UptimePercentage float64 `json:"uptime_percentage" example:"95.5"` - LastCheckTime *time.Time `json:"last_check_time"` - ChecksPerMinute int `json:"checks_per_minute" example:"60"` + TotalServices int `json:"total_services" example:"10"` + ServicesUp int `json:"services_up" example:"8"` + ServicesDown int `json:"services_down" example:"1"` + ServicesUnknown int `json:"services_unknown" example:"1"` + Protocols map[models.ServiceProtocolType]int `json:"protocols"` + ActiveIncidents int `json:"active_incidents" example:"2"` + AvgResponseTime int64 `json:"avg_response_time" example:"150"` + TotalChecks int `json:"total_checks" example:"1000"` + UptimePercentage float64 `json:"uptime_percentage" example:"95.5"` + LastCheckTime *time.Time `json:"last_check_time"` + ChecksPerMinute int `json:"checks_per_minute" example:"60"` } // Incident represents an incident @@ -54,37 +53,37 @@ type ServiceStats struct { // CreateUpdateServiceRequest represents a request to create or update a service type CreateUpdateServiceRequest struct { - Name string `json:"name" example:"Web Server"` - Protocol storage.ServiceProtocolType `json:"protocol" example:"http"` - Interval uint32 `json:"interval" swaggertype:"primitive,integer" example:"60000"` - Timeout uint32 `json:"timeout" swaggertype:"primitive,integer" example:"10000"` - Retries int `json:"retries" example:"5"` - Tags []string `json:"tags" example:"web,production"` - Config monitors.Config `json:"config"` - IsEnabled bool `json:"is_enabled" example:"true"` + Name string `json:"name" example:"Web Server"` + Protocol models.ServiceProtocolType `json:"protocol" example:"http"` + Interval uint32 `json:"interval" swaggertype:"primitive,integer" example:"60000"` + Timeout uint32 `json:"timeout" swaggertype:"primitive,integer" example:"10000"` + Retries int64 `json:"retries" example:"5"` + Tags []string `json:"tags" example:"web,production"` + Config monitors.Config `json:"config"` + IsEnabled bool `json:"is_enabled" example:"true"` } // ServiceDTO represents a service for API responses type ServiceDTO struct { - ID string `json:"id" example:"service-1"` - Name string `json:"name" example:"Web Server"` - Protocol storage.ServiceProtocolType `json:"protocol" example:"http"` - Interval uint32 `json:"interval" swaggertype:"primitive,integer" example:"60000"` - Timeout uint32 `json:"timeout" swaggertype:"primitive,integer" example:"10000"` - Retries int `json:"retries" example:"5"` - Tags []string `json:"tags" example:"web,production"` - Config monitors.Config `json:"config"` - IsEnabled bool `json:"is_enabled" example:"true"` - ActiveIncidents int `json:"active_incidents" example:"2"` - TotalIncidents int `json:"total_incidents" example:"10"` - Status storage.ServiceStatus `json:"status" example:"up / down / unknown"` - LastCheck *time.Time `json:"last_check,omitempty" example:"2023-10-01T12:00:00Z"` - NextCheck *time.Time `json:"next_check,omitempty" example:"2023-10-01T12:05:00Z"` - LastError *string `json:"last_error,omitempty" example:"Connection timeout"` - ConsecutiveFails int `json:"consecutive_fails" example:"1"` - ConsecutiveSuccess int `json:"consecutive_success" example:"5"` - TotalChecks int `json:"total_checks" example:"100"` - ResponseTime uint32 `json:"response_time" swaggertype:"primitive,integer" example:"150000000"` + ID string `json:"id" example:"service-1"` + Name string `json:"name" example:"Web Server"` + Protocol models.ServiceProtocolType `json:"protocol" example:"http"` + Interval uint32 `json:"interval" swaggertype:"primitive,integer" example:"60000"` + Timeout uint32 `json:"timeout" swaggertype:"primitive,integer" example:"10000"` + Retries int `json:"retries" example:"5"` + Tags []string `json:"tags" example:"web,production"` + Config monitors.Config `json:"config"` + IsEnabled bool `json:"is_enabled" example:"true"` + ActiveIncidents int `json:"active_incidents" example:"2"` + TotalIncidents int `json:"total_incidents" example:"10"` + Status models.ServiceStatus `json:"status" example:"up / down / unknown"` + LastCheck *time.Time `json:"last_check,omitempty" example:"2023-10-01T12:00:00Z"` + NextCheck *time.Time `json:"next_check,omitempty" example:"2023-10-01T12:05:00Z"` + LastError *string `json:"last_error,omitempty" example:"Connection timeout"` + ConsecutiveFails int `json:"consecutive_fails" example:"1"` + ConsecutiveSuccess int `json:"consecutive_success" example:"5"` + TotalChecks int `json:"total_checks" example:"100"` + ResponseTime uint32 `json:"response_time" swaggertype:"primitive,integer" example:"150000000"` } type ServerInfoResponse struct { diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 62c0ddd..da1a091 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -15,6 +15,7 @@ package web import ( "context" + "encoding/json" "errors" "fmt" goHTML "html" @@ -35,7 +36,10 @@ import ( "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/monitor" "github.com/sxwebdev/sentinel/internal/receiver" + "github.com/sxwebdev/sentinel/internal/services/baseservices" + "github.com/sxwebdev/sentinel/internal/services/service" "github.com/sxwebdev/sentinel/internal/storage" + "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/upgrader" "github.com/sxwebdev/sentinel/internal/utils" "github.com/sxwebdev/sentinel/pkg/dbutils" @@ -55,6 +59,7 @@ type Server struct { validator *validator.Validate storage *storage.Storage + baseServices *baseservices.BaseServices monitorService *monitor.MonitorService receiver *receiver.Receiver upgrader *upgrader.Upgrader @@ -65,6 +70,7 @@ func NewServer( logger logger.Logger, cfg *config.ConfigHub, serverInfo models.SystemInfo, + baseServices *baseservices.BaseServices, monitorService *monitor.MonitorService, storage *storage.Storage, receiver *receiver.Receiver, @@ -86,6 +92,7 @@ func NewServer( receiver: receiver, config: cfg, app: app, + baseServices: baseServices, wsConnections: make(map[*websocket.Conn]bool), validator: validator.New(), upgrader: upgrader, @@ -116,6 +123,9 @@ func (s *Server) Start(ctx context.Context) error { go func() { addr := fmt.Sprintf("%s:%d", s.config.Server.Host, s.config.Server.Port) + + s.logger.Infof("ui available on http://%s", s.config.Server.BaseHost) + if err := s.App().Listen(addr); err != nil { errChan <- fmt.Errorf("failed to start Fiber server: %w", err) } @@ -321,7 +331,7 @@ func (s *Server) handleFindServices(c *fiber.Ctx) error { return newErrorResponse(c, fiber.StatusBadRequest, err) } - services, err := s.monitorService.FindServices(ctx, storage.FindServicesParams{ + services, err := s.baseServices.Services().FindView(ctx, service.FindParams{ Name: params.Name, Tags: params.Tags, Status: params.Status, @@ -371,7 +381,7 @@ func (s *Server) handleAPIServiceDetail(c *fiber.Ctx) error { return newErrorResponse(c, fiber.StatusBadRequest, ErrServiceIDRequired) } - targetService, err := s.monitorService.GetServiceByID(c.Context(), serviceID) + targetService, err := s.baseServices.Services().GetViewByID(c.Context(), serviceID) if err != nil { return newErrorResponse(c, fiber.StatusInternalServerError, err) } @@ -432,9 +442,13 @@ func (s *Server) handleAPIServiceIncidents(c *fiber.Ctx) error { } // First check if service exists - _, err := s.monitorService.GetServiceByID(c.Context(), serviceID) + exists, err := s.baseServices.Services().Exists(c.Context(), serviceID) if err != nil { - return newErrorResponse(c, fiber.StatusNotFound, err) + return newErrorResponse(c, fiber.StatusInternalServerError, err) + } + + if !exists { + return newErrorResponse(c, fiber.StatusNotFound, storecmn.ErrNotFound) } incidents, err := s.storage.FindIncidents(c.Context(), storage.FindIncidentsParams{ @@ -483,11 +497,15 @@ func (s *Server) handleAPIServiceStats(c *fiber.Ctx) error { } // First check if service exists - _, err = s.monitorService.GetServiceByID(c.Context(), serviceID) + exists, err := s.baseServices.Services().Exists(c.Context(), serviceID) if err != nil { return newErrorResponse(c, fiber.StatusInternalServerError, err) } + if !exists { + return newErrorResponse(c, fiber.StatusNotFound, storecmn.ErrNotFound) + } + since := time.Now().AddDate(0, 0, -days) stats, err := s.monitorService.GetServiceStats(c.Context(), serviceID, since) if err != nil { @@ -517,9 +535,13 @@ func (s *Server) handleAPIServiceCheck(c *fiber.Ctx) error { } // First check if service exists - _, err := s.monitorService.GetServiceByID(c.Context(), serviceID) + exists, err := s.baseServices.Services().Exists(c.Context(), serviceID) if err != nil { - return newErrorResponse(c, fiber.StatusNotFound, err) + return newErrorResponse(c, fiber.StatusInternalServerError, err) + } + + if !exists { + return newErrorResponse(c, fiber.StatusNotFound, storecmn.ErrNotFound) } err = s.monitorService.TriggerCheck(c.Context(), serviceID) @@ -637,9 +659,13 @@ func (s *Server) handleAPIDeleteIncident(c *fiber.Ctx) error { } // Check if service exists - _, err := s.monitorService.GetServiceByID(c.Context(), serviceID) + exists, err := s.baseServices.Services().Exists(c.Context(), serviceID) if err != nil { - return newErrorResponse(c, fiber.StatusNotFound, err) + return newErrorResponse(c, fiber.StatusInternalServerError, err) + } + + if !exists { + return newErrorResponse(c, fiber.StatusNotFound, storecmn.ErrNotFound) } // Delete incident @@ -737,24 +763,41 @@ func (s *Server) handleAPICreateService(c *fiber.Ctx) error { return newErrorResponse(c, fiber.StatusBadRequest, ErrProtocolRequired) } + // convert tags to jsonRawMessage + tags := make(json.RawMessage, 0) + if len(serviceDTO.Tags) > 0 { + tagsBytes, err := json.Marshal(serviceDTO.Tags) + if err != nil { + return newErrorResponse(c, fiber.StatusBadRequest, fmt.Errorf("failed to parse tags: %w", err)) + } + tags = tagsBytes + } else { + tags = json.RawMessage("[]") + } + + interval := time.Millisecond * time.Duration(serviceDTO.Interval) + timeout := time.Millisecond * time.Duration(serviceDTO.Timeout) + // Convert to storage.Service - createParams := storage.CreateUpdateServiceRequest{ + createParams := service.CreateParams{ Name: serviceDTO.Name, Protocol: serviceDTO.Protocol, - Interval: time.Millisecond * time.Duration(serviceDTO.Interval), - Timeout: time.Millisecond * time.Duration(serviceDTO.Timeout), + Interval: dbutils.Duration(interval), + Timeout: dbutils.Duration(timeout), Retries: serviceDTO.Retries, - Tags: serviceDTO.Tags, + Tags: dbutils.JSONField(tags), IsEnabled: serviceDTO.IsEnabled, } // Set default values - if createParams.Interval == 0 { - createParams.Interval = s.config.Monitoring.Global.DefaultInterval + if interval == 0 { + createParams.Interval = dbutils.Duration(s.config.Monitoring.Global.DefaultInterval) } - if createParams.Timeout == 0 { - createParams.Timeout = s.config.Monitoring.Global.DefaultTimeout + + if timeout == 0 { + createParams.Timeout = dbutils.Duration(s.config.Monitoring.Global.DefaultTimeout) } + if createParams.Retries == 0 { createParams.Retries = s.config.Monitoring.Global.DefaultRetries } @@ -764,10 +807,15 @@ func (s *Server) handleAPICreateService(c *fiber.Ctx) error { return newErrorResponse(c, fiber.StatusBadRequest, err) } - createParams.Config = serviceDTO.Config.ConvertToMap() + rawMessage, err := serviceDTO.Config.ConvertToJSONRawMessage() + if err != nil { + return newErrorResponse(c, fiber.StatusInternalServerError, err) + } + + createParams.Config = dbutils.JSONField(rawMessage) // Add service - svc, err := s.monitorService.CreateService(c.Context(), createParams) + svc, err := s.baseServices.Services().Create(c.Context(), createParams) if err != nil { return newErrorResponse(c, fiber.StatusInternalServerError, err) } @@ -809,7 +857,7 @@ func (s *Server) handleAPIUpdateService(c *fiber.Ctx) error { s.logger.Debugf("update service request: %+v", serviceDTO) // Convert to storage.Service - updateParams := storage.CreateUpdateServiceRequest{ + updateParams := service.UpdateParams{ Name: serviceDTO.Name, Protocol: serviceDTO.Protocol, Interval: time.Millisecond * time.Duration(serviceDTO.Interval), @@ -846,7 +894,7 @@ func (s *Server) handleAPIUpdateService(c *fiber.Ctx) error { } // Update service - svc, err := s.monitorService.UpdateService(c.Context(), id, updateParams) + svc, err := s.baseServices.Services().Update(c.Context(), id, updateParams) if err != nil { return newErrorResponse(c, fiber.StatusInternalServerError, err) } @@ -877,7 +925,7 @@ func (s *Server) handleAPIDeleteService(c *fiber.Ctx) error { return newErrorResponse(c, fiber.StatusBadRequest, ErrServiceIDRequired) } - if err := s.monitorService.DeleteService(c.Context(), id); err != nil { + if err := s.baseServices.Services().Delete(c.Context(), id); err != nil { return newErrorResponse(c, fiber.StatusInternalServerError, err) } diff --git a/internal/web/helpers.go b/internal/web/helpers.go index b545ddb..4a6503b 100644 --- a/internal/web/helpers.go +++ b/internal/web/helpers.go @@ -5,13 +5,15 @@ import ( "fmt" "time" + "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/monitors" + "github.com/sxwebdev/sentinel/internal/services/service" "github.com/sxwebdev/sentinel/internal/storage" "github.com/sxwebdev/sentinel/internal/utils" ) -// convertServiceToDTO converts a storage.Service to ServiceDTO -func convertServiceToDTO(service *storage.Service) (ServiceDTO, error) { +// convertServiceToDTO converts a models.Service to ServiceDTO +func convertServiceToDTO(service *models.ServiceFullView) (ServiceDTO, error) { config := monitors.Config{} if service.Config != nil { var err error @@ -52,7 +54,7 @@ func convertServiceToDTO(service *storage.Service) (ServiceDTO, error) { // getDashboardStats calculates dashboard statistics func (s *Server) getDashboardStats(ctx context.Context) (*DashboardStats, error) { // Get all services with their states - services, err := s.monitorService.FindServices(ctx, storage.FindServicesParams{}) + services, err := s.baseServices.Services().FindView(ctx, service.FindParams{}) if err != nil { return nil, err } @@ -89,7 +91,7 @@ func (s *Server) getDashboardStats(ctx context.Context) (*DashboardStats, error) ActiveIncidents: 0, LastCheckTime: nil, ChecksPerMinute: 0, - Protocols: make(map[storage.ServiceProtocolType]int), + Protocols: make(map[models.ServiceProtocolType]int), } // Calculate statistics @@ -139,7 +141,9 @@ func (s *Server) getDashboardStats(ctx context.Context) (*DashboardStats, error) if protocol == "" { protocol = "unknown" } - stats.Protocols[protocol]++ + + // TODO: fix this when remove storage.ServiceProtocolType + stats.Protocols[models.ServiceProtocolType(protocol)]++ } // Calculate averages diff --git a/pgxgen.yaml b/pgxgen.yaml new file mode 100644 index 0000000..419330b --- /dev/null +++ b/pgxgen.yaml @@ -0,0 +1,105 @@ +version: 1 +sqlc: + - schema_dir: sql/migrations + models: + replace_sqlc_nullable_types: true + move: + output_dir: internal/models + output_file_name: models_gen.go + package_name: models + package_path: github.com/sxwebdev/sentinel/internal/models + imports: + - path: github.com/sxwebdev/sentinel/internal/models + go_type: ServiceProtocolType + - path: github.com/sxwebdev/sentinel/internal/models + go_type: ServiceStatus + crud: + auto_remove_generated_files: true + exclude_table_name_from_methods: true + tables: + # Services + services: + output_dir: sql/queries/services + primary_column: id + methods: + create: + skip_columns: + - created_at + - updated_at + returning: "*" + column_values: + created_at: now() + delete: + get: + name: GetByID + + # service_states + service_states: + output_dir: sql/queries/service_states + primary_column: id + methods: + create: + skip_columns: + - created_at + - updated_at + returning: "*" + column_values: + created_at: now() + delete: + get: + name: GetByID + + # incidents + incidents: + output_dir: sql/queries/incidents + primary_column: id + methods: + create: + skip_columns: + - created_at + - updated_at + returning: "*" + column_values: + created_at: now() + delete: + get: + name: GetByID + find: + name: GetAll + + # agents + agents: + output_dir: sql/queries/agents + primary_column: id + methods: + create: + skip_columns: + - fingerprint + - is_active + - system_info + - last_seen_at + - created_at + - updated_at + returning: "*" + column_values: + created_at: now() + delete: + get: + name: GetByID + find: + name: GetAll + + constants: + tables: + services: + output_dir: internal/store/repos/repo_services + include_column_names: true + service_states: + output_dir: internal/store/repos/repo_service_states + include_column_names: true + incidents: + output_dir: internal/store/repos/repo_incidents + include_column_names: true + agents: + output_dir: internal/store/repos/repo_agents + include_column_names: true diff --git a/pkg/dbutils/duration.go b/pkg/dbutils/duration.go new file mode 100644 index 0000000..ea8d2f6 --- /dev/null +++ b/pkg/dbutils/duration.go @@ -0,0 +1,58 @@ +package dbutils + +import ( + "database/sql/driver" + "encoding/json" + "fmt" + "time" +) + +type Duration time.Duration + +// UnmarshalJSON implements the json.Unmarshaler interface. +func (d *Duration) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + duration, err := time.ParseDuration(s) + if err != nil { + return err + } + *d = Duration(duration) + return nil +} + +// MarshalJSON implements the json.Marshaler interface. +func (d Duration) MarshalJSON() ([]byte, error) { + s := time.Duration(d).String() + return json.Marshal(s) +} + +// Value implements the driver.Valuer interface. +func (d Duration) Value() (driver.Value, error) { + return time.Duration(d).String(), nil +} + +// Scan implements the sql.Scanner interface. +func (d *Duration) Scan(value any) error { + if v, ok := value.(string); ok { + duration, err := time.ParseDuration(v) + if err != nil { + return err + } + *d = Duration(duration) + return nil + } + return fmt.Errorf("unsupported scan type: %T", value) +} + +// String returns the string representation of the duration +func (d Duration) String() string { + return time.Duration(d).String() +} + +// ToDuration converts Duration to time.Duration +func (d Duration) ToDuration() time.Duration { + return time.Duration(d) +} diff --git a/pkg/dbutils/json.go b/pkg/dbutils/json.go new file mode 100644 index 0000000..79a99bd --- /dev/null +++ b/pkg/dbutils/json.go @@ -0,0 +1,34 @@ +package dbutils + +import ( + "database/sql/driver" + "encoding/json" + "fmt" +) + +type JSONField json.RawMessage + +func (j *JSONField) Scan(value any) error { + if value == nil { + *j = nil + return nil + } + + switch v := value.(type) { + case string: + *j = JSONField(v) + return nil + case []byte: + *j = JSONField(v) + return nil + default: + return fmt.Errorf("cannot scan %T into JSONField", value) + } +} + +func (j JSONField) Value() (driver.Value, error) { + if len(j) == 0 { + return nil, nil + } + return string(j), nil +} diff --git a/pkg/dbutils/tx.go b/pkg/dbutils/tx.go new file mode 100644 index 0000000..77d2107 --- /dev/null +++ b/pkg/dbutils/tx.go @@ -0,0 +1,21 @@ +package dbutils + +import ( + "context" + "database/sql" +) + +// WrapTx wraps a function to be executed within a transaction. +func WrapTx(ctx context.Context, db *sql.DB, txFunc func(tx *sql.Tx) error) error { + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if err := txFunc(tx); err != nil { + return err + } + + return tx.Commit() +} diff --git a/pkg/migrations/apply.go b/pkg/migrations/apply.go new file mode 100644 index 0000000..1f4613c --- /dev/null +++ b/pkg/migrations/apply.go @@ -0,0 +1,67 @@ +package migrations + +import ( + "database/sql" + "fmt" +) + +type applyMigrationType int + +const ( + applyMigrationTypeUp applyMigrationType = iota + applyMigrationTypeDown +) + +// string returns the string representation of the applyMigrationType +func (at applyMigrationType) String() string { + switch at { + case applyMigrationTypeUp: + return "up" + case applyMigrationTypeDown: + return "down" + default: + return "unknown" + } +} + +// applyMigration runs a single migration +func (m *Migrations) applyMigration(db *sql.DB, at applyMigrationType, version int, sql string) error { + if sql == "" { + return nil + } + + m.info("applying migration %s version %d", at.String(), version) + + // Start transaction + tx, err := db.Begin() + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + // Execute migration SQL + if _, err := tx.Exec(sql); err != nil { + return fmt.Errorf("failed to execute migration SQL: %w", err) + } + + // Record migration version + switch at { + case applyMigrationTypeUp: + if _, err := tx.Exec("INSERT INTO schema_version (version) VALUES (?)", version); err != nil { + return fmt.Errorf("failed to record migration version: %w", err) + } + case applyMigrationTypeDown: + if _, err := tx.Exec("DELETE FROM schema_version WHERE version = ?", version); err != nil { + return fmt.Errorf("failed to remove migration version record: %w", err) + } + default: + return fmt.Errorf("unknown apply type: %d", at) + } + + // Commit transaction + if err := tx.Commit(); err != nil { + return fmt.Errorf("failed to commit migration: %w", err) + } + + return nil +} diff --git a/pkg/migrations/cli.go b/pkg/migrations/cli.go new file mode 100644 index 0000000..cf8232a --- /dev/null +++ b/pkg/migrations/cli.go @@ -0,0 +1,92 @@ +package migrations + +import ( + "context" + "embed" + "fmt" + "os" + "path/filepath" + + "github.com/urfave/cli/v3" +) + +// CliCmd returns a cli.Command for managing database migrations. +func CliCmd(l logger, fs embed.FS, migrationsPath string) *cli.Command { + return &cli.Command{ + Name: "migrations", + Usage: "manage database migrations", + Commands: []*cli.Command{ + { + Name: "create", + Usage: "create a new database migration", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "name", + Aliases: []string{"n"}, + Usage: "name of the migration", + Required: true, + }, + &cli.StringFlag{ + Name: "path", + Aliases: []string{"p"}, + Usage: "path to the migration file", + Required: true, + }, + }, + Action: func(ctx context.Context, cl *cli.Command) error { + name := cl.String("name") + path := cl.String("path") + + // check if path exists, if not create it + if _, err := os.Stat(path); os.IsNotExist(err) { + if err := os.MkdirAll(path, 0o700); err != nil { + return fmt.Errorf("failed to create migrations dir: %w", err) + } + } + + // get max version from existing migrations + maxVersion, err := GetMaxVersion(path) + if err != nil { + return fmt.Errorf("failed to get max version: %w", err) + } + + nextVersion := maxVersion + 1 + + upFile := filepath.Join(path, fmt.Sprintf("%d_%s.up.sql", nextVersion, name)) + downFile := filepath.Join(path, fmt.Sprintf("%d_%s.down.sql", nextVersion, name)) + + upContent := "-- SQL in section 'Up' is executed when this migration is applied.\n\n" + downContent := "-- SQL in section 'Down' is executed when this migration is rolled back.\n\n" + + if err := os.WriteFile(upFile, []byte(upContent), 0o644); err != nil { + return fmt.Errorf("failed to create up migration file: %w", err) + } + + if err := os.WriteFile(downFile, []byte(downContent), 0o644); err != nil { + return fmt.Errorf("failed to create down migration file: %w", err) + } + + fmt.Printf("Created migration files:\n%s\n%s\n", upFile, downFile) + + return nil + }, + }, + { + Name: "down", + Usage: "roll back the last database migration", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "db-path", + Aliases: []string{"dp"}, + Usage: "path to the SQLite database file", + Required: true, + }, + }, + Action: func(ctx context.Context, cl *cli.Command) error { + m := New(l, fs, migrationsPath) + return m.MigrateDown(cl.String("db-path")) + }, + }, + }, + } +} diff --git a/pkg/migrations/db.go b/pkg/migrations/db.go new file mode 100644 index 0000000..fbce1fd --- /dev/null +++ b/pkg/migrations/db.go @@ -0,0 +1,17 @@ +package migrations + +import ( + "database/sql" + "fmt" + + _ "modernc.org/sqlite" +) + +func initDatabase(dbPath string) (*sql.DB, error) { + db, err := sql.Open("sqlite", dbPath+"?_busy_timeout=30000&_journal_mode=WAL&_synchronous=NORMAL&_cache_size=10000&_foreign_keys=on") + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + return db, nil +} diff --git a/pkg/migrations/down.go b/pkg/migrations/down.go new file mode 100644 index 0000000..aa558f5 --- /dev/null +++ b/pkg/migrations/down.go @@ -0,0 +1,45 @@ +package migrations + +import ( + "fmt" +) + +// MigrateDown rolls back the last applied migration +func (m *Migrations) MigrateDown(dbPath string) error { + migrations, err := m.loadFromFS() + if err != nil { + return fmt.Errorf("failed to load migrations: %w", err) + } + + db, err := initDatabase(dbPath) + if err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + defer db.Close() + + // Get current schema version + currentVersion, err := GetCurrentSchemaVersion(db) + if err != nil { + return fmt.Errorf("failed to get current schema version: %w", err) + } + + // Find the migration to roll back + var migrationToRollback *migration + for i := len(migrations) - 1; i >= 0; i-- { + if migrations[i].Version == currentVersion { + migrationToRollback = &migrations[i] + break + } + } + + if migrationToRollback == nil { + return fmt.Errorf("no migration found to roll back for version %d", currentVersion) + } + + // Run the rollback + if err := m.applyMigration(db, applyMigrationTypeDown, migrationToRollback.Version, migrationToRollback.DownSQL); err != nil { + return fmt.Errorf("failed to roll back migration %d: %w", migrationToRollback.Version, err) + } + + return nil +} diff --git a/pkg/migrations/migrations.go b/pkg/migrations/migrations.go new file mode 100644 index 0000000..0703372 --- /dev/null +++ b/pkg/migrations/migrations.go @@ -0,0 +1,107 @@ +package migrations + +import ( + "embed" + "fmt" + "path/filepath" + "regexp" + "sort" +) + +type Migrations struct { + logger logger + fs embed.FS + migrationsPath string +} + +func New(logger logger, fs embed.FS, migrationsPath string) *Migrations { + return &Migrations{ + logger: logger, + fs: fs, + migrationsPath: migrationsPath, + } +} + +func (m *Migrations) info(format string, args ...any) { + if m.logger != nil { + m.logger.Infof(format, args...) + } +} + +func (m *Migrations) loadFromFS() ([]migration, error) { + // read all migration files from the embedded filesystem + entries, err := m.fs.ReadDir(m.migrationsPath) + if err != nil { + return nil, fmt.Errorf("failed to read embedded migrations: %v", err) + } + + // clear existing migrationsMap to replace with embedded ones + migrationsMap := map[int]migration{} + + // regex to parse filenames like: 1_init_repo.up.sql or 2_added_users.down.sql + migRe := regexp.MustCompile(`^(\d+)_([^.]+)\.(up|down)\.sql$`) + + for _, file := range entries { + if file.IsDir() { + continue + } + + // parse file name to get version, name and direction + matches := migRe.FindStringSubmatch(file.Name()) + if matches == nil || len(matches) != 4 { + return nil, fmt.Errorf("failed to parse migration file name %s: unexpected format", file.Name()) + } + + // extract parts + var version int + if _, err := fmt.Sscanf(matches[1], "%d", &version); err != nil { + return nil, fmt.Errorf("failed to parse migration version from %s: %v", file.Name(), err) + } + name := matches[2] + direction := matches[3] + + // read migration SQL from file + fullPath := filepath.Join(m.migrationsPath, file.Name()) + data, err := m.fs.ReadFile(fullPath) + if err != nil { + return nil, fmt.Errorf("failed to read migration file %s: %v", file.Name(), err) + } + + // create migration entry if not exists + item, exists := migrationsMap[version] + if !exists { + migrationsMap[version] = migration{ + Name: name, + } + } + + switch direction { + case "up": + item.UpSQL = string(data) + case "down": + item.DownSQL = string(data) + default: + return nil, fmt.Errorf("invalid migration direction in file %s", file.Name()) + } + + migrationsMap[version] = item + } + + // convert map to slice and sort by version + migrationsSlice := make([]migration, 0, len(migrationsMap)) + for version, item := range migrationsMap { + migrationsSlice = append(migrationsSlice, migration{ + Version: version, + Name: item.Name, + UpSQL: item.UpSQL, + DownSQL: item.DownSQL, + }) + } + + // sort migrations by version + sort.Slice(migrationsSlice, func(i, j int) bool { + return migrationsSlice[i].Version < migrationsSlice[j].Version + }) + + return migrationsSlice, nil +} diff --git a/pkg/migrations/schema.go b/pkg/migrations/schema.go new file mode 100644 index 0000000..bcfb111 --- /dev/null +++ b/pkg/migrations/schema.go @@ -0,0 +1,21 @@ +package migrations + +import "database/sql" + +// schemaVersionTable creates the schema version tracking table +const schemaVersionTable = ` +CREATE TABLE IF NOT EXISTS schema_version ( + version INTEGER PRIMARY KEY, + applied_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +` + +// GetCurrentSchemaVersion gets the current schema version from the database +func GetCurrentSchemaVersion(db *sql.DB) (int, error) { + var version int + err := db.QueryRow("SELECT COALESCE(MAX(version), 0) FROM schema_version").Scan(&version) + if err != nil { + return 0, err + } + return version, nil +} diff --git a/pkg/migrations/types.go b/pkg/migrations/types.go new file mode 100644 index 0000000..db56ee1 --- /dev/null +++ b/pkg/migrations/types.go @@ -0,0 +1,13 @@ +package migrations + +type logger interface { + Infof(format string, args ...any) + Errorf(format string, args ...any) +} + +type migration struct { + Version int + Name string + UpSQL string + DownSQL string +} diff --git a/pkg/migrations/up.go b/pkg/migrations/up.go new file mode 100644 index 0000000..88a4dc6 --- /dev/null +++ b/pkg/migrations/up.go @@ -0,0 +1,54 @@ +package migrations + +import ( + "fmt" +) + +// MigrateUpAll runs all pending database migrations +func (m *Migrations) MigrateUpAll(dbPath string) error { + m.info("run all migrations") + + migrations, err := m.loadFromFS() + if err != nil { + return fmt.Errorf("failed to load migrations: %w", err) + } + + db, err := initDatabase(dbPath) + if err != nil { + return fmt.Errorf("failed to initialize database: %w", err) + } + defer db.Close() + + // Create schema version table if it doesn't exist + if _, err := db.Exec(schemaVersionTable); err != nil { + return fmt.Errorf("failed to create schema version table: %w", err) + } + + // Get current schema version + currentVersion, err := GetCurrentSchemaVersion(db) + if err != nil { + return fmt.Errorf("failed to get current schema version: %w", err) + } + + m.info("current schema version: %d", currentVersion) + + var appliedMigrationsCount int + + // Run pending migrations + for _, migration := range migrations { + if migration.Version > currentVersion { + if err := m.applyMigration(db, applyMigrationTypeUp, migration.Version, migration.UpSQL); err != nil { + return fmt.Errorf("failed to run migration %d: %w", migration.Version, err) + } + appliedMigrationsCount++ + } + } + + if appliedMigrationsCount == 0 { + m.info("no new migrations to apply") + } else { + m.info("applied %d new migrations", appliedMigrationsCount) + } + + return nil +} diff --git a/pkg/migrations/utils.go b/pkg/migrations/utils.go new file mode 100644 index 0000000..f10df9d --- /dev/null +++ b/pkg/migrations/utils.go @@ -0,0 +1,32 @@ +package migrations + +import ( + "fmt" + "os" +) + +func GetMaxVersion(path string) (int, error) { + entries, err := os.ReadDir(path) + if err != nil { + return 0, fmt.Errorf("failed to read migrations dir: %w", err) + } + + var maxVersion int + for _, entry := range entries { + if entry.IsDir() { + continue + } + + var version int + _, err := fmt.Sscanf(entry.Name(), "%d_", &version) + if err != nil { + return 0, fmt.Errorf("failed to parse migration file name %s: %w", entry.Name(), err) + } + + if version > maxVersion { + maxVersion = version + } + } + + return maxVersion, nil +} diff --git a/pkg/sqlite/sqlite.go b/pkg/sqlite/sqlite.go new file mode 100644 index 0000000..008098c --- /dev/null +++ b/pkg/sqlite/sqlite.go @@ -0,0 +1,49 @@ +package sqlite + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "time" + + _ "modernc.org/sqlite" +) + +type SQLite struct { + DB *sql.DB +} + +func New(ctx context.Context, dbPath string) (*SQLite, error) { + if dbPath == "" { + return nil, fmt.Errorf("database path is empty") + } + + instance := &SQLite{} + + dir := filepath.Dir(dbPath) + if err := os.MkdirAll(dir, 0o755); err != nil { + return nil, fmt.Errorf("failed to create database directory: %w", err) + } + + // Open SQLite database with proper settings for concurrent access + db, err := sql.Open("sqlite", dbPath+"?_busy_timeout=30000&_journal_mode=WAL&_synchronous=NORMAL&_cache_size=10000&_foreign_keys=on") + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Set connection pool settings + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + db.SetConnMaxLifetime(time.Hour) + + // Test connection + if err := db.PingContext(ctx); err != nil { + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + instance.DB = db + + return instance, nil +} diff --git a/pkg/sqlite/svc.go b/pkg/sqlite/svc.go new file mode 100644 index 0000000..f8d972c --- /dev/null +++ b/pkg/sqlite/svc.go @@ -0,0 +1,20 @@ +package sqlite + +import "context" + +func (s *SQLite) Name() string { return "sqlite" } + +func (s *SQLite) Start(_ context.Context) error { return nil } + +func (s *SQLite) Stop(_ context.Context) error { + return s.DB.Close() +} + +func (s *SQLite) Ping(ctx context.Context) error { + return s.DB.PingContext(ctx) +} + +// Enabled returns true if the database is enabled +func (s *SQLite) Enabled() bool { + return true +} diff --git a/pkg/sqlite/version.go b/pkg/sqlite/version.go new file mode 100644 index 0000000..e4bce4a --- /dev/null +++ b/pkg/sqlite/version.go @@ -0,0 +1,16 @@ +package sqlite + +import ( + "context" + "fmt" +) + +// GetSQLiteVersion returns the SQLite version +func (o *SQLite) GetSQLiteVersion(ctx context.Context) (string, error) { + var version string + err := o.DB.QueryRowContext(ctx, "SELECT sqlite_version()").Scan(&version) + if err != nil { + return "", fmt.Errorf("failed to get SQLite version: %w", err) + } + return version, nil +} diff --git a/sql/emded.go b/sql/emded.go new file mode 100644 index 0000000..6f51c8a --- /dev/null +++ b/sql/emded.go @@ -0,0 +1,10 @@ +package sql + +import ( + "embed" +) + +//go:embed migrations/*.sql +var MigrationsFS embed.FS + +var MigrationsPath = "migrations" diff --git a/sql/migrations/1_init_repo.down.sql b/sql/migrations/1_init_repo.down.sql new file mode 100644 index 0000000..cc5f9bb --- /dev/null +++ b/sql/migrations/1_init_repo.down.sql @@ -0,0 +1,3 @@ +DROP TABLE IF EXISTS service_states CASCADE; +DROP TABLE IF EXISTS incidents CASCADE; +DROP TABLE IF EXISTS services CASCADE; diff --git a/sql/migrations/1_init_repo.up.sql b/sql/migrations/1_init_repo.up.sql new file mode 100644 index 0000000..552a41e --- /dev/null +++ b/sql/migrations/1_init_repo.up.sql @@ -0,0 +1,58 @@ +-- Create services table +CREATE TABLE IF NOT EXISTS services ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + protocol TEXT NOT NULL, + interval TEXT NOT NULL, + timeout TEXT NOT NULL, + retries INTEGER NOT NULL DEFAULT 3, + tags jsonb NOT NULL DEFAULT '[]', + config jsonb NOT NULL DEFAULT '{}', + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Create incidents table +CREATE TABLE IF NOT EXISTS incidents ( + id TEXT PRIMARY KEY, + service_id TEXT NOT NULL REFERENCES services(id), + start_time DATETIME NOT NULL, + end_time DATETIME, + error TEXT NOT NULL, + duration_ns INTEGER, + resolved BOOLEAN NOT NULL DEFAULT FALSE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); + +-- Create service_states table for current service states +CREATE TABLE IF NOT EXISTS service_states ( + id TEXT PRIMARY KEY, + service_id TEXT NOT NULL REFERENCES services(id), + status TEXT NOT NULL DEFAULT 'unknown', + last_check DATETIME, + next_check DATETIME, + last_error TEXT, + consecutive_fails INTEGER NOT NULL DEFAULT 0, + consecutive_success INTEGER NOT NULL DEFAULT 0, + total_checks INTEGER NOT NULL DEFAULT 0, + response_time_ns INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(service_id) +); + +-- Create indexes for performance +CREATE INDEX IF NOT EXISTS idx_services_name ON services(name); +CREATE INDEX IF NOT EXISTS idx_services_enabled ON services(is_enabled); +CREATE INDEX IF NOT EXISTS idx_services_created_at ON services(created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_incidents_service_id ON incidents(service_id); +CREATE INDEX IF NOT EXISTS idx_incidents_start_time_desc ON incidents(start_time DESC); +CREATE INDEX IF NOT EXISTS idx_incidents_resolved ON incidents(resolved); + +CREATE INDEX IF NOT EXISTS idx_service_states_service_id ON service_states(service_id); +CREATE INDEX IF NOT EXISTS idx_service_states_status ON service_states(status); +CREATE INDEX IF NOT EXISTS idx_service_states_last_check ON service_states(last_check DESC); +CREATE INDEX IF NOT EXISTS idx_service_states_next_check ON service_states(next_check); diff --git a/sql/migrations/2_agents.down.sql b/sql/migrations/2_agents.down.sql new file mode 100644 index 0000000..b2febd2 --- /dev/null +++ b/sql/migrations/2_agents.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS agents CASCADE; diff --git a/sql/migrations/2_agents.up.sql b/sql/migrations/2_agents.up.sql new file mode 100644 index 0000000..0ab757d --- /dev/null +++ b/sql/migrations/2_agents.up.sql @@ -0,0 +1,23 @@ +-- Create agents table +CREATE TABLE IF NOT EXISTS agents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + host TEXT, + port INT, + token_ct BLOB, + token_nonce BLOB, + token_hint TEXT, + fingerprint TEXT, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + system_info jsonb NOT NULL DEFAULT '{}', + last_seen_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE(token_ct), + UNIQUE(fingerprint) +); + +-- Create indexes for performance +CREATE INDEX IF NOT EXISTS idx_agents_name ON agents(name); +CREATE INDEX IF NOT EXISTS idx_agents_active ON agents(is_active); diff --git a/sql/queries/agents/agents_gen.sql b/sql/queries/agents/agents_gen.sql new file mode 100755 index 0000000..a361528 --- /dev/null +++ b/sql/queries/agents/agents_gen.sql @@ -0,0 +1,14 @@ +-- name: Create :one +INSERT INTO agents (id, name, description, host, port, token_ct, token_nonce, token_hint) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + RETURNING *; + +-- name: Delete :exec +DELETE FROM agents WHERE id=?; + +-- name: GetAll :many +SELECT * FROM agents; + +-- name: GetByID :one +SELECT * FROM agents WHERE id=? LIMIT 1; + diff --git a/sql/queries/incidents/incidents.sql b/sql/queries/incidents/incidents.sql new file mode 100755 index 0000000..1710e6d --- /dev/null +++ b/sql/queries/incidents/incidents.sql @@ -0,0 +1,2 @@ +-- name: DeleteByServiceID :exec +DELETE FROM incidents WHERE service_id=?; diff --git a/sql/queries/incidents/incidents_gen.sql b/sql/queries/incidents/incidents_gen.sql new file mode 100755 index 0000000..6d225a2 --- /dev/null +++ b/sql/queries/incidents/incidents_gen.sql @@ -0,0 +1,14 @@ +-- name: Create :one +INSERT INTO incidents (id, service_id, start_time, end_time, error, duration_ns, resolved) + VALUES (?, ?, ?, ?, ?, ?, ?) + RETURNING *; + +-- name: Delete :exec +DELETE FROM incidents WHERE id=?; + +-- name: GetAll :many +SELECT * FROM incidents; + +-- name: GetByID :one +SELECT * FROM incidents WHERE id=? LIMIT 1; + diff --git a/sql/queries/service_states/service_states.sql b/sql/queries/service_states/service_states.sql new file mode 100755 index 0000000..0c7358a --- /dev/null +++ b/sql/queries/service_states/service_states.sql @@ -0,0 +1,2 @@ +-- name: DeleteByServiceID :exec +DELETE FROM service_states WHERE service_id=?; diff --git a/sql/queries/service_states/service_states_gen.sql b/sql/queries/service_states/service_states_gen.sql new file mode 100755 index 0000000..be774d5 --- /dev/null +++ b/sql/queries/service_states/service_states_gen.sql @@ -0,0 +1,11 @@ +-- name: Create :one +INSERT INTO service_states (id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time_ns) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING *; + +-- name: Delete :exec +DELETE FROM service_states WHERE id=?; + +-- name: GetByID :one +SELECT * FROM service_states WHERE id=? LIMIT 1; + diff --git a/sql/queries/services/services.sql b/sql/queries/services/services.sql new file mode 100644 index 0000000..90e9556 --- /dev/null +++ b/sql/queries/services/services.sql @@ -0,0 +1,2 @@ +-- name: Exist :one +SELECT EXISTS (SELECT 1 FROM services WHERE id = ? LIMIT 1); diff --git a/sql/queries/services/services_gen.sql b/sql/queries/services/services_gen.sql new file mode 100755 index 0000000..22d4a95 --- /dev/null +++ b/sql/queries/services/services_gen.sql @@ -0,0 +1,11 @@ +-- name: Create :one +INSERT INTO services (id, name, protocol, interval, timeout, retries, tags, config, is_enabled) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING *; + +-- name: Delete :exec +DELETE FROM services WHERE id=?; + +-- name: GetByID :one +SELECT * FROM services WHERE id=? LIMIT 1; + diff --git a/sqlc.yaml b/sqlc.yaml new file mode 100644 index 0000000..9350cb0 --- /dev/null +++ b/sqlc.yaml @@ -0,0 +1,105 @@ +version: "2" +overrides: + go: + overrides: + # Services + - db_type: jsonb + go_type: + type: JSONField + import: github.com/sxwebdev/sentinel/pkg/dbutils + - column: services.protocol + go_type: + type: ServiceProtocolType + - column: services.interval + go_type: + type: Duration + import: github.com/sxwebdev/sentinel/pkg/dbutils + - column: services.timeout + go_type: + type: Duration + import: github.com/sxwebdev/sentinel/pkg/dbutils + + # service_states + - column: service_states.status + go_type: + type: ServiceStatus +sql: + # Services + - schema: sql/migrations + queries: sql/queries/services + engine: sqlite + gen: + go: + out: internal/store/repos/repo_services + emit_prepared_queries: false + emit_json_tags: true + emit_exported_queries: false + emit_db_tags: true + emit_interface: true + emit_exact_table_names: false + emit_empty_slices: true + emit_result_struct_pointers: true + emit_params_struct_pointers: false + emit_enum_valid_method: true + emit_all_enum_values: true + query_parameter_limit: 3 + + # service_states + - schema: sql/migrations + queries: sql/queries/service_states + engine: sqlite + gen: + go: + out: internal/store/repos/repo_service_states + emit_prepared_queries: false + emit_json_tags: true + emit_exported_queries: false + emit_db_tags: true + emit_interface: true + emit_exact_table_names: false + emit_empty_slices: true + emit_result_struct_pointers: true + emit_params_struct_pointers: false + emit_enum_valid_method: true + emit_all_enum_values: true + query_parameter_limit: 3 + + # incidents + - schema: sql/migrations + queries: sql/queries/incidents + engine: sqlite + gen: + go: + out: internal/store/repos/repo_incidents + emit_prepared_queries: false + emit_json_tags: true + emit_exported_queries: false + emit_db_tags: true + emit_interface: true + emit_exact_table_names: false + emit_empty_slices: true + emit_result_struct_pointers: true + emit_params_struct_pointers: false + emit_enum_valid_method: true + emit_all_enum_values: true + query_parameter_limit: 3 + + # agents + - schema: sql/migrations + queries: sql/queries/agents + engine: sqlite + gen: + go: + out: internal/store/repos/repo_agents + emit_prepared_queries: false + emit_json_tags: true + emit_exported_queries: false + emit_db_tags: true + emit_interface: true + emit_exact_table_names: false + emit_empty_slices: true + emit_result_struct_pointers: true + emit_params_struct_pointers: false + emit_enum_valid_method: true + emit_all_enum_values: true + query_parameter_limit: 3 From ff6fcef912d9981bca5fcf73368a6256b7480aa6 Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Fri, 19 Sep 2025 09:28:31 +0300 Subject: [PATCH 06/71] feat: enhance service creation and update parameters with JSONField and duration handling --- internal/services/service/methods.go | 78 +++++++++++++++++--- internal/store/repos/repo_services/update.go | 9 ++- internal/web/handlers.go | 48 ++++-------- pkg/dbutils/json.go | 55 ++++++++++++++ 4 files changed, 140 insertions(+), 50 deletions(-) diff --git a/internal/services/service/methods.go b/internal/services/service/methods.go index 79f4353..1a22158 100644 --- a/internal/services/service/methods.go +++ b/internal/services/service/methods.go @@ -18,28 +18,59 @@ import ( "github.com/sxwebdev/sentinel/pkg/dbutils" ) -type CreateParams = repo_services.CreateParams +type CreateUpdateParams struct { + Name string + Protocol models.ServiceProtocolType + Interval time.Duration + Timeout time.Duration + Retries int64 + Tags []string + Config map[string]any + IsEnabled bool +} // Create new service -func (s *Service) Create(ctx context.Context, params CreateParams) (*models.ServiceFullView, error) { +func (s *Service) Create(ctx context.Context, params CreateUpdateParams) (*models.ServiceFullView, error) { if len(params.Tags) > 0 { slices.Sort(params.Tags) } - params.ID = utils.GenerateULID() + // Convert tags to JSONField + tags := dbutils.JSONField("[]") + if err := tags.UnmarshalAny(params.Tags); err != nil { + return nil, fmt.Errorf("failed to convert tags to json raw message: %w", err) + } + + // Convert config to JSONField + config := dbutils.JSONField("{}") + if err := config.UnmarshalAny(params.Config); err != nil { + return nil, fmt.Errorf("failed to convert config to json raw message: %w", err) + } + + createParams := repo_services.CreateParams{ + ID: utils.GenerateULID(), + Name: params.Name, + Protocol: params.Protocol, + Interval: dbutils.Duration(params.Interval), + Timeout: dbutils.Duration(params.Timeout), + Retries: params.Retries, + Tags: tags, + Config: config, + IsEnabled: params.IsEnabled, + } err := dbutils.WrapTx(ctx, s.store.SQLite(), func(tx *sql.Tx) error { // Create service - _, err := s.store.Services(repos.WithTx(tx)).Create(ctx, params) + _, err := s.store.Services(repos.WithTx(tx)).Create(ctx, createParams) if err != nil { return fmt.Errorf("failed to create service: %w", err) } // Create initial service state - nextCheck := time.Now().Add(params.Interval.ToDuration()) + nextCheck := time.Now().Add(params.Interval) serviceState := &repo_service_states.CreateParams{ ID: utils.GenerateULID(), - ServiceID: params.ID, + ServiceID: createParams.ID, Status: models.StatusUnknown, NextCheck: &nextCheck, } @@ -55,7 +86,7 @@ func (s *Service) Create(ctx context.Context, params CreateParams) (*models.Serv return nil, fmt.Errorf("failed to create service in transaction: %w", err) } - svcView, err := s.GetViewByID(ctx, params.ID) + svcView, err := s.GetViewByID(ctx, createParams.ID) if err != nil { return nil, fmt.Errorf("failed to get created service: %w", err) } @@ -68,11 +99,36 @@ func (s *Service) Create(ctx context.Context, params CreateParams) (*models.Serv return svcView, nil } -type UpdateParams = repo_services.UpdateServiceRequest - // Update service -func (s *Service) Update(ctx context.Context, id string, params UpdateParams) (*models.ServiceFullView, error) { - item, err := s.store.Services().Update(ctx, id, params) +func (s *Service) Update(ctx context.Context, id string, params CreateUpdateParams) (*models.ServiceFullView, error) { + if len(params.Tags) > 0 { + slices.Sort(params.Tags) + } + + // Convert tags to JSONField + tags := dbutils.JSONField("[]") + if err := tags.UnmarshalAny(params.Tags); err != nil { + return nil, fmt.Errorf("failed to convert tags to json raw message: %w", err) + } + + // Convert config to JSONField + config := dbutils.JSONField("{}") + if err := config.UnmarshalAny(params.Config); err != nil { + return nil, fmt.Errorf("failed to convert config to json raw message: %w", err) + } + + updateParams := repo_services.UpdateServiceRequest{ + Name: params.Name, + Protocol: params.Protocol, + Interval: dbutils.Duration(params.Interval), + Timeout: dbutils.Duration(params.Timeout), + Retries: params.Retries, + Tags: tags, + Config: config, + IsEnabled: params.IsEnabled, + } + + item, err := s.store.Services().Update(ctx, id, updateParams) if err != nil { return nil, err } diff --git a/internal/store/repos/repo_services/update.go b/internal/store/repos/repo_services/update.go index 8d52c79..fed4da8 100644 --- a/internal/store/repos/repo_services/update.go +++ b/internal/store/repos/repo_services/update.go @@ -8,16 +8,17 @@ import ( "github.com/huandu/go-sqlbuilder" "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/pkg/dbutils" ) type UpdateServiceRequest struct { Name string `json:"name" yaml:"name"` Protocol models.ServiceProtocolType `json:"protocol" yaml:"protocol"` - Interval time.Duration `json:"interval" yaml:"interval" swaggertype:"primitive,integer"` - Timeout time.Duration `json:"timeout" yaml:"timeout" swaggertype:"primitive,integer"` + Interval dbutils.Duration `json:"interval" yaml:"interval" swaggertype:"primitive,integer"` + Timeout dbutils.Duration `json:"timeout" yaml:"timeout" swaggertype:"primitive,integer"` Retries int64 `json:"retries" yaml:"retries"` - Tags []string `json:"tags" yaml:"tags"` - Config map[string]any `json:"config" yaml:"config"` + Tags dbutils.JSONField `json:"tags" yaml:"tags"` + Config dbutils.JSONField `json:"config" yaml:"config"` IsEnabled bool `json:"is_enabled" yaml:"is_enabled"` } diff --git a/internal/web/handlers.go b/internal/web/handlers.go index da1a091..44bfe92 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -15,7 +15,6 @@ package web import ( "context" - "encoding/json" "errors" "fmt" goHTML "html" @@ -763,57 +762,36 @@ func (s *Server) handleAPICreateService(c *fiber.Ctx) error { return newErrorResponse(c, fiber.StatusBadRequest, ErrProtocolRequired) } - // convert tags to jsonRawMessage - tags := make(json.RawMessage, 0) - if len(serviceDTO.Tags) > 0 { - tagsBytes, err := json.Marshal(serviceDTO.Tags) - if err != nil { - return newErrorResponse(c, fiber.StatusBadRequest, fmt.Errorf("failed to parse tags: %w", err)) - } - tags = tagsBytes - } else { - tags = json.RawMessage("[]") + // Validate config based on protocol + if err := serviceDTO.Config.Validate(serviceDTO.Protocol); err != nil { + return newErrorResponse(c, fiber.StatusBadRequest, err) } - interval := time.Millisecond * time.Duration(serviceDTO.Interval) - timeout := time.Millisecond * time.Duration(serviceDTO.Timeout) - // Convert to storage.Service - createParams := service.CreateParams{ + createParams := service.CreateUpdateParams{ Name: serviceDTO.Name, Protocol: serviceDTO.Protocol, - Interval: dbutils.Duration(interval), - Timeout: dbutils.Duration(timeout), + Interval: time.Millisecond * time.Duration(serviceDTO.Interval), + Timeout: time.Millisecond * time.Duration(serviceDTO.Timeout), Retries: serviceDTO.Retries, - Tags: dbutils.JSONField(tags), + Tags: serviceDTO.Tags, + Config: serviceDTO.Config.ConvertToMap(), IsEnabled: serviceDTO.IsEnabled, } // Set default values - if interval == 0 { - createParams.Interval = dbutils.Duration(s.config.Monitoring.Global.DefaultInterval) + if createParams.Interval == 0 { + createParams.Interval = s.config.Monitoring.Global.DefaultInterval } - if timeout == 0 { - createParams.Timeout = dbutils.Duration(s.config.Monitoring.Global.DefaultTimeout) + if createParams.Timeout == 0 { + createParams.Timeout = s.config.Monitoring.Global.DefaultTimeout } if createParams.Retries == 0 { createParams.Retries = s.config.Monitoring.Global.DefaultRetries } - // Convert flat config to proper MonitorConfig structure - if err := serviceDTO.Config.Validate(serviceDTO.Protocol); err != nil { - return newErrorResponse(c, fiber.StatusBadRequest, err) - } - - rawMessage, err := serviceDTO.Config.ConvertToJSONRawMessage() - if err != nil { - return newErrorResponse(c, fiber.StatusInternalServerError, err) - } - - createParams.Config = dbutils.JSONField(rawMessage) - // Add service svc, err := s.baseServices.Services().Create(c.Context(), createParams) if err != nil { @@ -857,7 +835,7 @@ func (s *Server) handleAPIUpdateService(c *fiber.Ctx) error { s.logger.Debugf("update service request: %+v", serviceDTO) // Convert to storage.Service - updateParams := service.UpdateParams{ + updateParams := service.CreateUpdateParams{ Name: serviceDTO.Name, Protocol: serviceDTO.Protocol, Interval: time.Millisecond * time.Duration(serviceDTO.Interval), diff --git a/pkg/dbutils/json.go b/pkg/dbutils/json.go index 79a99bd..0fc1446 100644 --- a/pkg/dbutils/json.go +++ b/pkg/dbutils/json.go @@ -8,6 +8,48 @@ import ( type JSONField json.RawMessage +// Mashals JSONField to a JSON string +func (j JSONField) MarshalJSON() ([]byte, error) { + if len(j) == 0 { + return []byte("null"), nil + } + return j, nil +} + +// Unmarshals a JSON string to JSONField +func (j *JSONField) UnmarshalJSON(data []byte) error { + if j == nil { + return fmt.Errorf("JSONField: UnmarshalJSON on nil pointer") + } + *j = append((*j)[0:0], data...) + return nil +} + +// Unmarshal any to JSONField +func (j *JSONField) UnmarshalAny(value any) error { + if value == nil { + *j = nil + return nil + } + + switch v := value.(type) { + case string: + *j = JSONField(v) + return nil + case []byte: + *j = JSONField(v) + return nil + default: + bytes, err := json.Marshal(v) + if err != nil { + return fmt.Errorf("failed to marshal value: %w", err) + } + *j = JSONField(bytes) + } + return nil +} + +// Scan implements the sql.Scanner interface for JSONField func (j *JSONField) Scan(value any) error { if value == nil { *j = nil @@ -26,9 +68,22 @@ func (j *JSONField) Scan(value any) error { } } +// Value implements the driver.Valuer interface for JSONField func (j JSONField) Value() (driver.Value, error) { if len(j) == 0 { return nil, nil } return string(j), nil } + +// ToMap converts JSONField to map[string]any +func (j JSONField) ConvertToMap() map[string]any { + if len(j) == 0 { + return map[string]any{} + } + var result map[string]any + if err := json.Unmarshal(j, &result); err != nil { + return map[string]any{} + } + return result +} From 818c20d8a12d7340ed2aaa156f9e237b404db205 Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Fri, 19 Sep 2025 10:24:33 +0300 Subject: [PATCH 07/71] Refactor agent and service repositories to use new storecmn package - Updated agent repository to replace dbutils with storecmn for JSONField, Duration, and pagination handling. - Renamed and modified agent columns in constants and SQL migrations to reflect new structure (status, is_enabled, tags, config). - Implemented custom queries for agents and services, enhancing the Find and Update functionalities. - Removed dbutils package and migrated relevant functionality to storecmn. - Added new service methods for agent creation, deletion, and retrieval. - Updated API handlers to reflect changes in response types and pagination. --- cmd/testapi/extended_tests.go | 20 +-- cmd/testapi/main.go | 16 +-- cmd/testapi/model_tests.go | 8 +- docs/docsv1/docs.go | 132 +++++++++--------- docs/docsv1/swagger.json | 132 +++++++++--------- docs/docsv1/swagger.yaml | 108 +++++++------- go.mod | 3 + go.sum | 18 ++- internal/models/models_gen.go | 41 +++--- internal/monitor/monitor.go | 2 +- internal/services/agents/methods.go | 73 ++++++++++ internal/services/agents/service.go | 15 ++ internal/services/baseservices/base.go | 24 +++- internal/services/incidents/service.go | 15 ++ internal/services/service/methods.go | 23 ++- internal/storage/incidents.go | 8 +- internal/storage/services.go | 8 +- .../store/repos/repo_agents/agents_gen.sql.go | 50 ++++--- .../store/repos/repo_agents/constants_gen.go | 10 +- internal/store/repos/repo_agents/custom.go | 36 +++++ internal/store/repos/repo_agents/find.go | 92 ++++++++++++ internal/store/repos/repo_agents/update.go | 75 ++++++++++ internal/store/repos/repo_services/custom.go | 4 +- internal/store/repos/repo_services/find.go | 14 +- internal/store/repos/repo_services/get.go | 4 +- .../repos/repo_services/services_gen.sql.go | 10 +- internal/store/repos/repo_services/types.go | 6 +- internal/store/repos/repo_services/update.go | 12 +- internal/store/repos/repos.go | 6 +- .../store/storecmn}/duration.go | 2 +- internal/store/storecmn/errors.go | 1 + .../store/storecmn}/json.go | 2 +- .../store/storecmn}/pagination.go | 2 +- .../store/storecmn}/response.go | 2 +- .../dbutils => internal/store/storecmn}/tx.go | 2 +- internal/web/handlers.go | 41 +++--- sql/migrations/2_agents.up.sql | 13 +- sql/queries/agents/agents_gen.sql | 4 +- sqlc.yaml | 6 +- 39 files changed, 701 insertions(+), 339 deletions(-) create mode 100644 internal/services/agents/methods.go create mode 100644 internal/services/agents/service.go create mode 100644 internal/services/incidents/service.go create mode 100644 internal/store/repos/repo_agents/custom.go create mode 100644 internal/store/repos/repo_agents/find.go create mode 100644 internal/store/repos/repo_agents/update.go rename {pkg/dbutils => internal/store/storecmn}/duration.go (98%) rename {pkg/dbutils => internal/store/storecmn}/json.go (99%) rename {pkg/dbutils => internal/store/storecmn}/pagination.go (98%) rename {pkg/dbutils => internal/store/storecmn}/response.go (94%) rename {pkg/dbutils => internal/store/storecmn}/tx.go (95%) diff --git a/cmd/testapi/extended_tests.go b/cmd/testapi/extended_tests.go index de555e8..f81a3c9 100644 --- a/cmd/testapi/extended_tests.go +++ b/cmd/testapi/extended_tests.go @@ -9,8 +9,8 @@ import ( "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/monitors" + "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/web" - "github.com/sxwebdev/sentinel/pkg/dbutils" ) // Extended tests for comprehensive API coverage @@ -22,7 +22,7 @@ func testAdvancedServiceFilters(s *TestSuite) error { return err } - var result dbutils.FindResponseWithCount[web.ServiceDTO] + var result storecmn.FindResponseWithCount[web.ServiceDTO] if err := s.decodeResponse(resp, &result); err != nil { return err } @@ -238,7 +238,7 @@ func testServiceCRUDCompleteFlow(s *TestSuite) error { return fmt.Errorf("get service incidents: %w", err) } - var incidents dbutils.FindResponseWithCount[web.Incident] + var incidents storecmn.FindResponseWithCount[web.Incident] if err := s.decodeResponse(resp, &incidents); err != nil { return fmt.Errorf("decode service incidents: %w", err) } @@ -322,7 +322,7 @@ func testAdvancedIncidentManagement(s *TestSuite) error { return err } - var unresolvedIncidents dbutils.FindResponseWithCount[web.Incident] + var unresolvedIncidents storecmn.FindResponseWithCount[web.Incident] if err := s.decodeResponse(resp, &unresolvedIncidents); err != nil { return err } @@ -340,7 +340,7 @@ func testAdvancedIncidentManagement(s *TestSuite) error { return err } - var resolvedIncidents dbutils.FindResponseWithCount[web.Incident] + var resolvedIncidents storecmn.FindResponseWithCount[web.Incident] if err := s.decodeResponse(resp, &resolvedIncidents); err != nil { return err } @@ -367,7 +367,7 @@ func testAdvancedIncidentManagement(s *TestSuite) error { return err } - var timeFilteredIncidents dbutils.FindResponseWithCount[web.Incident] + var timeFilteredIncidents storecmn.FindResponseWithCount[web.Incident] if err := s.decodeResponse(resp, &timeFilteredIncidents); err != nil { return err } @@ -378,7 +378,7 @@ func testAdvancedIncidentManagement(s *TestSuite) error { return err } - var paginatedIncidents dbutils.FindResponseWithCount[web.Incident] + var paginatedIncidents storecmn.FindResponseWithCount[web.Incident] if err := s.decodeResponse(resp, &paginatedIncidents); err != nil { return err } @@ -408,7 +408,7 @@ func testAdvancedIncidentManagement(s *TestSuite) error { return err } - var searchResults dbutils.FindResponseWithCount[web.Incident] + var searchResults storecmn.FindResponseWithCount[web.Incident] if err := s.decodeResponse(resp, &searchResults); err != nil { return err } @@ -642,7 +642,7 @@ func testAdvancedPaginationAndSorting(s *TestSuite) error { return fmt.Errorf("pagination test page %d size %d: %w", page, pageSize, err) } - var result dbutils.FindResponseWithCount[web.ServiceDTO] + var result storecmn.FindResponseWithCount[web.ServiceDTO] if err := s.decodeResponse(resp, &result); err != nil { return fmt.Errorf("decode pagination test page %d size %d: %w", page, pageSize, err) } @@ -684,7 +684,7 @@ func testAdvancedPaginationAndSorting(s *TestSuite) error { return fmt.Errorf("order by created_at test: %w", err) } - var orderedResult dbutils.FindResponseWithCount[web.ServiceDTO] + var orderedResult storecmn.FindResponseWithCount[web.ServiceDTO] if err := s.decodeResponse(resp, &orderedResult); err != nil { return fmt.Errorf("decode order by created_at test: %w", err) } diff --git a/cmd/testapi/main.go b/cmd/testapi/main.go index 7ab1aae..514419d 100644 --- a/cmd/testapi/main.go +++ b/cmd/testapi/main.go @@ -21,9 +21,9 @@ import ( "github.com/sxwebdev/sentinel/internal/notifier" "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/storage" + "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/upgrader" "github.com/sxwebdev/sentinel/internal/web" - "github.com/sxwebdev/sentinel/pkg/dbutils" "github.com/tkcrm/mx/logger" ) @@ -409,7 +409,7 @@ func testGetServices(s *TestSuite) error { return err } - var result dbutils.FindResponseWithCount[web.ServiceDTO] + var result storecmn.FindResponseWithCount[web.ServiceDTO] if err := s.decodeResponse(resp, &result); err != nil { return err } @@ -432,7 +432,7 @@ func testServiceFilters(s *TestSuite) error { return err } - var result dbutils.FindResponseWithCount[web.ServiceDTO] + var result storecmn.FindResponseWithCount[web.ServiceDTO] if err := s.decodeResponse(resp, &result); err != nil { return err } @@ -714,7 +714,7 @@ func testIncidents(s *TestSuite) error { return err } - var incidents dbutils.FindResponseWithCount[web.Incident] + var incidents storecmn.FindResponseWithCount[web.Incident] if err := s.decodeResponse(resp, &incidents); err != nil { return err } @@ -732,7 +732,7 @@ func testIncidents(s *TestSuite) error { return err } - var serviceIncidents dbutils.FindResponseWithCount[web.Incident] + var serviceIncidents storecmn.FindResponseWithCount[web.Incident] if err := s.decodeResponse(resp, &serviceIncidents); err != nil { return err } @@ -761,7 +761,7 @@ func testIncidentFilters(s *TestSuite) error { return err } - var incidents dbutils.FindResponseWithCount[web.Incident] + var incidents storecmn.FindResponseWithCount[web.Incident] if err := s.decodeResponse(resp, &incidents); err != nil { return err } @@ -848,7 +848,7 @@ func testPagination(s *TestSuite) error { return err } - var result dbutils.FindResponseWithCount[web.ServiceDTO] + var result storecmn.FindResponseWithCount[web.ServiceDTO] if err := s.decodeResponse(resp, &result); err != nil { return err } @@ -863,7 +863,7 @@ func testPagination(s *TestSuite) error { return err } - var result2 dbutils.FindResponseWithCount[web.ServiceDTO] + var result2 storecmn.FindResponseWithCount[web.ServiceDTO] if err := s.decodeResponse(resp, &result2); err != nil { return err } diff --git a/cmd/testapi/model_tests.go b/cmd/testapi/model_tests.go index 21de5d6..8fab81d 100644 --- a/cmd/testapi/model_tests.go +++ b/cmd/testapi/model_tests.go @@ -6,8 +6,8 @@ import ( "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/monitors" + "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/web" - "github.com/sxwebdev/sentinel/pkg/dbutils" ) // Model validation tests @@ -284,7 +284,7 @@ func testIncidentFields(s *TestSuite) error { return fmt.Errorf("get incidents for validation: %w", err) } - var incidents dbutils.FindResponseWithCount[web.Incident] + var incidents storecmn.FindResponseWithCount[web.Incident] if err := s.decodeResponse(resp, &incidents); err != nil { return fmt.Errorf("decode incidents for validation: %w", err) } @@ -552,7 +552,7 @@ func testPaginationResponseModel(s *TestSuite) error { return fmt.Errorf("get paginated services: %w", err) } - var result dbutils.FindResponseWithCount[web.ServiceDTO] + var result storecmn.FindResponseWithCount[web.ServiceDTO] if err := s.decodeResponse(resp, &result); err != nil { return fmt.Errorf("decode paginated services: %w", err) } @@ -569,7 +569,7 @@ func testPaginationResponseModel(s *TestSuite) error { return fmt.Errorf("get paginated incidents: %w", err) } - var incidentResult dbutils.FindResponseWithCount[web.Incident] + var incidentResult storecmn.FindResponseWithCount[web.Incident] if err := s.decodeResponse(resp, &incidentResult); err != nil { return fmt.Errorf("decode paginated incidents: %w", err) } diff --git a/docs/docsv1/docs.go b/docs/docsv1/docs.go index 9fa6fa5..37a86ad 100644 --- a/docs/docsv1/docs.go +++ b/docs/docsv1/docs.go @@ -96,7 +96,7 @@ const docTemplate = `{ "200": { "description": "List of incidents", "schema": { - "$ref": "#/definitions/dbutils.FindResponseWithCount-storage_Incident" + "$ref": "#/definitions/storecmn.FindResponseWithCount-storage_Incident" } }, "500": { @@ -320,7 +320,7 @@ const docTemplate = `{ "200": { "description": "List of services with states", "schema": { - "$ref": "#/definitions/dbutils.FindResponseWithCount-web_ServiceDTO" + "$ref": "#/definitions/storecmn.FindResponseWithCount-web_ServiceDTO" } }, "500": { @@ -616,7 +616,7 @@ const docTemplate = `{ "200": { "description": "List of incidents", "schema": { - "$ref": "#/definitions/dbutils.FindResponseWithCount-storage_Incident" + "$ref": "#/definitions/storecmn.FindResponseWithCount-storage_Incident" } }, "400": { @@ -848,34 +848,51 @@ const docTemplate = `{ } }, "definitions": { - "dbutils.FindResponseWithCount-storage_Incident": { + "models.AvailableUpdate": { "type": "object", "properties": { - "count": { - "type": "integer" + "description": { + "type": "string" }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/storage.Incident" - } - } - } - }, - "dbutils.FindResponseWithCount-web_ServiceDTO": { - "type": "object", - "properties": { - "count": { - "type": "integer" + "is_available_manual": { + "type": "boolean" }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/web.ServiceDTO" - } + "tag_name": { + "type": "string" + }, + "url": { + "type": "string" } } }, + "models.ServiceProtocolType": { + "type": "string", + "enum": [ + "http", + "tcp", + "grpc" + ], + "x-enum-varnames": [ + "ServiceProtocolTypeHTTP", + "ServiceProtocolTypeTCP", + "ServiceProtocolTypeGRPC" + ] + }, + "models.ServiceStatus": { + "type": "string", + "enum": [ + "unknown", + "up", + "down", + "maintenance" + ], + "x-enum-varnames": [ + "StatusUnknown", + "StatusUp", + "StatusDown", + "StatusMaintenance" + ] + }, "monitors.Config": { "type": "object", "properties": { @@ -1038,19 +1055,6 @@ const docTemplate = `{ } } }, - "storage.ServiceProtocolType": { - "type": "string", - "enum": [ - "http", - "tcp", - "grpc" - ], - "x-enum-varnames": [ - "ServiceProtocolTypeHTTP", - "ServiceProtocolTypeTCP", - "ServiceProtocolTypeGRPC" - ] - }, "storage.ServiceStats": { "type": "object", "properties": { @@ -1074,35 +1078,31 @@ const docTemplate = `{ } } }, - "storage.ServiceStatus": { - "type": "string", - "enum": [ - "unknown", - "up", - "down", - "maintenance" - ], - "x-enum-varnames": [ - "StatusUnknown", - "StatusUp", - "StatusDown", - "StatusMaintenance" - ] - }, - "web.AvailableUpdate": { + "storecmn.FindResponseWithCount-storage_Incident": { "type": "object", "properties": { - "description": { - "type": "string" - }, - "is_available_manual": { - "type": "boolean" + "count": { + "type": "integer" }, - "tag_name": { - "type": "string" + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/storage.Incident" + } + } + } + }, + "storecmn.FindResponseWithCount-web_ServiceDTO": { + "type": "object", + "properties": { + "count": { + "type": "integer" }, - "url": { - "type": "string" + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/web.ServiceDTO" + } } } }, @@ -1127,7 +1127,7 @@ const docTemplate = `{ "protocol": { "allOf": [ { - "$ref": "#/definitions/storage.ServiceProtocolType" + "$ref": "#/definitions/models.ServiceProtocolType" } ], "example": "http" @@ -1221,7 +1221,7 @@ const docTemplate = `{ "example": "amd64" }, "available_update": { - "$ref": "#/definitions/web.AvailableUpdate" + "$ref": "#/definitions/models.AvailableUpdate" }, "build_date": { "type": "string", @@ -1298,7 +1298,7 @@ const docTemplate = `{ "protocol": { "allOf": [ { - "$ref": "#/definitions/storage.ServiceProtocolType" + "$ref": "#/definitions/models.ServiceProtocolType" } ], "example": "http" @@ -1314,7 +1314,7 @@ const docTemplate = `{ "status": { "allOf": [ { - "$ref": "#/definitions/storage.ServiceStatus" + "$ref": "#/definitions/models.ServiceStatus" } ], "example": "up / down / unknown" diff --git a/docs/docsv1/swagger.json b/docs/docsv1/swagger.json index 382fb79..b9ecad1 100644 --- a/docs/docsv1/swagger.json +++ b/docs/docsv1/swagger.json @@ -89,7 +89,7 @@ "200": { "description": "List of incidents", "schema": { - "$ref": "#/definitions/dbutils.FindResponseWithCount-storage_Incident" + "$ref": "#/definitions/storecmn.FindResponseWithCount-storage_Incident" } }, "500": { @@ -313,7 +313,7 @@ "200": { "description": "List of services with states", "schema": { - "$ref": "#/definitions/dbutils.FindResponseWithCount-web_ServiceDTO" + "$ref": "#/definitions/storecmn.FindResponseWithCount-web_ServiceDTO" } }, "500": { @@ -609,7 +609,7 @@ "200": { "description": "List of incidents", "schema": { - "$ref": "#/definitions/dbutils.FindResponseWithCount-storage_Incident" + "$ref": "#/definitions/storecmn.FindResponseWithCount-storage_Incident" } }, "400": { @@ -841,34 +841,51 @@ } }, "definitions": { - "dbutils.FindResponseWithCount-storage_Incident": { + "models.AvailableUpdate": { "type": "object", "properties": { - "count": { - "type": "integer" + "description": { + "type": "string" }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/storage.Incident" - } - } - } - }, - "dbutils.FindResponseWithCount-web_ServiceDTO": { - "type": "object", - "properties": { - "count": { - "type": "integer" + "is_available_manual": { + "type": "boolean" }, - "items": { - "type": "array", - "items": { - "$ref": "#/definitions/web.ServiceDTO" - } + "tag_name": { + "type": "string" + }, + "url": { + "type": "string" } } }, + "models.ServiceProtocolType": { + "type": "string", + "enum": [ + "http", + "tcp", + "grpc" + ], + "x-enum-varnames": [ + "ServiceProtocolTypeHTTP", + "ServiceProtocolTypeTCP", + "ServiceProtocolTypeGRPC" + ] + }, + "models.ServiceStatus": { + "type": "string", + "enum": [ + "unknown", + "up", + "down", + "maintenance" + ], + "x-enum-varnames": [ + "StatusUnknown", + "StatusUp", + "StatusDown", + "StatusMaintenance" + ] + }, "monitors.Config": { "type": "object", "properties": { @@ -1031,19 +1048,6 @@ } } }, - "storage.ServiceProtocolType": { - "type": "string", - "enum": [ - "http", - "tcp", - "grpc" - ], - "x-enum-varnames": [ - "ServiceProtocolTypeHTTP", - "ServiceProtocolTypeTCP", - "ServiceProtocolTypeGRPC" - ] - }, "storage.ServiceStats": { "type": "object", "properties": { @@ -1067,35 +1071,31 @@ } } }, - "storage.ServiceStatus": { - "type": "string", - "enum": [ - "unknown", - "up", - "down", - "maintenance" - ], - "x-enum-varnames": [ - "StatusUnknown", - "StatusUp", - "StatusDown", - "StatusMaintenance" - ] - }, - "web.AvailableUpdate": { + "storecmn.FindResponseWithCount-storage_Incident": { "type": "object", "properties": { - "description": { - "type": "string" - }, - "is_available_manual": { - "type": "boolean" + "count": { + "type": "integer" }, - "tag_name": { - "type": "string" + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/storage.Incident" + } + } + } + }, + "storecmn.FindResponseWithCount-web_ServiceDTO": { + "type": "object", + "properties": { + "count": { + "type": "integer" }, - "url": { - "type": "string" + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/web.ServiceDTO" + } } } }, @@ -1120,7 +1120,7 @@ "protocol": { "allOf": [ { - "$ref": "#/definitions/storage.ServiceProtocolType" + "$ref": "#/definitions/models.ServiceProtocolType" } ], "example": "http" @@ -1214,7 +1214,7 @@ "example": "amd64" }, "available_update": { - "$ref": "#/definitions/web.AvailableUpdate" + "$ref": "#/definitions/models.AvailableUpdate" }, "build_date": { "type": "string", @@ -1291,7 +1291,7 @@ "protocol": { "allOf": [ { - "$ref": "#/definitions/storage.ServiceProtocolType" + "$ref": "#/definitions/models.ServiceProtocolType" } ], "example": "http" @@ -1307,7 +1307,7 @@ "status": { "allOf": [ { - "$ref": "#/definitions/storage.ServiceStatus" + "$ref": "#/definitions/models.ServiceStatus" } ], "example": "up / down / unknown" diff --git a/docs/docsv1/swagger.yaml b/docs/docsv1/swagger.yaml index 2ad658e..5bc9bfe 100644 --- a/docs/docsv1/swagger.yaml +++ b/docs/docsv1/swagger.yaml @@ -1,23 +1,38 @@ basePath: /api/v1 definitions: - dbutils.FindResponseWithCount-storage_Incident: + models.AvailableUpdate: properties: - count: - type: integer - items: - items: - $ref: '#/definitions/storage.Incident' - type: array - type: object - dbutils.FindResponseWithCount-web_ServiceDTO: - properties: - count: - type: integer - items: - items: - $ref: '#/definitions/web.ServiceDTO' - type: array + description: + type: string + is_available_manual: + type: boolean + tag_name: + type: string + url: + type: string type: object + models.ServiceProtocolType: + enum: + - http + - tcp + - grpc + type: string + x-enum-varnames: + - ServiceProtocolTypeHTTP + - ServiceProtocolTypeTCP + - ServiceProtocolTypeGRPC + models.ServiceStatus: + enum: + - unknown + - up + - down + - maintenance + type: string + x-enum-varnames: + - StatusUnknown + - StatusUp + - StatusDown + - StatusMaintenance monitors.Config: properties: grpc: @@ -130,16 +145,6 @@ definitions: start_time: type: string type: object - storage.ServiceProtocolType: - enum: - - http - - tcp - - grpc - type: string - x-enum-varnames: - - ServiceProtocolTypeHTTP - - ServiceProtocolTypeTCP - - ServiceProtocolTypeGRPC storage.ServiceStats: properties: avg_response_time: @@ -155,28 +160,23 @@ definitions: uptime_percentage: type: number type: object - storage.ServiceStatus: - enum: - - unknown - - up - - down - - maintenance - type: string - x-enum-varnames: - - StatusUnknown - - StatusUp - - StatusDown - - StatusMaintenance - web.AvailableUpdate: + storecmn.FindResponseWithCount-storage_Incident: properties: - description: - type: string - is_available_manual: - type: boolean - tag_name: - type: string - url: - type: string + count: + type: integer + items: + items: + $ref: '#/definitions/storage.Incident' + type: array + type: object + storecmn.FindResponseWithCount-web_ServiceDTO: + properties: + count: + type: integer + items: + items: + $ref: '#/definitions/web.ServiceDTO' + type: array type: object web.CreateUpdateServiceRequest: properties: @@ -193,7 +193,7 @@ definitions: type: string protocol: allOf: - - $ref: '#/definitions/storage.ServiceProtocolType' + - $ref: '#/definitions/models.ServiceProtocolType' example: http retries: example: 5 @@ -259,7 +259,7 @@ definitions: example: amd64 type: string available_update: - $ref: '#/definitions/web.AvailableUpdate' + $ref: '#/definitions/models.AvailableUpdate' build_date: example: "2023-10-01T12:00:00Z" type: string @@ -315,7 +315,7 @@ definitions: type: string protocol: allOf: - - $ref: '#/definitions/storage.ServiceProtocolType' + - $ref: '#/definitions/models.ServiceProtocolType' example: http response_time: example: 150000000 @@ -325,7 +325,7 @@ definitions: type: integer status: allOf: - - $ref: '#/definitions/storage.ServiceStatus' + - $ref: '#/definitions/models.ServiceStatus' example: up / down / unknown tags: example: @@ -432,7 +432,7 @@ paths: "200": description: List of incidents schema: - $ref: '#/definitions/dbutils.FindResponseWithCount-storage_Incident' + $ref: '#/definitions/storecmn.FindResponseWithCount-storage_Incident' "500": description: Internal server error schema: @@ -580,7 +580,7 @@ paths: "200": description: List of services with states schema: - $ref: '#/definitions/dbutils.FindResponseWithCount-web_ServiceDTO' + $ref: '#/definitions/storecmn.FindResponseWithCount-web_ServiceDTO' "500": description: Internal server error schema: @@ -776,7 +776,7 @@ paths: "200": description: List of incidents schema: - $ref: '#/definitions/dbutils.FindResponseWithCount-storage_Incident' + $ref: '#/definitions/storecmn.FindResponseWithCount-storage_Incident' "400": description: Bad request schema: diff --git a/go.mod b/go.mod index 9fc7ece..2ca2da2 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/oklog/ulid/v2 v2.1.1 github.com/puzpuzpuz/xsync/v3 v3.5.1 github.com/rs/cors v1.11.1 + github.com/samber/lo v1.51.0 github.com/shirou/gopsutil/v4 v4.25.8 github.com/stretchr/testify v1.11.1 github.com/swaggo/fiber-swagger v1.3.0 @@ -26,6 +27,7 @@ require ( github.com/sxwebdev/xconfig v0.0.0-20250917185517-9fc0b932f57a github.com/sxwebdev/xconfig/decoders/xconfigdotenv v0.0.0-20250917185517-9fc0b932f57a github.com/sxwebdev/xconfig/decoders/xconfigyaml v0.0.0-20250917185517-9fc0b932f57a + github.com/tkcrm/modules v0.0.0-20250909093305-a0b86c209cc5 github.com/tkcrm/mx v0.2.34 github.com/tkcrm/mx/transport/connectrpc_transport v0.0.0-20250618055556-3f77aaa9ddbd github.com/urfave/cli/v3 v3.4.1 @@ -71,6 +73,7 @@ require ( github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-sourcemap/sourcemap v2.1.4+incompatible // indirect + github.com/goccy/go-json v0.10.5 // indirect github.com/google/pprof v0.0.0-20250903194437-c28834ac2320 // indirect github.com/google/uuid v1.6.0 // indirect github.com/huandu/go-clone v1.7.3 // indirect diff --git a/go.sum b/go.sum index 6cb455b..be6c459 100644 --- a/go.sum +++ b/go.sum @@ -106,6 +106,8 @@ github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEe github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/gobeam/stringy v0.0.7 h1:TD8SfhedUoiANhW88JlJqfrMsihskIRpU/VTsHGnAps= github.com/gobeam/stringy v0.0.7/go.mod h1:W3620X9dJHf2FSZF5fRnWekHcHQjwmCz8ZQ2d1qloqE= +github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/gofiber/contrib/websocket v1.3.4 h1:tWeBdbJ8q0WFQXariLN4dBIbGH9KBU75s0s7YXplOSg= @@ -140,12 +142,12 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= -github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg= -github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E= -github.com/jackc/pgx/v5 v5.0.0 h1:3UdmB3yUeTnJtZ+nDv3Mxzd4GHHvHkl9XN3oboIbOrY= -github.com/jackc/pgx/v5 v5.0.0/go.mod h1:JBbvW3Hdw77jKl9uJrEDATUZIFM2VFPzRq4RWIhkF4o= -github.com/jackc/puddle/v2 v2.0.0 h1:Kwk/AlLigcnZsDssc3Zun1dk1tAtQNPaBBxBHWn0Mjc= -github.com/jackc/puddle/v2 v2.0.0/go.mod h1:itE7ZJY8xnoo0JqJEpSMprN0f+NQkMCuEV/N9j8h0oc= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= +github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= @@ -227,6 +229,8 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/rs/cors v1.11.1 h1:eU3gRzXLRK57F5rKMGMZURNdIG4EoAmX8k94r9wXWHA= github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.51.0 h1:kysRYLbHy/MB7kQZf5DSN50JHmMsNEdeY24VzJFu7wI= +github.com/samber/lo v1.51.0/go.mod h1:4+MXEGsJzbKGaUEQFKBq2xtfuznW9oz/WrgyzMzRoM0= github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287 h1:qIQ0tWF9vxGtkJa24bR+2i53WBCz1nW/Pc47oVYauC4= github.com/savsgio/gotils v0.0.0-20250408102913-196191ec6287/go.mod h1:sM7Mt7uEoCeFSCBM+qBrqvEo+/9vdmj19wzp3yzUhmg= github.com/shirou/gopsutil/v4 v4.25.8 h1:NnAsw9lN7587WHxjJA9ryDnqhJpFH6A+wagYWTOH970= @@ -257,6 +261,8 @@ github.com/sxwebdev/xconfig/decoders/xconfigdotenv v0.0.0-20250917185517-9fc0b93 github.com/sxwebdev/xconfig/decoders/xconfigdotenv v0.0.0-20250917185517-9fc0b932f57a/go.mod h1:NTNcUc35LpkCP8dPdvOWTbq9SLGC7eS1FDAHqzwSMrk= github.com/sxwebdev/xconfig/decoders/xconfigyaml v0.0.0-20250917185517-9fc0b932f57a h1:ZJ0xDxSDwo9GzhwfkNZl0ZdnBBqe7alWetKF+860Ohw= github.com/sxwebdev/xconfig/decoders/xconfigyaml v0.0.0-20250917185517-9fc0b932f57a/go.mod h1:cGLysNNHvnNHKYFVsHfudRZ7BbjXlaDBjFQXiBN4B30= +github.com/tkcrm/modules v0.0.0-20250909093305-a0b86c209cc5 h1:rqtgAZFbz/Vdnyf6GvoUEP4DWgqvj6/PsI3Gce81h5A= +github.com/tkcrm/modules v0.0.0-20250909093305-a0b86c209cc5/go.mod h1:uYV10lVr7Ba+Udzdc9kBUMeGd7xILwALLs4yZC7aAyA= github.com/tkcrm/mx v0.2.34 h1:reTg836KS00FI+QMBTQIa2wh6/Z28PE7cHBtTw7Y5nQ= github.com/tkcrm/mx v0.2.34/go.mod h1:9N8UrILT8mg0IWb2MMtq2MqOMW1CQVIMmEh9ML37N+0= github.com/tkcrm/mx/transport/connectrpc_transport v0.0.0-20250618055556-3f77aaa9ddbd h1:a2zSOXliAdXfGKPH9h6QJWpfh0Uv/zfShlp+JzB85Ns= diff --git a/internal/models/models_gen.go b/internal/models/models_gen.go index aba15e8..e012ac5 100644 --- a/internal/models/models_gen.go +++ b/internal/models/models_gen.go @@ -7,24 +7,27 @@ package models import ( "time" - "github.com/sxwebdev/sentinel/pkg/dbutils" + "github.com/sxwebdev/sentinel/internal/store/storecmn" ) type Agent struct { - ID string `db:"id" json:"id"` - Name string `db:"name" json:"name"` - Description *string `db:"description" json:"description"` - Host *string `db:"host" json:"host"` - Port *int64 `db:"port" json:"port"` - TokenCt []byte `db:"token_ct" json:"token_ct"` - TokenNonce []byte `db:"token_nonce" json:"token_nonce"` - TokenHint *string `db:"token_hint" json:"token_hint"` - Fingerprint *string `db:"fingerprint" json:"fingerprint"` - IsActive bool `db:"is_active" json:"is_active"` - SystemInfo dbutils.JSONField `db:"system_info" json:"system_info"` - LastSeenAt *time.Time `db:"last_seen_at" json:"last_seen_at"` - CreatedAt *time.Time `db:"created_at" json:"created_at"` - UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` + ID string `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Description *string `db:"description" json:"description"` + Host string `db:"host" json:"host"` + Port int64 `db:"port" json:"port"` + TokenCt []byte `db:"token_ct" json:"token_ct"` + TokenNonce []byte `db:"token_nonce" json:"token_nonce"` + TokenHint string `db:"token_hint" json:"token_hint"` + Fingerprint *string `db:"fingerprint" json:"fingerprint"` + Status string `db:"status" json:"status"` + IsEnabled bool `db:"is_enabled" json:"is_enabled"` + Tags storecmn.JSONField `db:"tags" json:"tags"` + Config storecmn.JSONField `db:"config" json:"config"` + SystemInfo storecmn.JSONField `db:"system_info" json:"system_info"` + LastSeenAt *time.Time `db:"last_seen_at" json:"last_seen_at"` + CreatedAt *time.Time `db:"created_at" json:"created_at"` + UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` } type Incident struct { @@ -43,11 +46,11 @@ type Service struct { ID string `db:"id" json:"id"` Name string `db:"name" json:"name"` Protocol ServiceProtocolType `db:"protocol" json:"protocol"` - Interval dbutils.Duration `db:"interval" json:"interval"` - Timeout dbutils.Duration `db:"timeout" json:"timeout"` + Interval storecmn.Duration `db:"interval" json:"interval"` + Timeout storecmn.Duration `db:"timeout" json:"timeout"` Retries int64 `db:"retries" json:"retries"` - Tags dbutils.JSONField `db:"tags" json:"tags"` - Config dbutils.JSONField `db:"config" json:"config"` + Tags storecmn.JSONField `db:"tags" json:"tags"` + Config storecmn.JSONField `db:"config" json:"config"` IsEnabled bool `db:"is_enabled" json:"is_enabled"` CreatedAt *time.Time `db:"created_at" json:"created_at"` UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 6d057a4..136f4ba 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -35,7 +35,7 @@ func NewMonitorService(store *store.Store, storage *storage.Storage, config *con } // FindServices loads all enabled services from storage and initializes monitoring -// func (m *MonitorService) FindServices(ctx context.Context, params storage.FindServicesParams) (dbutils.FindResponseWithCount[*storage.Service], error) { +// func (m *MonitorService) FindServices(ctx context.Context, params storage.FindServicesParams) (storecmn.FindResponseWithCount[*storage.Service], error) { // return m.storage.FindServices(ctx, params) // } diff --git a/internal/services/agents/methods.go b/internal/services/agents/methods.go new file mode 100644 index 0000000..bce94c6 --- /dev/null +++ b/internal/services/agents/methods.go @@ -0,0 +1,73 @@ +package agents + +import ( + "context" + "fmt" + "slices" + + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/repos/repo_agents" + "github.com/sxwebdev/sentinel/internal/store/storecmn" + "github.com/sxwebdev/sentinel/internal/utils" +) + +type CreateParams struct { + Name string + Description *string + Host string + Port int64 + TokenCt []byte + TokenNonce []byte + TokenHint string + Tags []string + Config map[string]any +} + +// Create +func (s *Service) Create(ctx context.Context, params CreateParams) (*models.Agent, error) { + if len(params.Tags) > 0 { + slices.Sort(params.Tags) + } + + // Convert tags to JSONField + tags := storecmn.JSONField("[]") + if err := tags.UnmarshalAny(params.Tags); err != nil { + return nil, fmt.Errorf("failed to convert tags to json raw message: %w", err) + } + + // Convert config to JSONField + config := storecmn.JSONField("{}") + if err := config.UnmarshalAny(params.Config); err != nil { + return nil, fmt.Errorf("failed to convert config to json raw message: %w", err) + } + + createParams := repo_agents.CreateParams{ + ID: utils.GenerateULID(), + Name: params.Name, + Description: params.Description, + Host: params.Host, + Port: params.Port, + TokenCt: params.TokenCt, + TokenNonce: params.TokenNonce, + TokenHint: params.TokenHint, + Tags: tags, + Config: config, + } + + return s.store.Agents().Create(ctx, createParams) +} + +// Delete +func (s *Service) Delete(ctx context.Context, id string) error { + return s.store.Agents().Delete(ctx, id) +} + +// GetByID +func (s *Service) GetByID(ctx context.Context, id string) (*models.Agent, error) { + return s.store.Agents().GetByID(ctx, id) +} + +// GetAll +func (s *Service) GetAll(ctx context.Context) ([]*models.Agent, error) { + return s.store.Agents().GetAll(ctx) +} diff --git a/internal/services/agents/service.go b/internal/services/agents/service.go new file mode 100644 index 0000000..e20d479 --- /dev/null +++ b/internal/services/agents/service.go @@ -0,0 +1,15 @@ +package agents + +import ( + "github.com/sxwebdev/sentinel/internal/store" +) + +type Service struct { + store *store.Store +} + +func New(store *store.Store) *Service { + return &Service{ + store: store, + } +} diff --git a/internal/services/baseservices/base.go b/internal/services/baseservices/base.go index 4a21a0b..55178a0 100644 --- a/internal/services/baseservices/base.go +++ b/internal/services/baseservices/base.go @@ -2,26 +2,44 @@ package baseservices import ( "github.com/sxwebdev/sentinel/internal/receiver" + "github.com/sxwebdev/sentinel/internal/services/agents" + "github.com/sxwebdev/sentinel/internal/services/incidents" "github.com/sxwebdev/sentinel/internal/services/service" "github.com/sxwebdev/sentinel/internal/store" ) type BaseServices struct { - services *service.Service + agentsService *agents.Service + servicesService *service.Service + incidentsService *incidents.Service } func New( st *store.Store, receiver *receiver.Receiver, ) *BaseServices { + agentsService := agents.New(st) servicesService := service.New(st, receiver) + incidentsService := incidents.New(st) return &BaseServices{ - services: servicesService, + agentsService: agentsService, + servicesService: servicesService, + incidentsService: incidentsService, } } +// Agents returns agents service +func (b *BaseServices) Agents() *agents.Service { + return b.agentsService +} + // Services returns services service func (b *BaseServices) Services() *service.Service { - return b.services + return b.servicesService +} + +// Incidents returns incidents service +func (b *BaseServices) Incidents() *incidents.Service { + return b.incidentsService } diff --git a/internal/services/incidents/service.go b/internal/services/incidents/service.go new file mode 100644 index 0000000..0960d34 --- /dev/null +++ b/internal/services/incidents/service.go @@ -0,0 +1,15 @@ +package incidents + +import ( + "github.com/sxwebdev/sentinel/internal/store" +) + +type Service struct { + store *store.Store +} + +func New(store *store.Store) *Service { + return &Service{ + store: store, + } +} diff --git a/internal/services/service/methods.go b/internal/services/service/methods.go index 1a22158..7f37053 100644 --- a/internal/services/service/methods.go +++ b/internal/services/service/methods.go @@ -15,7 +15,6 @@ import ( "github.com/sxwebdev/sentinel/internal/store/repos/repo_services" "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/utils" - "github.com/sxwebdev/sentinel/pkg/dbutils" ) type CreateUpdateParams struct { @@ -36,13 +35,13 @@ func (s *Service) Create(ctx context.Context, params CreateUpdateParams) (*model } // Convert tags to JSONField - tags := dbutils.JSONField("[]") + tags := storecmn.JSONField("[]") if err := tags.UnmarshalAny(params.Tags); err != nil { return nil, fmt.Errorf("failed to convert tags to json raw message: %w", err) } // Convert config to JSONField - config := dbutils.JSONField("{}") + config := storecmn.JSONField("{}") if err := config.UnmarshalAny(params.Config); err != nil { return nil, fmt.Errorf("failed to convert config to json raw message: %w", err) } @@ -51,15 +50,15 @@ func (s *Service) Create(ctx context.Context, params CreateUpdateParams) (*model ID: utils.GenerateULID(), Name: params.Name, Protocol: params.Protocol, - Interval: dbutils.Duration(params.Interval), - Timeout: dbutils.Duration(params.Timeout), + Interval: storecmn.Duration(params.Interval), + Timeout: storecmn.Duration(params.Timeout), Retries: params.Retries, Tags: tags, Config: config, IsEnabled: params.IsEnabled, } - err := dbutils.WrapTx(ctx, s.store.SQLite(), func(tx *sql.Tx) error { + err := storecmn.WrapTx(ctx, s.store.SQLite(), func(tx *sql.Tx) error { // Create service _, err := s.store.Services(repos.WithTx(tx)).Create(ctx, createParams) if err != nil { @@ -106,13 +105,13 @@ func (s *Service) Update(ctx context.Context, id string, params CreateUpdatePara } // Convert tags to JSONField - tags := dbutils.JSONField("[]") + tags := storecmn.JSONField("[]") if err := tags.UnmarshalAny(params.Tags); err != nil { return nil, fmt.Errorf("failed to convert tags to json raw message: %w", err) } // Convert config to JSONField - config := dbutils.JSONField("{}") + config := storecmn.JSONField("{}") if err := config.UnmarshalAny(params.Config); err != nil { return nil, fmt.Errorf("failed to convert config to json raw message: %w", err) } @@ -120,8 +119,8 @@ func (s *Service) Update(ctx context.Context, id string, params CreateUpdatePara updateParams := repo_services.UpdateServiceRequest{ Name: params.Name, Protocol: params.Protocol, - Interval: dbutils.Duration(params.Interval), - Timeout: dbutils.Duration(params.Timeout), + Interval: storecmn.Duration(params.Interval), + Timeout: storecmn.Duration(params.Timeout), Retries: params.Retries, Tags: tags, Config: config, @@ -162,7 +161,7 @@ func (s *Service) GetViewByID(ctx context.Context, id string) (*models.ServiceFu type FindParams = repo_services.FindParams // FindView services by params -func (s *Service) FindView(ctx context.Context, params FindParams) (*dbutils.FindResponseWithCount[*models.ServiceFullView], error) { +func (s *Service) FindView(ctx context.Context, params FindParams) (*storecmn.FindResponseWithCount[*models.ServiceFullView], error) { return s.store.Services().FindView(ctx, params) } @@ -175,7 +174,7 @@ func (s *Service) Delete(ctx context.Context, id string) error { } // Delete service and related data in a transaction - err = dbutils.WrapTx(ctx, s.store.SQLite(), func(tx *sql.Tx) error { + err = storecmn.WrapTx(ctx, s.store.SQLite(), func(tx *sql.Tx) error { if err := s.store.ServiceStates(repos.WithTx(tx)).DeleteByServiceID(ctx, id); err != nil { return fmt.Errorf("failed to delete service states: %w", err) } diff --git a/internal/storage/incidents.go b/internal/storage/incidents.go index b6a6243..a31c5cc 100644 --- a/internal/storage/incidents.go +++ b/internal/storage/incidents.go @@ -8,8 +8,8 @@ import ( "time" "github.com/huandu/go-sqlbuilder" + "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/utils" - "github.com/sxwebdev/sentinel/pkg/dbutils" ) // IncidentRow represents a database row for incidents @@ -120,7 +120,7 @@ func findIncidentsBuilder(params FindIncidentsParams, col ...string) *sqlbuilder } // FindIncidents finds incidents -func (o *Storage) FindIncidents(ctx context.Context, params FindIncidentsParams) (dbutils.FindResponseWithCount[*Incident], error) { +func (o *Storage) FindIncidents(ctx context.Context, params FindIncidentsParams) (storecmn.FindResponseWithCount[*Incident], error) { sb := findIncidentsBuilder(params, "i.id", "i.service_id", @@ -134,9 +134,9 @@ func (o *Storage) FindIncidents(ctx context.Context, params FindIncidentsParams) ) sb.OrderBy("i.start_time").Desc() - res := dbutils.FindResponseWithCount[*Incident]{} + res := storecmn.FindResponseWithCount[*Incident]{} - limit, offset, err := dbutils.Pagination(params.Page, params.PageSize) + limit, offset, err := storecmn.Pagination(params.Page, params.PageSize) if err != nil { return res, fmt.Errorf("failed to apply pagination: %w", err) } diff --git a/internal/storage/services.go b/internal/storage/services.go index 81c9611..cf2c685 100644 --- a/internal/storage/services.go +++ b/internal/storage/services.go @@ -10,8 +10,8 @@ import ( "time" "github.com/huandu/go-sqlbuilder" + "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/utils" - "github.com/sxwebdev/sentinel/pkg/dbutils" ) // GetServiceStats calculates statistics for a service @@ -258,7 +258,7 @@ type FindServicesParams struct { } // GetAllServices finds all services using ORM -func (o *Storage) FindServices(ctx context.Context, params FindServicesParams) (dbutils.FindResponseWithCount[*Service], error) { +func (o *Storage) FindServices(ctx context.Context, params FindServicesParams) (storecmn.FindResponseWithCount[*Service], error) { sb := findServicesBuilder( params, "s.id", @@ -309,9 +309,9 @@ func (o *Storage) FindServices(ctx context.Context, params FindServicesParams) ( sb.OrderBy("s.name") } - res := dbutils.FindResponseWithCount[*Service]{} + res := storecmn.FindResponseWithCount[*Service]{} - limit, offset, err := dbutils.Pagination(params.Page, params.PageSize) + limit, offset, err := storecmn.Pagination(params.Page, params.PageSize) if err != nil { return res, err } diff --git a/internal/store/repos/repo_agents/agents_gen.sql.go b/internal/store/repos/repo_agents/agents_gen.sql.go index fe41122..e51613f 100644 --- a/internal/store/repos/repo_agents/agents_gen.sql.go +++ b/internal/store/repos/repo_agents/agents_gen.sql.go @@ -9,23 +9,28 @@ import ( "context" "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/storecmn" ) const create = `-- name: Create :one -INSERT INTO agents (id, name, description, host, port, token_ct, token_nonce, token_hint) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - RETURNING id, name, description, host, port, token_ct, token_nonce, token_hint, fingerprint, is_active, json(system_info), last_seen_at, created_at, updated_at +INSERT INTO agents (id, name, description, host, port, token_ct, token_nonce, token_hint, status, is_enabled, tags, config) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id, name, description, host, port, token_ct, token_nonce, token_hint, fingerprint, status, is_enabled, json(tags), json(config), json(system_info), last_seen_at, created_at, updated_at ` type CreateParams struct { - ID string `db:"id" json:"id"` - Name string `db:"name" json:"name"` - Description *string `db:"description" json:"description"` - Host *string `db:"host" json:"host"` - Port *int64 `db:"port" json:"port"` - TokenCt []byte `db:"token_ct" json:"token_ct"` - TokenNonce []byte `db:"token_nonce" json:"token_nonce"` - TokenHint *string `db:"token_hint" json:"token_hint"` + ID string `db:"id" json:"id"` + Name string `db:"name" json:"name"` + Description *string `db:"description" json:"description"` + Host string `db:"host" json:"host"` + Port int64 `db:"port" json:"port"` + TokenCt []byte `db:"token_ct" json:"token_ct"` + TokenNonce []byte `db:"token_nonce" json:"token_nonce"` + TokenHint string `db:"token_hint" json:"token_hint"` + Status string `db:"status" json:"status"` + IsEnabled bool `db:"is_enabled" json:"is_enabled"` + Tags storecmn.JSONField `db:"tags" json:"tags"` + Config storecmn.JSONField `db:"config" json:"config"` } func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.Agent, error) { @@ -38,6 +43,10 @@ func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.Agent, arg.TokenCt, arg.TokenNonce, arg.TokenHint, + arg.Status, + arg.IsEnabled, + arg.Tags, + arg.Config, ) var i models.Agent err := row.Scan( @@ -50,7 +59,10 @@ func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.Agent, &i.TokenNonce, &i.TokenHint, &i.Fingerprint, - &i.IsActive, + &i.Status, + &i.IsEnabled, + &i.Tags, + &i.Config, &i.SystemInfo, &i.LastSeenAt, &i.CreatedAt, @@ -69,7 +81,7 @@ func (q *Queries) Delete(ctx context.Context, id string) error { } const getAll = `-- name: GetAll :many -SELECT id, name, description, host, port, token_ct, token_nonce, token_hint, fingerprint, is_active, json(system_info), last_seen_at, created_at, updated_at FROM agents +SELECT id, name, description, host, port, token_ct, token_nonce, token_hint, fingerprint, status, is_enabled, json(tags), json(config), json(system_info), last_seen_at, created_at, updated_at FROM agents ` func (q *Queries) GetAll(ctx context.Context) ([]*models.Agent, error) { @@ -91,7 +103,10 @@ func (q *Queries) GetAll(ctx context.Context) ([]*models.Agent, error) { &i.TokenNonce, &i.TokenHint, &i.Fingerprint, - &i.IsActive, + &i.Status, + &i.IsEnabled, + &i.Tags, + &i.Config, &i.SystemInfo, &i.LastSeenAt, &i.CreatedAt, @@ -111,7 +126,7 @@ func (q *Queries) GetAll(ctx context.Context) ([]*models.Agent, error) { } const getByID = `-- name: GetByID :one -SELECT id, name, description, host, port, token_ct, token_nonce, token_hint, fingerprint, is_active, json(system_info), last_seen_at, created_at, updated_at FROM agents WHERE id=? LIMIT 1 +SELECT id, name, description, host, port, token_ct, token_nonce, token_hint, fingerprint, status, is_enabled, json(tags), json(config), json(system_info), last_seen_at, created_at, updated_at FROM agents WHERE id=? LIMIT 1 ` func (q *Queries) GetByID(ctx context.Context, id string) (*models.Agent, error) { @@ -127,7 +142,10 @@ func (q *Queries) GetByID(ctx context.Context, id string) (*models.Agent, error) &i.TokenNonce, &i.TokenHint, &i.Fingerprint, - &i.IsActive, + &i.Status, + &i.IsEnabled, + &i.Tags, + &i.Config, &i.SystemInfo, &i.LastSeenAt, &i.CreatedAt, diff --git a/internal/store/repos/repo_agents/constants_gen.go b/internal/store/repos/repo_agents/constants_gen.go index 2284222..b4ced43 100755 --- a/internal/store/repos/repo_agents/constants_gen.go +++ b/internal/store/repos/repo_agents/constants_gen.go @@ -48,7 +48,10 @@ const ( ColumnNameAgentsTokenNonce ColumnName = "token_nonce" ColumnNameAgentsTokenHint ColumnName = "token_hint" ColumnNameAgentsFingerprint ColumnName = "fingerprint" - ColumnNameAgentsIsActive ColumnName = "is_active" + ColumnNameAgentsStatus ColumnName = "status" + ColumnNameAgentsIsEnabled ColumnName = "is_enabled" + ColumnNameAgentsTags ColumnName = "tags" + ColumnNameAgentsConfig ColumnName = "config" ColumnNameAgentsSystemInfo ColumnName = "system_info" ColumnNameAgentsLastSeenAt ColumnName = "last_seen_at" ColumnNameAgentsCreatedAt ColumnName = "created_at" @@ -66,7 +69,10 @@ func AgentsColumnNames() ColumnNames { ColumnNameAgentsTokenNonce, ColumnNameAgentsTokenHint, ColumnNameAgentsFingerprint, - ColumnNameAgentsIsActive, + ColumnNameAgentsStatus, + ColumnNameAgentsIsEnabled, + ColumnNameAgentsTags, + ColumnNameAgentsConfig, ColumnNameAgentsSystemInfo, ColumnNameAgentsLastSeenAt, ColumnNameAgentsCreatedAt, diff --git a/internal/store/repos/repo_agents/custom.go b/internal/store/repos/repo_agents/custom.go new file mode 100644 index 0000000..063fb4c --- /dev/null +++ b/internal/store/repos/repo_agents/custom.go @@ -0,0 +1,36 @@ +package repo_agents + +import ( + "context" + "database/sql" + + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/storecmn" +) + +type ICustomQuerier interface { + Querier + Update(ctx context.Context, id string, params UpdateRequest) (*models.Agent, error) + Find(ctx context.Context, params FindParams) (*storecmn.FindResponseWithCount[*models.Agent], error) +} + +type CustomQueries struct { + *Queries + db DBTX +} + +func NewCustom(db DBTX) *CustomQueries { + return &CustomQueries{ + Queries: New(db), + db: db, + } +} + +func (s *CustomQueries) WithTx(tx *sql.Tx) *CustomQueries { + return &CustomQueries{ + Queries: New(tx), + db: tx, + } +} + +var _ ICustomQuerier = (*CustomQueries)(nil) diff --git a/internal/store/repos/repo_agents/find.go b/internal/store/repos/repo_agents/find.go new file mode 100644 index 0000000..fc4d02a --- /dev/null +++ b/internal/store/repos/repo_agents/find.go @@ -0,0 +1,92 @@ +package repo_agents + +import ( + "context" + "fmt" + "strings" + + "github.com/georgysavva/scany/v2/sqlscan" + "github.com/huandu/go-sqlbuilder" + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/storecmn" +) + +type FindParams struct { + Name string + IsEnabled *bool + Tags []string + Status string + OrderBy string + Page *uint32 + PageSize *uint32 +} + +func findBuilder(params FindParams, col ...string) *sqlbuilder.SelectBuilder { + sb := sqlbuilder.NewSelectBuilder() + sb.Select(col...) + sb.From(TableNameAgents.String()) + + if params.Name != "" { + sb.Where(sb.Like("name", "%"+params.Name+"%")) + } + + if params.IsEnabled != nil { + sb.Where(sb.Equal("is_enabled", *params.IsEnabled)) + } + + if params.Status != "" { + sb.Where(sb.Equal("status", params.Status)) + } + + if len(params.Tags) > 0 { + var tagConditions []string + for _, tag := range params.Tags { + tagConditions = append(tagConditions, + fmt.Sprintf("EXISTS (SELECT 1 FROM json_each(s.tags) WHERE json_each.value = %s)", + sb.Args.Add(tag))) + } + + if len(tagConditions) > 0 { + sb.Where(fmt.Sprintf("(%s)", strings.Join(tagConditions, " AND "))) + } + } + + return sb +} + +// Find returns list of agents by given filters with pagination +func (s *CustomQueries) Find(ctx context.Context, params FindParams) (*storecmn.FindResponseWithCount[*models.Agent], error) { + sb := findBuilder(params, AgentsColumnNames().Strings()...) + + if params.OrderBy != "" { + sb.OrderBy(params.OrderBy) + } else { + sb.OrderBy("name") + } + + sb.Desc() + + limit, offset, err := storecmn.Pagination(params.Page, params.PageSize) + if err != nil { + return nil, err + } + sb.Limit(int(limit)).Offset(int(offset)) + + items := []*models.Agent{} + sql, args := sb.Build() + if err := sqlscan.Select(ctx, s.db, &items, sql, args...); err != nil { + return nil, err + } + + // Get total count of services + var totalCount uint32 + countSQL, countArgs := findBuilder(params, "count(*)").Build() + if err := sqlscan.Get(ctx, s.db, &totalCount, countSQL, countArgs...); err != nil { + return nil, err + } + + return &storecmn.FindResponseWithCount[*models.Agent]{ + Count: totalCount, + Items: items, + }, nil +} diff --git a/internal/store/repos/repo_agents/update.go b/internal/store/repos/repo_agents/update.go new file mode 100644 index 0000000..7535639 --- /dev/null +++ b/internal/store/repos/repo_agents/update.go @@ -0,0 +1,75 @@ +package repo_agents + +import ( + "context" + "fmt" + "slices" + "time" + + "github.com/huandu/go-sqlbuilder" + "github.com/samber/lo" + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/storecmn" + "github.com/tkcrm/modules/pkg/db/dbutils" + "github.com/tkcrm/modules/pkg/utils" +) + +var availableUpdateColumns = lo.Filter(AgentsColumnNames(), func(item ColumnName, _ int) bool { + return !slices.Contains([]ColumnName{ + ColumnNameAgentsId, + ColumnNameAgentsCreatedAt, + ColumnNameAgentsUpdatedAt, + }, item) +}) + +type UpdateRequest struct { + models.Agent + FieldMask dbutils.FieldMask[ColumnName] +} + +func (s *CustomQueries) Update(ctx context.Context, id string, params UpdateRequest) (*models.Agent, error) { + if id == "" { + return nil, storecmn.ErrEmptyID + } + + for _, path := range params.FieldMask.Items() { + if !slices.Contains(availableUpdateColumns, path) { + return nil, fmt.Errorf("unavailable field %s for this method", path) + } + } + + ub := sqlbuilder.NewUpdateBuilder() + ub.Update(TableNameAgents.String()). + Where(ub.Equal("id", id)). + Set(ub.Assign("updated_at", time.Now())) + + values, err := utils.StructToMap(params.Agent, "json") + if err != nil { + return nil, err + } + + for _, path := range params.FieldMask { + idx := slices.IndexFunc(AgentsColumnNames(), func(i ColumnName) bool { + return path == i + }) + + if idx == -1 { + return nil, fmt.Errorf("unavailable path: %s", path) + } + + value, ok := values[path.String()] + if !ok { + return nil, fmt.Errorf("value not found for path: %s", path) + } + + ub.SetMore(ub.Assign(path.String(), value)) + } + + // execute query + sql, args := ub.Build() + if _, err := s.db.ExecContext(ctx, sql, args...); err != nil { + return nil, err + } + + return s.GetByID(ctx, id) +} diff --git a/internal/store/repos/repo_services/custom.go b/internal/store/repos/repo_services/custom.go index 715bee1..0d4bc1d 100644 --- a/internal/store/repos/repo_services/custom.go +++ b/internal/store/repos/repo_services/custom.go @@ -5,14 +5,14 @@ import ( "database/sql" "github.com/sxwebdev/sentinel/internal/models" - "github.com/sxwebdev/sentinel/pkg/dbutils" + "github.com/sxwebdev/sentinel/internal/store/storecmn" ) type ICustomQuerier interface { Querier GetViewByID(ctx context.Context, id string) (*models.ServiceFullView, error) Update(ctx context.Context, id string, service UpdateServiceRequest) (*models.ServiceFullView, error) - FindView(ctx context.Context, params FindParams) (*dbutils.FindResponseWithCount[*models.ServiceFullView], error) + FindView(ctx context.Context, params FindParams) (*storecmn.FindResponseWithCount[*models.ServiceFullView], error) } type CustomQueries struct { diff --git a/internal/store/repos/repo_services/find.go b/internal/store/repos/repo_services/find.go index c9a70c0..a5de9a6 100644 --- a/internal/store/repos/repo_services/find.go +++ b/internal/store/repos/repo_services/find.go @@ -8,7 +8,7 @@ import ( "github.com/georgysavva/scany/v2/sqlscan" "github.com/huandu/go-sqlbuilder" "github.com/sxwebdev/sentinel/internal/models" - "github.com/sxwebdev/sentinel/pkg/dbutils" + "github.com/sxwebdev/sentinel/internal/store/storecmn" ) func findServicesBuilder(params FindParams, col ...string) *sqlbuilder.SelectBuilder { @@ -64,8 +64,8 @@ type FindParams struct { PageSize *uint32 } -// GetAllServices finds all services using ORM -func (s *CustomQueries) FindView(ctx context.Context, params FindParams) (*dbutils.FindResponseWithCount[*models.ServiceFullView], error) { +// FindView returns list of services with their states and incidents by given filters with pagination +func (s *CustomQueries) FindView(ctx context.Context, params FindParams) (*storecmn.FindResponseWithCount[*models.ServiceFullView], error) { sb := findServicesBuilder( params, "s.id", @@ -116,13 +116,13 @@ func (s *CustomQueries) FindView(ctx context.Context, params FindParams) (*dbuti sb.OrderBy("s.name") } - limit, offset, err := dbutils.Pagination(params.Page, params.PageSize) + limit, offset, err := storecmn.Pagination(params.Page, params.PageSize) if err != nil { return nil, err } sb.Limit(int(limit)).Offset(int(offset)) - itemsRows := []serviceViewRow{} + itemsRows := []itemViewRow{} sql, args := sb.Build() if err := sqlscan.Select(ctx, s.db, &itemsRows, sql, args...); err != nil { return nil, err @@ -140,14 +140,14 @@ func (s *CustomQueries) FindView(ctx context.Context, params FindParams) (*dbuti items := make([]*models.ServiceFullView, 0, len(itemsRows)) for i := range itemsRows { - item, err := rowToService(&itemsRows[i]) + item, err := rowToModel(&itemsRows[i]) if err != nil { return nil, fmt.Errorf("failed to convert row to service: %w", err) } items = append(items, item) } - return &dbutils.FindResponseWithCount[*models.ServiceFullView]{ + return &storecmn.FindResponseWithCount[*models.ServiceFullView]{ Count: totalCount, Items: items, }, nil diff --git a/internal/store/repos/repo_services/get.go b/internal/store/repos/repo_services/get.go index 77f9289..8bc2ed3 100644 --- a/internal/store/repos/repo_services/get.go +++ b/internal/store/repos/repo_services/get.go @@ -44,7 +44,7 @@ func (s *CustomQueries) GetViewByID(ctx context.Context, id string) (*models.Ser sb.Where(sb.Equal("s.id", id)) sb.GroupBy("s.id") - var itemRow serviceViewRow + var itemRow itemViewRow query, args := sb.Build() if err := sqlscan.Get(ctx, s.db, &itemRow, query, args...); err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -53,7 +53,7 @@ func (s *CustomQueries) GetViewByID(ctx context.Context, id string) (*models.Ser return nil, fmt.Errorf("failed to scan service: %w", err) } - item, err := rowToService(&itemRow) + item, err := rowToModel(&itemRow) if err != nil { return nil, fmt.Errorf("failed to convert row to service: %w", err) } diff --git a/internal/store/repos/repo_services/services_gen.sql.go b/internal/store/repos/repo_services/services_gen.sql.go index bbebe4b..d55bdff 100644 --- a/internal/store/repos/repo_services/services_gen.sql.go +++ b/internal/store/repos/repo_services/services_gen.sql.go @@ -9,7 +9,7 @@ import ( "context" "github.com/sxwebdev/sentinel/internal/models" - "github.com/sxwebdev/sentinel/pkg/dbutils" + "github.com/sxwebdev/sentinel/internal/store/storecmn" ) const create = `-- name: Create :one @@ -22,11 +22,11 @@ type CreateParams struct { ID string `db:"id" json:"id"` Name string `db:"name" json:"name"` Protocol models.ServiceProtocolType `db:"protocol" json:"protocol"` - Interval dbutils.Duration `db:"interval" json:"interval"` - Timeout dbutils.Duration `db:"timeout" json:"timeout"` + Interval storecmn.Duration `db:"interval" json:"interval"` + Timeout storecmn.Duration `db:"timeout" json:"timeout"` Retries int64 `db:"retries" json:"retries"` - Tags dbutils.JSONField `db:"tags" json:"tags"` - Config dbutils.JSONField `db:"config" json:"config"` + Tags storecmn.JSONField `db:"tags" json:"tags"` + Config storecmn.JSONField `db:"config" json:"config"` IsEnabled bool `db:"is_enabled" json:"is_enabled"` } diff --git a/internal/store/repos/repo_services/types.go b/internal/store/repos/repo_services/types.go index 2f34900..16a9a0e 100644 --- a/internal/store/repos/repo_services/types.go +++ b/internal/store/repos/repo_services/types.go @@ -9,7 +9,7 @@ import ( "github.com/sxwebdev/sentinel/internal/utils" ) -type serviceViewRow struct { +type itemViewRow struct { ID string Name string Protocol string @@ -33,8 +33,8 @@ type serviceViewRow struct { ResponseTimeNS *int64 } -// rowToService converts a ServiceRow to Service -func rowToService(row *serviceViewRow) (*models.ServiceFullView, error) { +// rowToModel converts a ServiceRow to Service +func rowToModel(row *itemViewRow) (*models.ServiceFullView, error) { interval, err := time.ParseDuration(row.Interval) if err != nil { return nil, fmt.Errorf("failed to parse interval: %w", err) diff --git a/internal/store/repos/repo_services/update.go b/internal/store/repos/repo_services/update.go index fed4da8..5380924 100644 --- a/internal/store/repos/repo_services/update.go +++ b/internal/store/repos/repo_services/update.go @@ -8,23 +8,23 @@ import ( "github.com/huandu/go-sqlbuilder" "github.com/sxwebdev/sentinel/internal/models" - "github.com/sxwebdev/sentinel/pkg/dbutils" + "github.com/sxwebdev/sentinel/internal/store/storecmn" ) type UpdateServiceRequest struct { Name string `json:"name" yaml:"name"` Protocol models.ServiceProtocolType `json:"protocol" yaml:"protocol"` - Interval dbutils.Duration `json:"interval" yaml:"interval" swaggertype:"primitive,integer"` - Timeout dbutils.Duration `json:"timeout" yaml:"timeout" swaggertype:"primitive,integer"` + Interval storecmn.Duration `json:"interval" yaml:"interval" swaggertype:"primitive,integer"` + Timeout storecmn.Duration `json:"timeout" yaml:"timeout" swaggertype:"primitive,integer"` Retries int64 `json:"retries" yaml:"retries"` - Tags dbutils.JSONField `json:"tags" yaml:"tags"` - Config dbutils.JSONField `json:"config" yaml:"config"` + Tags storecmn.JSONField `json:"tags" yaml:"tags"` + Config storecmn.JSONField `json:"config" yaml:"config"` IsEnabled bool `json:"is_enabled" yaml:"is_enabled"` } func (s *CustomQueries) Update(ctx context.Context, id string, service UpdateServiceRequest) (*models.ServiceFullView, error) { ub := sqlbuilder.NewUpdateBuilder() - ub.Update("services") + ub.Update(TableNameServices.String()) tagsJSON, err := json.Marshal(service.Tags) if err != nil { diff --git a/internal/store/repos/repos.go b/internal/store/repos/repos.go index 427ae77..1a27b34 100644 --- a/internal/store/repos/repos.go +++ b/internal/store/repos/repos.go @@ -10,7 +10,7 @@ import ( ) type Repos struct { - agents *repo_agents.Queries + agents *repo_agents.CustomQueries services *repo_services.CustomQueries serviceStates *repo_service_states.Queries incidents *repo_incidents.Queries @@ -18,7 +18,7 @@ type Repos struct { func New(sqlite *sql.DB) *Repos { return &Repos{ - agents: repo_agents.New(sqlite), + agents: repo_agents.NewCustom(sqlite), services: repo_services.NewCustom(sqlite), serviceStates: repo_service_states.New(sqlite), incidents: repo_incidents.New(sqlite), @@ -26,7 +26,7 @@ func New(sqlite *sql.DB) *Repos { } // Agents returns repo for agents -func (s *Repos) Agents(opts ...Option) repo_agents.Querier { +func (s *Repos) Agents(opts ...Option) repo_agents.ICustomQuerier { options := parseOptions(opts...) if options.Tx != nil { diff --git a/pkg/dbutils/duration.go b/internal/store/storecmn/duration.go similarity index 98% rename from pkg/dbutils/duration.go rename to internal/store/storecmn/duration.go index ea8d2f6..eb79bce 100644 --- a/pkg/dbutils/duration.go +++ b/internal/store/storecmn/duration.go @@ -1,4 +1,4 @@ -package dbutils +package storecmn import ( "database/sql/driver" diff --git a/internal/store/storecmn/errors.go b/internal/store/storecmn/errors.go index c7fe739..4033614 100644 --- a/internal/store/storecmn/errors.go +++ b/internal/store/storecmn/errors.go @@ -3,6 +3,7 @@ package storecmn import "errors" var ( + ErrEmptyID = errors.New("empty id") ErrNotFound = errors.New("not found") ErrAlreadyExists = errors.New("already exists") ) diff --git a/pkg/dbutils/json.go b/internal/store/storecmn/json.go similarity index 99% rename from pkg/dbutils/json.go rename to internal/store/storecmn/json.go index 0fc1446..f4804d1 100644 --- a/pkg/dbutils/json.go +++ b/internal/store/storecmn/json.go @@ -1,4 +1,4 @@ -package dbutils +package storecmn import ( "database/sql/driver" diff --git a/pkg/dbutils/pagination.go b/internal/store/storecmn/pagination.go similarity index 98% rename from pkg/dbutils/pagination.go rename to internal/store/storecmn/pagination.go index d44f82c..af6bcc6 100644 --- a/pkg/dbutils/pagination.go +++ b/internal/store/storecmn/pagination.go @@ -1,4 +1,4 @@ -package dbutils +package storecmn import "fmt" diff --git a/pkg/dbutils/response.go b/internal/store/storecmn/response.go similarity index 94% rename from pkg/dbutils/response.go rename to internal/store/storecmn/response.go index d0fc979..1e46524 100644 --- a/pkg/dbutils/response.go +++ b/internal/store/storecmn/response.go @@ -1,4 +1,4 @@ -package dbutils +package storecmn type FindResponseWithCount[T any] struct { Items []T `json:"items"` diff --git a/pkg/dbutils/tx.go b/internal/store/storecmn/tx.go similarity index 95% rename from pkg/dbutils/tx.go rename to internal/store/storecmn/tx.go index 77d2107..a7ffba6 100644 --- a/pkg/dbutils/tx.go +++ b/internal/store/storecmn/tx.go @@ -1,4 +1,4 @@ -package dbutils +package storecmn import ( "context" diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 44bfe92..b3506bd 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -41,7 +41,6 @@ import ( "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/upgrader" "github.com/sxwebdev/sentinel/internal/utils" - "github.com/sxwebdev/sentinel/pkg/dbutils" "github.com/tkcrm/mx/logger" ) @@ -304,7 +303,7 @@ func (s *Server) handleSPA(c *fiber.Ctx) error { // @Param order_by query string false "Order by field" ENUM("name", "created_at") // @Param page query uint32 false "Page number (for pagination)" // @Param page_size query uint32 false "Number of items per page (default 20)" -// @Success 200 {object} dbutils.FindResponseWithCount[ServiceDTO] "List of services with states" +// @Success 200 {object} storecmn.FindResponseWithCount[ServiceDTO] "List of services with states" // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /services [get] func (s *Server) handleFindServices(c *fiber.Ctx) error { @@ -344,7 +343,7 @@ func (s *Server) handleFindServices(c *fiber.Ctx) error { return newErrorResponse(c, fiber.StatusInternalServerError, err) } - result := dbutils.FindResponseWithCount[ServiceDTO]{ + result := storecmn.FindResponseWithCount[ServiceDTO]{ Items: make([]ServiceDTO, 0, len(services.Items)), Count: services.Count, } @@ -405,16 +404,16 @@ func (s *Server) handleAPIServiceDetail(c *fiber.Ctx) error { // @Tags incidents // @Accept json // @Produce json -// @Param id path string true "Service ID" -// @Param incident_id query string false "Filter by incident ID" -// @Param resolved query bool false "Filter by resolved status" -// @Param start_time query time.Time false "Filter by start time (RFC3339 format)" -// @Param end_time query time.Time false "Filter by end time (RFC3339 format)" -// @Param page query uint32 false "Page number (for pagination)" -// @Param page_size query uint32 false "Number of items per page (default 20)" -// @Success 200 {object} dbutils.FindResponseWithCount[storage.Incident] "List of incidents" -// @Failure 400 {object} ErrorResponse "Bad request" -// @Failure 500 {object} ErrorResponse "Internal server error" +// @Param id path string true "Service ID" +// @Param incident_id query string false "Filter by incident ID" +// @Param resolved query bool false "Filter by resolved status" +// @Param start_time query time.Time false "Filter by start time (RFC3339 format)" +// @Param end_time query time.Time false "Filter by end time (RFC3339 format)" +// @Param page query uint32 false "Page number (for pagination)" +// @Param page_size query uint32 false "Number of items per page (default 20)" +// @Success 200 {object} storecmn.FindResponseWithCount[storage.Incident] "List of incidents" +// @Failure 400 {object} ErrorResponse "Bad request" +// @Failure 500 {object} ErrorResponse "Internal server error" // @Router /services/{id}/incidents [get] func (s *Server) handleAPIServiceIncidents(c *fiber.Ctx) error { serviceID := c.Params("id") @@ -584,14 +583,14 @@ func (s *Server) handleAPIServiceResolve(c *fiber.Ctx) error { // @Tags incidents // @Accept json // @Produce json -// @Param search query string false "Filter by service ID or incident ID" -// @Param resolved query bool false "Filter by resolved status" -// @Param start_time query time.Time false "Start time for filtering (RFC3339 format)" -// @Param end_time query time.Time false "End time for filtering (RFC3339 format)" -// @Param page query uint32 false "Page number (default 1)" -// @Param page_size query uint32 false "Number of items per page (default 100)" -// @Success 200 {object} dbutils.FindResponseWithCount[storage.Incident] "List of incidents" -// @Failure 500 {object} ErrorResponse "Internal server error" +// @Param search query string false "Filter by service ID or incident ID" +// @Param resolved query bool false "Filter by resolved status" +// @Param start_time query time.Time false "Start time for filtering (RFC3339 format)" +// @Param end_time query time.Time false "End time for filtering (RFC3339 format)" +// @Param page query uint32 false "Page number (default 1)" +// @Param page_size query uint32 false "Number of items per page (default 100)" +// @Success 200 {object} storecmn.FindResponseWithCount[storage.Incident] "List of incidents" +// @Failure 500 {object} ErrorResponse "Internal server error" // @Router /incidents [get] func (s *Server) handleFindIncidents(c *fiber.Ctx) error { params := struct { diff --git a/sql/migrations/2_agents.up.sql b/sql/migrations/2_agents.up.sql index 0ab757d..1b96255 100644 --- a/sql/migrations/2_agents.up.sql +++ b/sql/migrations/2_agents.up.sql @@ -3,13 +3,16 @@ CREATE TABLE IF NOT EXISTS agents ( id TEXT PRIMARY KEY, name TEXT NOT NULL, description TEXT, - host TEXT, - port INT, + host TEXT NOT NULL, + port INT NOT NULL, token_ct BLOB, token_nonce BLOB, - token_hint TEXT, + token_hint TEXT NOT NULL, fingerprint TEXT, - is_active BOOLEAN NOT NULL DEFAULT TRUE, + "status" TEXT NOT NULL DEFAULT 'unknown', + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + tags jsonb NOT NULL DEFAULT '[]', + config jsonb NOT NULL DEFAULT '{}', system_info jsonb NOT NULL DEFAULT '{}', last_seen_at DATETIME, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, @@ -20,4 +23,4 @@ CREATE TABLE IF NOT EXISTS agents ( -- Create indexes for performance CREATE INDEX IF NOT EXISTS idx_agents_name ON agents(name); -CREATE INDEX IF NOT EXISTS idx_agents_active ON agents(is_active); +CREATE INDEX IF NOT EXISTS idx_agents_enabled ON agents(is_enabled); diff --git a/sql/queries/agents/agents_gen.sql b/sql/queries/agents/agents_gen.sql index a361528..e526eba 100755 --- a/sql/queries/agents/agents_gen.sql +++ b/sql/queries/agents/agents_gen.sql @@ -1,6 +1,6 @@ -- name: Create :one -INSERT INTO agents (id, name, description, host, port, token_ct, token_nonce, token_hint) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO agents (id, name, description, host, port, token_ct, token_nonce, token_hint, status, is_enabled, tags, config) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; -- name: Delete :exec diff --git a/sqlc.yaml b/sqlc.yaml index 9350cb0..f49f8aa 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -6,18 +6,18 @@ overrides: - db_type: jsonb go_type: type: JSONField - import: github.com/sxwebdev/sentinel/pkg/dbutils + import: github.com/sxwebdev/sentinel/internal/store/storecmn - column: services.protocol go_type: type: ServiceProtocolType - column: services.interval go_type: type: Duration - import: github.com/sxwebdev/sentinel/pkg/dbutils + import: github.com/sxwebdev/sentinel/internal/store/storecmn - column: services.timeout go_type: type: Duration - import: github.com/sxwebdev/sentinel/pkg/dbutils + import: github.com/sxwebdev/sentinel/internal/store/storecmn # service_states - column: service_states.status From 2bfac1f62bcd2e1ad9cfce0eb200486035b82a53 Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Fri, 19 Sep 2025 10:47:37 +0300 Subject: [PATCH 08/71] feat: add validation to CreateParams and UpdateParams for agents and services --- internal/services/agents/methods.go | 133 +++++++++++++++++++++++++-- internal/services/service/methods.go | 15 ++- 2 files changed, 134 insertions(+), 14 deletions(-) diff --git a/internal/services/agents/methods.go b/internal/services/agents/methods.go index bce94c6..c21a27f 100644 --- a/internal/services/agents/methods.go +++ b/internal/services/agents/methods.go @@ -4,18 +4,21 @@ import ( "context" "fmt" "slices" + "time" + "github.com/go-playground/validator/v10" "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/store/repos/repo_agents" "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/utils" + "github.com/tkcrm/modules/pkg/db/dbutils" ) type CreateParams struct { - Name string + Name string `validate:"required"` Description *string - Host string - Port int64 + Host string `validate:"required"` + Port int64 `validate:"required"` TokenCt []byte TokenNonce []byte TokenHint string @@ -23,8 +26,12 @@ type CreateParams struct { Config map[string]any } -// Create +// Create a new agent func (s *Service) Create(ctx context.Context, params CreateParams) (*models.Agent, error) { + if err := validator.New().Struct(params); err != nil { + return nil, fmt.Errorf("validation error: %w", err) + } + if len(params.Tags) > 0 { slices.Sort(params.Tags) } @@ -57,17 +64,125 @@ func (s *Service) Create(ctx context.Context, params CreateParams) (*models.Agen return s.store.Agents().Create(ctx, createParams) } -// Delete +// Delete an existing agent func (s *Service) Delete(ctx context.Context, id string) error { return s.store.Agents().Delete(ctx, id) } -// GetByID +// GetByID retrieves an agent by its ID func (s *Service) GetByID(ctx context.Context, id string) (*models.Agent, error) { return s.store.Agents().GetByID(ctx, id) } -// GetAll -func (s *Service) GetAll(ctx context.Context) ([]*models.Agent, error) { - return s.store.Agents().GetAll(ctx) +type FindParams = repo_agents.FindParams + +// Find all agents with given filters and pagination +func (s *Service) Find(ctx context.Context, params FindParams) (*storecmn.FindResponseWithCount[*models.Agent], error) { + return s.store.Agents().Find(ctx, params) +} + +type UpdateParams struct { + Name string + Description *string + Host string + Port int64 + TokenCt []byte + TokenNonce []byte + TokenHint string + Fingerprint *string + Status string + IsEnabled bool + Tags []string + Config map[string]any + SystemInfo models.SystemInfo + LastSeenAt *time.Time + FieldMask dbutils.FieldMask[repo_agents.ColumnName] +} + +// Validate +func (p UpdateParams) Validate() error { + if p.FieldMask.Contains(repo_agents.ColumnNameAgentsName) && p.Name == "" { + return fmt.Errorf("name is required") + } + + if p.FieldMask.Contains(repo_agents.ColumnNameAgentsHost) && p.Host == "" { + return fmt.Errorf("host is required") + } + + if p.FieldMask.Contains(repo_agents.ColumnNameAgentsPort) && p.Port == 0 { + return fmt.Errorf("port is required") + } + + if p.FieldMask.Contains(repo_agents.ColumnNameAgentsTokenCt) && p.TokenCt == nil { + return fmt.Errorf("token_ct is required") + } + + if p.FieldMask.Contains(repo_agents.ColumnNameAgentsTokenNonce) && p.TokenNonce == nil { + return fmt.Errorf("token_nonce is required") + } + + if p.FieldMask.Contains(repo_agents.ColumnNameAgentsTokenHint) && p.TokenHint == "" { + return fmt.Errorf("token_hint is required") + } + + if p.FieldMask.Contains(repo_agents.ColumnNameAgentsFingerprint) && p.Fingerprint == nil { + return fmt.Errorf("fingerprint is required") + } + + return nil +} + +// Update an existing agent +func (s *Service) Update(ctx context.Context, id string, params UpdateParams) (*models.Agent, error) { + if id == "" { + return nil, storecmn.ErrEmptyID + } + + if err := params.Validate(); err != nil { + return nil, fmt.Errorf("validation error: %w", err) + } + + if len(params.Tags) > 0 { + slices.Sort(params.Tags) + } + + // Convert tags to JSONField + tags := storecmn.JSONField("[]") + if err := tags.UnmarshalAny(params.Tags); err != nil { + return nil, fmt.Errorf("failed to convert tags to json raw message: %w", err) + } + + // Convert config to JSONField + config := storecmn.JSONField("{}") + if err := config.UnmarshalAny(params.Config); err != nil { + return nil, fmt.Errorf("failed to convert config to json raw message: %w", err) + } + + // Convert system info to JSONField + systemInfo := storecmn.JSONField("{}") + if err := systemInfo.UnmarshalAny(params.SystemInfo); err != nil { + return nil, fmt.Errorf("failed to convert system info to json raw message: %w", err) + } + + updateParams := repo_agents.UpdateRequest{ + Agent: models.Agent{ + Name: params.Name, + Description: params.Description, + Host: params.Host, + Port: params.Port, + TokenCt: params.TokenCt, + TokenNonce: params.TokenNonce, + TokenHint: params.TokenHint, + Fingerprint: params.Fingerprint, + Status: params.Status, + IsEnabled: params.IsEnabled, + Tags: tags, + Config: config, + SystemInfo: systemInfo, + LastSeenAt: params.LastSeenAt, + }, + FieldMask: params.FieldMask, + } + + return s.store.Agents().Update(ctx, id, updateParams) } diff --git a/internal/services/service/methods.go b/internal/services/service/methods.go index 7f37053..ebe7505 100644 --- a/internal/services/service/methods.go +++ b/internal/services/service/methods.go @@ -8,6 +8,7 @@ import ( "slices" "time" + "github.com/go-playground/validator/v10" "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/store/repos" @@ -18,11 +19,11 @@ import ( ) type CreateUpdateParams struct { - Name string - Protocol models.ServiceProtocolType - Interval time.Duration - Timeout time.Duration - Retries int64 + Name string `validate:"required"` + Protocol models.ServiceProtocolType `validate:"required"` + Interval time.Duration `validate:"required"` + Timeout time.Duration `validate:"required"` + Retries int64 `validate:"required,gte=0"` Tags []string Config map[string]any IsEnabled bool @@ -30,6 +31,10 @@ type CreateUpdateParams struct { // Create new service func (s *Service) Create(ctx context.Context, params CreateUpdateParams) (*models.ServiceFullView, error) { + if err := validator.New().Struct(params); err != nil { + return nil, fmt.Errorf("validation error: %w", err) + } + if len(params.Tags) > 0 { slices.Sort(params.Tags) } From ddaf836aa20b9ee747e285eafb84566ea490a7e5 Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Fri, 19 Sep 2025 13:29:38 +0300 Subject: [PATCH 09/71] Refactor agent and incident repositories to remove unused methods and streamline queries - Removed `GetAll` method from `repo_agents` and `repo_incidents` to simplify data retrieval. - Updated `Create` method in `repo_agents` to exclude unnecessary fields. - Added `StatsByServiceID` method in `repo_incidents` for incident statistics retrieval. - Introduced `GetByServiceID` method in `repo_service_states` for fetching service state by ID. - Enhanced `Update` method in `repo_service_states` to support field masking for updates. - Updated service statistics calculation logic to improve accuracy. - Adjusted data types in DTOs for consistency and accuracy. - Generated new TypeScript models for frontend integration. --- cmd/sentinel/hub.go | 2 +- docs/docsv1/docs.go | 54 +- docs/docsv1/swagger.json | 54 +- docs/docsv1/swagger.yaml | 36 +- frontend/package.json | 2 +- .../service/components/incidentsList.tsx | 4 +- .../pages/service/components/serviceStats.tsx | 7 +- .../service/store/useServiceDeteilStore.ts | 16 +- frontend/src/routes/incidents.tsx | 4 +- .../src/shared/api/incidents/incidents.ts | 6 +- frontend/src/shared/api/services/services.ts | 4 +- .../src/shared/api/statistics/statistics.ts | 7 +- frontend/src/shared/types/model/index.ts | 12 +- ...ableUpdate.ts => modelsAvailableUpdate.ts} | 2 +- ...olType.ts => modelsServiceProtocolType.ts} | 6 +- ...erviceStatus.ts => modelsServiceStatus.ts} | 6 +- ...storageServiceStats.ts => serviceStats.ts} | 4 +- ...mnFindResponseWithCountStorageIncident.ts} | 2 +- ...ecmnFindResponseWithCountWebServiceDTO.ts} | 2 +- .../model/webCreateUpdateServiceRequest.ts | 4 +- .../types/model/webServerInfoResponse.ts | 4 +- .../src/shared/types/model/webServiceDTO.ts | 8 +- internal/monitor/monitor.go | 176 ++- internal/notifier/shoutrrr.go | 9 +- internal/services/baseservices/base.go | 21 +- internal/services/service/stats.go | 63 + internal/services/servicestate/methods.go | 25 + internal/services/servicestate/service.go | 15 + internal/storage/services.go | 1184 ++++++++--------- .../store/repos/repo_agents/agents_gen.sql.go | 53 +- internal/store/repos/repo_agents/querier.go | 1 - internal/store/repos/repo_agents/update.go | 2 +- .../repos/repo_incidents/incidents.sql.go | 33 + .../repos/repo_incidents/incidents_gen.sql.go | 37 - .../store/repos/repo_incidents/querier.go | 3 +- internal/store/repos/repo_incidents/types.go | 41 + .../store/repos/repo_service_states/custom.go | 34 + .../repos/repo_service_states/querier.go | 2 + .../repo_service_states/service_states.sql.go | 26 + .../service_states_gen.sql.go | 40 + .../store/repos/repo_service_states/update.go | 75 ++ internal/store/repos/repos.go | 6 +- internal/web/dto.go | 2 +- internal/web/handlers.go | 12 +- internal/web/helpers.go | 16 +- pgxgen.yaml | 35 +- sql/queries/agents/agents_gen.sql | 7 +- sql/queries/incidents/incidents.sql | 10 + sql/queries/incidents/incidents_gen.sql | 3 - sql/queries/service_states/service_states.sql | 3 + .../service_states/service_states_gen.sql | 3 + 51 files changed, 1256 insertions(+), 927 deletions(-) rename frontend/src/shared/types/model/{webAvailableUpdate.ts => modelsAvailableUpdate.ts} (87%) rename frontend/src/shared/types/model/{storageServiceProtocolType.ts => modelsServiceProtocolType.ts} (68%) rename frontend/src/shared/types/model/{storageServiceStatus.ts => modelsServiceStatus.ts} (71%) rename frontend/src/shared/types/model/{storageServiceStats.ts => serviceStats.ts} (77%) rename frontend/src/shared/types/model/{dbutilsFindResponseWithCountStorageIncident.ts => storecmnFindResponseWithCountStorageIncident.ts} (81%) rename frontend/src/shared/types/model/{dbutilsFindResponseWithCountWebServiceDTO.ts => storecmnFindResponseWithCountWebServiceDTO.ts} (81%) create mode 100644 internal/services/service/stats.go create mode 100644 internal/services/servicestate/methods.go create mode 100644 internal/services/servicestate/service.go create mode 100644 internal/store/repos/repo_incidents/types.go create mode 100644 internal/store/repos/repo_service_states/custom.go create mode 100644 internal/store/repos/repo_service_states/update.go diff --git a/cmd/sentinel/hub.go b/cmd/sentinel/hub.go index 9186791..733ed83 100644 --- a/cmd/sentinel/hub.go +++ b/cmd/sentinel/hub.go @@ -116,7 +116,7 @@ func hubStartCMD() *cli.Command { baseServices := baseservices.New(st, rc) // Create monitor service - monitorService := monitor.NewMonitorService(st, storage, conf, notif, rc) + monitorService := monitor.NewMonitorService(st, storage, conf, notif, rc, baseServices) // Initialize scheduler sched := scheduler.New(l, monitorService, rc, baseServices) diff --git a/docs/docsv1/docs.go b/docs/docsv1/docs.go index 37a86ad..4acf5b7 100644 --- a/docs/docsv1/docs.go +++ b/docs/docsv1/docs.go @@ -764,7 +764,7 @@ const docTemplate = `{ "200": { "description": "Service statistics", "schema": { - "$ref": "#/definitions/storage.ServiceStats" + "$ref": "#/definitions/service.Stats" } }, "400": { @@ -1029,6 +1029,35 @@ const docTemplate = `{ } } }, + "service.Stats": { + "type": "object", + "properties": { + "avg_response_time": { + "type": "integer" + }, + "period": { + "type": "integer" + }, + "resolved_incidents": { + "type": "integer" + }, + "service_id": { + "type": "string" + }, + "total_downtime": { + "type": "integer" + }, + "total_incidents": { + "type": "integer" + }, + "unresolved_incidents": { + "type": "integer" + }, + "uptime_percentage": { + "type": "number" + } + } + }, "storage.Incident": { "type": "object", "properties": { @@ -1055,29 +1084,6 @@ const docTemplate = `{ } } }, - "storage.ServiceStats": { - "type": "object", - "properties": { - "avg_response_time": { - "type": "integer" - }, - "period": { - "type": "integer" - }, - "service_id": { - "type": "string" - }, - "total_downtime": { - "type": "integer" - }, - "total_incidents": { - "type": "integer" - }, - "uptime_percentage": { - "type": "number" - } - } - }, "storecmn.FindResponseWithCount-storage_Incident": { "type": "object", "properties": { diff --git a/docs/docsv1/swagger.json b/docs/docsv1/swagger.json index b9ecad1..528047d 100644 --- a/docs/docsv1/swagger.json +++ b/docs/docsv1/swagger.json @@ -757,7 +757,7 @@ "200": { "description": "Service statistics", "schema": { - "$ref": "#/definitions/storage.ServiceStats" + "$ref": "#/definitions/service.Stats" } }, "400": { @@ -1022,6 +1022,35 @@ } } }, + "service.Stats": { + "type": "object", + "properties": { + "avg_response_time": { + "type": "integer" + }, + "period": { + "type": "integer" + }, + "resolved_incidents": { + "type": "integer" + }, + "service_id": { + "type": "string" + }, + "total_downtime": { + "type": "integer" + }, + "total_incidents": { + "type": "integer" + }, + "unresolved_incidents": { + "type": "integer" + }, + "uptime_percentage": { + "type": "number" + } + } + }, "storage.Incident": { "type": "object", "properties": { @@ -1048,29 +1077,6 @@ } } }, - "storage.ServiceStats": { - "type": "object", - "properties": { - "avg_response_time": { - "type": "integer" - }, - "period": { - "type": "integer" - }, - "service_id": { - "type": "string" - }, - "total_downtime": { - "type": "integer" - }, - "total_incidents": { - "type": "integer" - }, - "uptime_percentage": { - "type": "number" - } - } - }, "storecmn.FindResponseWithCount-storage_Incident": { "type": "object", "properties": { diff --git a/docs/docsv1/swagger.yaml b/docs/docsv1/swagger.yaml index 5bc9bfe..f77b6e6 100644 --- a/docs/docsv1/swagger.yaml +++ b/docs/docsv1/swagger.yaml @@ -128,6 +128,25 @@ definitions: required: - endpoint type: object + service.Stats: + properties: + avg_response_time: + type: integer + period: + type: integer + resolved_incidents: + type: integer + service_id: + type: string + total_downtime: + type: integer + total_incidents: + type: integer + unresolved_incidents: + type: integer + uptime_percentage: + type: number + type: object storage.Incident: properties: duration: @@ -145,21 +164,6 @@ definitions: start_time: type: string type: object - storage.ServiceStats: - properties: - avg_response_time: - type: integer - period: - type: integer - service_id: - type: string - total_downtime: - type: integer - total_incidents: - type: integer - uptime_percentage: - type: number - type: object storecmn.FindResponseWithCount-storage_Incident: properties: count: @@ -874,7 +878,7 @@ paths: "200": description: Service statistics schema: - $ref: '#/definitions/storage.ServiceStats' + $ref: '#/definitions/service.Stats' "400": description: Bad request schema: diff --git a/frontend/package.json b/frontend/package.json index 92cffb4..326a6da 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,7 @@ "preview": "vite preview", "format": "prettier --config .prettierrc --write .", "format-check": "prettier --config .prettierrc --check .", - "gen-api": "rm src/shared/types/model/* && npx orval" + "genapi": "rm src/shared/types/model/* && npx orval" }, "dependencies": { "@tanstack/react-router": "^1.131.36", diff --git a/frontend/src/pages/service/components/incidentsList.tsx b/frontend/src/pages/service/components/incidentsList.tsx index 32a25ab..f0e1027 100644 --- a/frontend/src/pages/service/components/incidentsList.tsx +++ b/frontend/src/pages/service/components/incidentsList.tsx @@ -17,13 +17,13 @@ import { ExpandableText } from "@/shared/components/expandableText"; import { formatDuration } from "@/shared/utils"; import PaginationTable from "@/shared/components/paginationTable"; import type { - DbutilsFindResponseWithCountStorageIncident, + StorecmnFindResponseWithCountStorageIncident, GetServicesIdIncidentsParams, StorageIncident, } from "@/shared/types/model"; interface IncidentsListProps { - incidentsData: DbutilsFindResponseWithCountStorageIncident; + incidentsData: StorecmnFindResponseWithCountStorageIncident; incidentsCount: number | null; filters: GetServicesIdIncidentsParams; setFilters: (filters: Partial) => void; diff --git a/frontend/src/pages/service/components/serviceStats.tsx b/frontend/src/pages/service/components/serviceStats.tsx index 0a9f295..1ab3b07 100644 --- a/frontend/src/pages/service/components/serviceStats.tsx +++ b/frontend/src/pages/service/components/serviceStats.tsx @@ -1,9 +1,12 @@ import { InfoCardStats } from "@/entities/infoStatsCard/infoCardStats"; -import type { WebServiceDTO, StorageServiceStats } from "@/shared/types/model"; +import type { + WebServiceDTO, + ServiceStats as ModelServiceStats, +} from "@/shared/types/model"; interface ServiceStatsProps { serviceDetailData: WebServiceDTO; - serviceStatsData: StorageServiceStats; + serviceStatsData: ModelServiceStats; } export const ServiceStats = ({ diff --git a/frontend/src/pages/service/store/useServiceDeteilStore.ts b/frontend/src/pages/service/store/useServiceDeteilStore.ts index 4a33fc1..f1575db 100644 --- a/frontend/src/pages/service/store/useServiceDeteilStore.ts +++ b/frontend/src/pages/service/store/useServiceDeteilStore.ts @@ -1,9 +1,9 @@ import type { - DbutilsFindResponseWithCountStorageIncident, + StorecmnFindResponseWithCountStorageIncident, GetServicesIdIncidentsParams, StorageIncident, WebServiceDTO, - StorageServiceStats, + ServiceStats, } from "@/shared/types/model"; import { create } from "zustand"; @@ -11,20 +11,18 @@ interface ServiceDetailStore { deleteIncident: StorageIncident | null; serviceDetailData: WebServiceDTO | null; resolveIncident: boolean; - incidentsData: DbutilsFindResponseWithCountStorageIncident | null; + incidentsData: StorecmnFindResponseWithCountStorageIncident | null; filters: GetServicesIdIncidentsParams; - serviceStatsData: StorageServiceStats | null; + serviceStatsData: ServiceStats | null; setFilters: (value: Partial) => void; setDeleteIncident: (deleteIncident: StorageIncident | null) => void; setResolveIncident: (resolveIncident: boolean) => void; setServiceDetailData: (serviceDetailData: WebServiceDTO | null) => void; setIncidentsData: ( - incidentsData: DbutilsFindResponseWithCountStorageIncident | null, - ) => void; - setServiceStatsData: (serviceStatsData: StorageServiceStats | null) => void; - setUpdateServiceStatsData: ( - serviceStatsData: StorageServiceStats | null, + incidentsData: StorecmnFindResponseWithCountStorageIncident | null, ) => void; + setServiceStatsData: (serviceStatsData: ServiceStats | null) => void; + setUpdateServiceStatsData: (serviceStatsData: ServiceStats | null) => void; } const initialState = { diff --git a/frontend/src/routes/incidents.tsx b/frontend/src/routes/incidents.tsx index b606e8e..2978c4c 100644 --- a/frontend/src/routes/incidents.tsx +++ b/frontend/src/routes/incidents.tsx @@ -16,7 +16,7 @@ import { } from "@/shared/components/ui"; import { cn } from "@/shared/lib/utils"; import type { - DbutilsFindResponseWithCountStorageIncident, + StorecmnFindResponseWithCountStorageIncident, GetIncidentsParams, StorageIncident, WebErrorResponse, @@ -49,7 +49,7 @@ function RouteComponent() { } interface IncidentsListProps { - incidentsData: DbutilsFindResponseWithCountStorageIncident; + incidentsData: StorecmnFindResponseWithCountStorageIncident; } export const IncidentsList = ({ incidentsData }: IncidentsListProps) => { diff --git a/frontend/src/shared/api/incidents/incidents.ts b/frontend/src/shared/api/incidents/incidents.ts index 67ad91a..b012b1a 100644 --- a/frontend/src/shared/api/incidents/incidents.ts +++ b/frontend/src/shared/api/incidents/incidents.ts @@ -6,10 +6,10 @@ * OpenAPI spec version: 1.0 */ import type { - DbutilsFindResponseWithCountStorageIncident, GetIncidentsParams, GetIncidentsStatsParams, GetServicesIdIncidentsParams, + StorecmnFindResponseWithCountStorageIncident, WebGetIncidentsStatsItem, WebSuccessResponse, } from "../../types/model"; @@ -22,7 +22,7 @@ export const getIncidents = () => { * @summary Get recent incidents */ const getIncidents = (params?: GetIncidentsParams) => { - return customFetcher({ + return customFetcher({ url: `/incidents`, method: "GET", params, @@ -47,7 +47,7 @@ export const getIncidents = () => { id: string, params?: GetServicesIdIncidentsParams, ) => { - return customFetcher({ + return customFetcher({ url: `/services/${id}/incidents`, method: "GET", params, diff --git a/frontend/src/shared/api/services/services.ts b/frontend/src/shared/api/services/services.ts index c0f7b48..39d8b18 100644 --- a/frontend/src/shared/api/services/services.ts +++ b/frontend/src/shared/api/services/services.ts @@ -6,8 +6,8 @@ * OpenAPI spec version: 1.0 */ import type { - DbutilsFindResponseWithCountWebServiceDTO, GetServicesParams, + StorecmnFindResponseWithCountWebServiceDTO, WebCreateUpdateServiceRequest, WebServiceDTO, WebSuccessResponse, @@ -21,7 +21,7 @@ export const getServices = () => { * @summary Get all services */ const getServices = (params?: GetServicesParams) => { - return customFetcher({ + return customFetcher({ url: `/services`, method: "GET", params, diff --git a/frontend/src/shared/api/statistics/statistics.ts b/frontend/src/shared/api/statistics/statistics.ts index 064ce2d..72a21ca 100644 --- a/frontend/src/shared/api/statistics/statistics.ts +++ b/frontend/src/shared/api/statistics/statistics.ts @@ -5,10 +5,7 @@ * API for service monitoring and incident management * OpenAPI spec version: 1.0 */ -import type { - GetServicesIdStatsParams, - StorageServiceStats, -} from "../../types/model"; +import type { GetServicesIdStatsParams, ServiceStats } from "../../types/model"; import { customFetcher } from ".././baseApi"; @@ -21,7 +18,7 @@ export const getStatistics = () => { id: string, params?: GetServicesIdStatsParams, ) => { - return customFetcher({ + return customFetcher({ url: `/services/${id}/stats`, method: "GET", params, diff --git a/frontend/src/shared/types/model/index.ts b/frontend/src/shared/types/model/index.ts index a0ce08c..e099e75 100644 --- a/frontend/src/shared/types/model/index.ts +++ b/frontend/src/shared/types/model/index.ts @@ -6,14 +6,15 @@ * OpenAPI spec version: 1.0 */ -export * from "./dbutilsFindResponseWithCountStorageIncident"; -export * from "./dbutilsFindResponseWithCountWebServiceDTO"; export * from "./getIncidentsParams"; export * from "./getIncidentsStatsParams"; export * from "./getServicesIdIncidentsParams"; export * from "./getServicesIdStatsParams"; export * from "./getServicesParams"; export * from "./getTagsCount200"; +export * from "./modelsAvailableUpdate"; +export * from "./modelsServiceProtocolType"; +export * from "./modelsServiceStatus"; export * from "./monitorsConfig"; export * from "./monitorsEndpointConfig"; export * from "./monitorsEndpointConfigHeaders"; @@ -22,11 +23,10 @@ export * from "./monitorsGRPCConfig"; export * from "./monitorsGRPCConfigCheckType"; export * from "./monitorsHTTPConfig"; export * from "./monitorsTCPConfig"; +export * from "./serviceStats"; export * from "./storageIncident"; -export * from "./storageServiceProtocolType"; -export * from "./storageServiceStats"; -export * from "./storageServiceStatus"; -export * from "./webAvailableUpdate"; +export * from "./storecmnFindResponseWithCountStorageIncident"; +export * from "./storecmnFindResponseWithCountWebServiceDTO"; export * from "./webCreateUpdateServiceRequest"; export * from "./webDashboardStats"; export * from "./webDashboardStatsProtocols"; diff --git a/frontend/src/shared/types/model/webAvailableUpdate.ts b/frontend/src/shared/types/model/modelsAvailableUpdate.ts similarity index 87% rename from frontend/src/shared/types/model/webAvailableUpdate.ts rename to frontend/src/shared/types/model/modelsAvailableUpdate.ts index b02e0e0..4f7eaaf 100644 --- a/frontend/src/shared/types/model/webAvailableUpdate.ts +++ b/frontend/src/shared/types/model/modelsAvailableUpdate.ts @@ -6,7 +6,7 @@ * OpenAPI spec version: 1.0 */ -export interface WebAvailableUpdate { +export interface ModelsAvailableUpdate { description?: string; is_available_manual?: boolean; tag_name?: string; diff --git a/frontend/src/shared/types/model/storageServiceProtocolType.ts b/frontend/src/shared/types/model/modelsServiceProtocolType.ts similarity index 68% rename from frontend/src/shared/types/model/storageServiceProtocolType.ts rename to frontend/src/shared/types/model/modelsServiceProtocolType.ts index 2b1f8d9..22bbaaf 100644 --- a/frontend/src/shared/types/model/storageServiceProtocolType.ts +++ b/frontend/src/shared/types/model/modelsServiceProtocolType.ts @@ -6,11 +6,11 @@ * OpenAPI spec version: 1.0 */ -export type StorageServiceProtocolType = - (typeof StorageServiceProtocolType)[keyof typeof StorageServiceProtocolType]; +export type ModelsServiceProtocolType = + (typeof ModelsServiceProtocolType)[keyof typeof ModelsServiceProtocolType]; // eslint-disable-next-line @typescript-eslint/no-redeclare -export const StorageServiceProtocolType = { +export const ModelsServiceProtocolType = { ServiceProtocolTypeHTTP: "http", ServiceProtocolTypeTCP: "tcp", ServiceProtocolTypeGRPC: "grpc", diff --git a/frontend/src/shared/types/model/storageServiceStatus.ts b/frontend/src/shared/types/model/modelsServiceStatus.ts similarity index 71% rename from frontend/src/shared/types/model/storageServiceStatus.ts rename to frontend/src/shared/types/model/modelsServiceStatus.ts index bdb2a44..e82d0fd 100644 --- a/frontend/src/shared/types/model/storageServiceStatus.ts +++ b/frontend/src/shared/types/model/modelsServiceStatus.ts @@ -6,11 +6,11 @@ * OpenAPI spec version: 1.0 */ -export type StorageServiceStatus = - (typeof StorageServiceStatus)[keyof typeof StorageServiceStatus]; +export type ModelsServiceStatus = + (typeof ModelsServiceStatus)[keyof typeof ModelsServiceStatus]; // eslint-disable-next-line @typescript-eslint/no-redeclare -export const StorageServiceStatus = { +export const ModelsServiceStatus = { StatusUnknown: "unknown", StatusUp: "up", StatusDown: "down", diff --git a/frontend/src/shared/types/model/storageServiceStats.ts b/frontend/src/shared/types/model/serviceStats.ts similarity index 77% rename from frontend/src/shared/types/model/storageServiceStats.ts rename to frontend/src/shared/types/model/serviceStats.ts index 55207a4..395bdc4 100644 --- a/frontend/src/shared/types/model/storageServiceStats.ts +++ b/frontend/src/shared/types/model/serviceStats.ts @@ -6,11 +6,13 @@ * OpenAPI spec version: 1.0 */ -export interface StorageServiceStats { +export interface ServiceStats { avg_response_time?: number; period?: number; + resolved_incidents?: number; service_id?: string; total_downtime?: number; total_incidents?: number; + unresolved_incidents?: number; uptime_percentage?: number; } diff --git a/frontend/src/shared/types/model/dbutilsFindResponseWithCountStorageIncident.ts b/frontend/src/shared/types/model/storecmnFindResponseWithCountStorageIncident.ts similarity index 81% rename from frontend/src/shared/types/model/dbutilsFindResponseWithCountStorageIncident.ts rename to frontend/src/shared/types/model/storecmnFindResponseWithCountStorageIncident.ts index 939cbf6..05bcc6c 100644 --- a/frontend/src/shared/types/model/dbutilsFindResponseWithCountStorageIncident.ts +++ b/frontend/src/shared/types/model/storecmnFindResponseWithCountStorageIncident.ts @@ -7,7 +7,7 @@ */ import type { StorageIncident } from "./storageIncident"; -export interface DbutilsFindResponseWithCountStorageIncident { +export interface StorecmnFindResponseWithCountStorageIncident { count?: number; items?: StorageIncident[]; } diff --git a/frontend/src/shared/types/model/dbutilsFindResponseWithCountWebServiceDTO.ts b/frontend/src/shared/types/model/storecmnFindResponseWithCountWebServiceDTO.ts similarity index 81% rename from frontend/src/shared/types/model/dbutilsFindResponseWithCountWebServiceDTO.ts rename to frontend/src/shared/types/model/storecmnFindResponseWithCountWebServiceDTO.ts index 96c1b4b..4daf048 100644 --- a/frontend/src/shared/types/model/dbutilsFindResponseWithCountWebServiceDTO.ts +++ b/frontend/src/shared/types/model/storecmnFindResponseWithCountWebServiceDTO.ts @@ -7,7 +7,7 @@ */ import type { WebServiceDTO } from "./webServiceDTO"; -export interface DbutilsFindResponseWithCountWebServiceDTO { +export interface StorecmnFindResponseWithCountWebServiceDTO { count?: number; items?: WebServiceDTO[]; } diff --git a/frontend/src/shared/types/model/webCreateUpdateServiceRequest.ts b/frontend/src/shared/types/model/webCreateUpdateServiceRequest.ts index 6c1a5f0..838e257 100644 --- a/frontend/src/shared/types/model/webCreateUpdateServiceRequest.ts +++ b/frontend/src/shared/types/model/webCreateUpdateServiceRequest.ts @@ -6,14 +6,14 @@ * OpenAPI spec version: 1.0 */ import type { MonitorsConfig } from "./monitorsConfig"; -import type { StorageServiceProtocolType } from "./storageServiceProtocolType"; +import type { ModelsServiceProtocolType } from "./modelsServiceProtocolType"; export interface WebCreateUpdateServiceRequest { config?: MonitorsConfig; interval?: number; is_enabled?: boolean; name?: string; - protocol?: StorageServiceProtocolType; + protocol?: ModelsServiceProtocolType; retries?: number; tags?: string[]; timeout?: number; diff --git a/frontend/src/shared/types/model/webServerInfoResponse.ts b/frontend/src/shared/types/model/webServerInfoResponse.ts index 270799d..7d77c18 100644 --- a/frontend/src/shared/types/model/webServerInfoResponse.ts +++ b/frontend/src/shared/types/model/webServerInfoResponse.ts @@ -5,11 +5,11 @@ * API for service monitoring and incident management * OpenAPI spec version: 1.0 */ -import type { WebAvailableUpdate } from "./webAvailableUpdate"; +import type { ModelsAvailableUpdate } from "./modelsAvailableUpdate"; export interface WebServerInfoResponse { arch?: string; - available_update?: WebAvailableUpdate; + available_update?: ModelsAvailableUpdate; build_date?: string; commit_hash?: string; go_version?: string; diff --git a/frontend/src/shared/types/model/webServiceDTO.ts b/frontend/src/shared/types/model/webServiceDTO.ts index 0e9ae69..4815dc5 100644 --- a/frontend/src/shared/types/model/webServiceDTO.ts +++ b/frontend/src/shared/types/model/webServiceDTO.ts @@ -6,8 +6,8 @@ * OpenAPI spec version: 1.0 */ import type { MonitorsConfig } from "./monitorsConfig"; -import type { StorageServiceProtocolType } from "./storageServiceProtocolType"; -import type { StorageServiceStatus } from "./storageServiceStatus"; +import type { ModelsServiceProtocolType } from "./modelsServiceProtocolType"; +import type { ModelsServiceStatus } from "./modelsServiceStatus"; export interface WebServiceDTO { active_incidents?: number; @@ -21,10 +21,10 @@ export interface WebServiceDTO { last_error?: string; name?: string; next_check?: string; - protocol?: StorageServiceProtocolType; + protocol?: ModelsServiceProtocolType; response_time?: number; retries?: number; - status?: StorageServiceStatus; + status?: ModelsServiceStatus; tags?: string[]; timeout?: number; total_checks?: number; diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 136f4ba..7ec2b6d 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -7,30 +7,44 @@ import ( "time" "github.com/sxwebdev/sentinel/internal/config" + "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/notifier" "github.com/sxwebdev/sentinel/internal/receiver" + "github.com/sxwebdev/sentinel/internal/services/baseservices" + "github.com/sxwebdev/sentinel/internal/services/servicestate" "github.com/sxwebdev/sentinel/internal/storage" "github.com/sxwebdev/sentinel/internal/store" + "github.com/sxwebdev/sentinel/internal/store/repos/repo_service_states" "github.com/sxwebdev/sentinel/internal/utils" + "github.com/tkcrm/modules/pkg/db/dbutils" ) // MonitorService handles service monitoring type MonitorService struct { - store *store.Store - storage *storage.Storage - config *config.ConfigHub - notifier *notifier.Notifier - receiver *receiver.Receiver + store *store.Store + storage *storage.Storage + config *config.ConfigHub + notifier *notifier.Notifier + receiver *receiver.Receiver + baseservices *baseservices.BaseServices } // NewMonitorService creates a new monitor service -func NewMonitorService(store *store.Store, storage *storage.Storage, config *config.ConfigHub, notifier *notifier.Notifier, receiver *receiver.Receiver) *MonitorService { +func NewMonitorService( + store *store.Store, + storage *storage.Storage, + config *config.ConfigHub, + notifier *notifier.Notifier, + receiver *receiver.Receiver, + baseservices *baseservices.BaseServices, +) *MonitorService { return &MonitorService{ - store: store, - storage: storage, - config: config, - notifier: notifier, - receiver: receiver, + store: store, + storage: storage, + config: config, + notifier: notifier, + receiver: receiver, + baseservices: baseservices, } } @@ -113,31 +127,38 @@ func NewMonitorService(store *store.Store, storage *storage.Storage, config *con // RecordSuccess records a successful check for a service func (m *MonitorService) RecordSuccess(ctx context.Context, serviceID string, responseTime time.Duration) error { - // Get current service from database - service, err := m.storage.GetServiceByID(ctx, serviceID) - if err != nil { - return fmt.Errorf("service %s not found in database: %w", serviceID, err) - } - // Get current service state - serviceState, err := m.storage.GetServiceState(ctx, serviceID) + serviceState, err := m.baseservices.ServiceStates().GetByServiceID(ctx, serviceID) if err != nil { return fmt.Errorf("failed to get service state: %w", err) } // Update state now := time.Now() - serviceState.Status = storage.StatusUp - serviceState.LastCheck = &now - serviceState.ResponseTimeNS = utils.Pointer(responseTime.Nanoseconds()) - serviceState.ConsecutiveFails = 0 - serviceState.ConsecutiveSuccess++ - serviceState.TotalChecks++ - serviceState.LastError = nil + updateParams := servicestate.UpdateParams{ + ServiceState: models.ServiceState{ + Status: models.StatusUp, + LastCheck: &now, + ResponseTimeNs: utils.Pointer(responseTime.Nanoseconds()), + ConsecutiveFails: 0, + ConsecutiveSuccess: serviceState.ConsecutiveSuccess + 1, + TotalChecks: serviceState.TotalChecks + 1, + LastError: nil, + }, + FieldMask: dbutils.FieldMask[repo_service_states.ColumnName]{ + repo_service_states.ColumnNameServiceStatesStatus, + repo_service_states.ColumnNameServiceStatesLastCheck, + repo_service_states.ColumnNameServiceStatesResponseTimeNs, + repo_service_states.ColumnNameServiceStatesConsecutiveFails, + repo_service_states.ColumnNameServiceStatesConsecutiveSuccess, + repo_service_states.ColumnNameServiceStatesTotalChecks, + repo_service_states.ColumnNameServiceStatesLastError, + }, + } // Save to database - if err := m.storage.UpdateServiceState(ctx, serviceState); err != nil { - return fmt.Errorf("failed to update service state for %s: %w", service.Name, err) + if _, err := m.baseservices.ServiceStates().Update(ctx, serviceState.ID, updateParams); err != nil { + return fmt.Errorf("failed to update service state: %w", err) } // Resolve any active incidents @@ -151,31 +172,44 @@ func (m *MonitorService) RecordSuccess(ctx context.Context, serviceID string, re // RecordFailure records a failed check for a service func (m *MonitorService) RecordFailure(ctx context.Context, serviceID string, checkErr error, responseTime time.Duration) error { // Get current service from database - service, err := m.storage.GetServiceByID(ctx, serviceID) + service, err := m.store.Services().GetViewByID(ctx, serviceID) if err != nil { return fmt.Errorf("service %s not found in database: %w", serviceID, err) } // Get current service state - serviceState, err := m.storage.GetServiceState(ctx, serviceID) + serviceState, err := m.baseservices.ServiceStates().GetByServiceID(ctx, serviceID) if err != nil { return fmt.Errorf("failed to get service state: %w", err) } // Update state now := time.Now() - wasUp := serviceState.Status == storage.StatusUp || serviceState.Status == storage.StatusUnknown - - serviceState.Status = storage.StatusDown - serviceState.LastCheck = &now - serviceState.ResponseTimeNS = &[]int64{responseTime.Nanoseconds()}[0] - serviceState.ConsecutiveFails++ - serviceState.ConsecutiveSuccess = 0 - serviceState.TotalChecks++ - serviceState.LastError = utils.Pointer(checkErr.Error()) + wasUp := serviceState.Status == models.StatusUp || serviceState.Status == models.StatusUnknown + + updateParams := servicestate.UpdateParams{ + ServiceState: models.ServiceState{ + Status: models.StatusDown, + LastCheck: &now, + ResponseTimeNs: utils.Pointer(responseTime.Nanoseconds()), + ConsecutiveFails: serviceState.ConsecutiveFails + 1, + ConsecutiveSuccess: 0, + TotalChecks: serviceState.TotalChecks + 1, + LastError: utils.Pointer(checkErr.Error()), + }, + FieldMask: dbutils.FieldMask[repo_service_states.ColumnName]{ + repo_service_states.ColumnNameServiceStatesStatus, + repo_service_states.ColumnNameServiceStatesLastCheck, + repo_service_states.ColumnNameServiceStatesResponseTimeNs, + repo_service_states.ColumnNameServiceStatesConsecutiveFails, + repo_service_states.ColumnNameServiceStatesConsecutiveSuccess, + repo_service_states.ColumnNameServiceStatesTotalChecks, + repo_service_states.ColumnNameServiceStatesLastError, + }, + } // Save to database - if err := m.storage.UpdateServiceState(ctx, serviceState); err != nil { + if _, err := m.baseservices.ServiceStates().Update(ctx, serviceState.ID, updateParams); err != nil { return fmt.Errorf("failed to update service state for %s: %w", service.Name, err) } @@ -190,7 +224,7 @@ func (m *MonitorService) RecordFailure(ctx context.Context, serviceID string, ch } // createIncident creates a new incident when a service goes down -func (m *MonitorService) createIncident(ctx context.Context, svc *storage.Service, err error) error { +func (m *MonitorService) createIncident(ctx context.Context, svc *models.ServiceFullView, err error) error { incident := &storage.Incident{ ID: utils.GenerateULID(), ServiceID: svc.ID, @@ -219,7 +253,7 @@ func (m *MonitorService) createIncident(ctx context.Context, svc *storage.Servic // resolveActiveIncidents resolves the active incident when a service recovers func (m *MonitorService) resolveActiveIncidents(ctx context.Context, serviceID string) error { // Get service - svc, err := m.storage.GetServiceByID(ctx, serviceID) + svc, err := m.store.Services().GetViewByID(ctx, serviceID) if err != nil { return fmt.Errorf("failed to get service: %w", err) } @@ -254,16 +288,6 @@ func (m *MonitorService) DeleteIncident(ctx context.Context, serviceID, incident return nil } -// GetServiceStats gets statistics for a service -func (m *MonitorService) GetServiceStats(ctx context.Context, serviceID string, since time.Time) (*storage.ServiceStats, error) { - params := storage.FindIncidentsParams{ - ServiceID: serviceID, - StartTime: utils.Pointer(since), - } - - return m.storage.GetServiceStats(ctx, params) -} - // TriggerCheck triggers a manual check for a service func (m *MonitorService) TriggerCheck(ctx context.Context, id string) error { // Get service to check if it exists @@ -293,38 +317,44 @@ func (m *MonitorService) ForceResolveIncidents(ctx context.Context, serviceID st // CheckService performs a health check on a service func (m *MonitorService) CheckService(ctx context.Context, service *storage.Service) error { // Get current service state - serviceState, err := m.storage.GetServiceState(ctx, service.ID) + serviceState, err := m.baseservices.ServiceStates().GetByServiceID(ctx, service.ID) if err != nil { return fmt.Errorf("failed to get service state: %w", err) } - // Initialize state if not exists - if serviceState == nil { - serviceState = &storage.ServiceStateRecord{ - ID: utils.GenerateULID(), - ServiceID: service.ID, - Status: storage.StatusUnknown, - ConsecutiveFails: 0, - ConsecutiveSuccess: 0, - TotalChecks: 0, - } - } - // Perform the check (simplified - just record success/failure) startTime := time.Now() responseTime := time.Since(startTime) now := time.Now() // For now, just record success (this should be replaced with actual check logic) - wasDown := serviceState.Status == storage.StatusDown + wasDown := serviceState.Status == models.StatusDown - serviceState.Status = storage.StatusUp - serviceState.LastCheck = &now - serviceState.ResponseTimeNS = &[]int64{responseTime.Nanoseconds()}[0] - serviceState.ConsecutiveFails = 0 - serviceState.ConsecutiveSuccess++ - serviceState.TotalChecks++ - serviceState.LastError = nil + updateParams := servicestate.UpdateParams{ + ServiceState: models.ServiceState{ + Status: models.StatusUp, + LastCheck: &now, + ResponseTimeNs: utils.Pointer(responseTime.Nanoseconds()), + ConsecutiveFails: 0, + ConsecutiveSuccess: serviceState.ConsecutiveSuccess + 1, + TotalChecks: serviceState.TotalChecks + 1, + LastError: nil, + }, + FieldMask: dbutils.FieldMask[repo_service_states.ColumnName]{ + repo_service_states.ColumnNameServiceStatesStatus, + repo_service_states.ColumnNameServiceStatesLastCheck, + repo_service_states.ColumnNameServiceStatesResponseTimeNs, + repo_service_states.ColumnNameServiceStatesConsecutiveFails, + repo_service_states.ColumnNameServiceStatesConsecutiveSuccess, + repo_service_states.ColumnNameServiceStatesTotalChecks, + repo_service_states.ColumnNameServiceStatesLastError, + }, + } + + // Save to database + if _, err := m.baseservices.ServiceStates().Update(ctx, serviceState.ID, updateParams); err != nil { + return fmt.Errorf("failed to update service state: %w", err) + } // Resolve incident if service was down before if wasDown { @@ -334,7 +364,7 @@ func (m *MonitorService) CheckService(ctx context.Context, service *storage.Serv } // Update service state - if err := m.storage.UpdateServiceState(ctx, serviceState); err != nil { + if _, err := m.baseservices.ServiceStates().Update(ctx, serviceState.ID, updateParams); err != nil { return fmt.Errorf("failed to update service state: %w", err) } diff --git a/internal/notifier/shoutrrr.go b/internal/notifier/shoutrrr.go index 3e457d1..b224e3f 100644 --- a/internal/notifier/shoutrrr.go +++ b/internal/notifier/shoutrrr.go @@ -8,6 +8,7 @@ import ( "time" "github.com/containrrr/shoutrrr" + "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/storage" "github.com/tkcrm/mx/logger" ) @@ -148,7 +149,7 @@ func (s *Notifier) processNotification(req *notificationRequest) { } // SendAlert sends an alert notification when a service goes down -func (s *Notifier) SendAlert(service *storage.Service, incident *storage.Incident) error { +func (s *Notifier) SendAlert(service *models.ServiceFullView, incident *storage.Incident) error { s.mu.RLock() if !s.isStarted { s.mu.RUnlock() @@ -161,7 +162,7 @@ func (s *Notifier) SendAlert(service *storage.Service, incident *storage.Inciden } // SendRecovery sends a recovery notification when a service comes back up -func (s *Notifier) SendRecovery(service *storage.Service, incident *storage.Incident) error { +func (s *Notifier) SendRecovery(service *models.ServiceFullView, incident *storage.Incident) error { s.mu.RLock() if !s.isStarted { s.mu.RUnlock() @@ -272,7 +273,7 @@ func (s *Notifier) sendMessageSync(message string) error { } // formatAlertMessage formats an alert message -func (s *Notifier) formatAlertMessage(service *storage.Service, incident *storage.Incident) string { +func (s *Notifier) formatAlertMessage(service *models.ServiceFullView, incident *storage.Incident) string { tags := "-" if len(service.Tags) > 0 { tags = strings.Join(service.Tags, ", ") @@ -295,7 +296,7 @@ func (s *Notifier) formatAlertMessage(service *storage.Service, incident *storag } // formatRecoveryMessage formats a recovery message -func (s *Notifier) formatRecoveryMessage(service *storage.Service, incident *storage.Incident) string { +func (s *Notifier) formatRecoveryMessage(service *models.ServiceFullView, incident *storage.Incident) string { var duration string if incident.Duration != nil { duration = formatDuration(*incident.Duration) diff --git a/internal/services/baseservices/base.go b/internal/services/baseservices/base.go index 55178a0..1254f38 100644 --- a/internal/services/baseservices/base.go +++ b/internal/services/baseservices/base.go @@ -5,13 +5,15 @@ import ( "github.com/sxwebdev/sentinel/internal/services/agents" "github.com/sxwebdev/sentinel/internal/services/incidents" "github.com/sxwebdev/sentinel/internal/services/service" + "github.com/sxwebdev/sentinel/internal/services/servicestate" "github.com/sxwebdev/sentinel/internal/store" ) type BaseServices struct { - agentsService *agents.Service - servicesService *service.Service - incidentsService *incidents.Service + agentsService *agents.Service + servicesService *service.Service + serviceStateService *servicestate.Service + incidentsService *incidents.Service } func New( @@ -20,12 +22,14 @@ func New( ) *BaseServices { agentsService := agents.New(st) servicesService := service.New(st, receiver) + serviceStateService := servicestate.New(st) incidentsService := incidents.New(st) return &BaseServices{ - agentsService: agentsService, - servicesService: servicesService, - incidentsService: incidentsService, + agentsService: agentsService, + servicesService: servicesService, + serviceStateService: serviceStateService, + incidentsService: incidentsService, } } @@ -39,6 +43,11 @@ func (b *BaseServices) Services() *service.Service { return b.servicesService } +// ServiceStates returns service states service +func (b *BaseServices) ServiceStates() *servicestate.Service { + return b.serviceStateService +} + // Incidents returns incidents service func (b *BaseServices) Incidents() *incidents.Service { return b.incidentsService diff --git a/internal/services/service/stats.go b/internal/services/service/stats.go new file mode 100644 index 0000000..66ffe71 --- /dev/null +++ b/internal/services/service/stats.go @@ -0,0 +1,63 @@ +package service + +import ( + "context" + "fmt" + "time" +) + +type Stats struct { + ServiceID string `json:"service_id"` + Period time.Duration `json:"period" swaggertype:"primitive,integer"` + TotalIncidents int64 `json:"total_incidents"` + TotalDowntime time.Duration `json:"total_downtime" swaggertype:"primitive,integer"` + UptimePercentage float64 `json:"uptime_percentage"` + AvgResponseTime time.Duration `json:"avg_response_time" swaggertype:"primitive,integer"` + ResolvedIncidents int64 `json:"resolved_incidents"` + UnresolvedIncidents int64 `json:"unresolved_incidents"` +} + +func (s *Service) Stats(ctx context.Context, serviceID string, since time.Time) (*Stats, error) { + if serviceID == "" || since.IsZero() { + return nil, fmt.Errorf("service ID and start time are required for stats") + } + + incidentsStats, err := s.store.Incidents().StatsByServiceID(ctx, serviceID, since) + if err != nil { + return nil, fmt.Errorf("failed to get incidents stats: %w", err) + } + + incidentsStatsDomain := incidentsStats.ToDomain() + + // Calculate uptime percentage + period := time.Since(since) + uptimePercentage := 100.0 + if period > 0 { + uptimePercentage = 100.0 - (float64(incidentsStatsDomain.TotalDowntime) / float64(period) * 100.0) + if uptimePercentage < 0 { + uptimePercentage = 0 + } + } + + // Get average response time from service state + var avgResponseTime time.Duration + serviceState, err := s.store.ServiceStates().GetByServiceID(ctx, serviceID) + if err != nil { + return nil, fmt.Errorf("failed to get service state: %w", err) + } + + if serviceState.ResponseTimeNs != nil { + avgResponseTime = time.Duration(*serviceState.ResponseTimeNs) + } + + return &Stats{ + ServiceID: serviceID, + Period: period, + TotalIncidents: incidentsStatsDomain.TotalIncidents, + TotalDowntime: incidentsStatsDomain.TotalDowntime, + UptimePercentage: uptimePercentage, + AvgResponseTime: avgResponseTime, + ResolvedIncidents: incidentsStatsDomain.ResolvedIncidents, + UnresolvedIncidents: incidentsStatsDomain.UnresolvedIncidents, + }, nil +} diff --git a/internal/services/servicestate/methods.go b/internal/services/servicestate/methods.go new file mode 100644 index 0000000..60e7e8a --- /dev/null +++ b/internal/services/servicestate/methods.go @@ -0,0 +1,25 @@ +package servicestate + +import ( + "context" + + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/repos/repo_service_states" +) + +// GetAll retrieves all service states +func (s *Service) GetAll(ctx context.Context) ([]*models.ServiceState, error) { + return s.store.ServiceStates().GetAll(ctx) +} + +// GetByServiceID retrieves a service state by service ID +func (s *Service) GetByServiceID(ctx context.Context, serviceID string) (*models.ServiceState, error) { + return s.store.ServiceStates().GetByServiceID(ctx, serviceID) +} + +type UpdateParams = repo_service_states.UpdateRequest + +// Update updates a service state by ID +func (s *Service) Update(ctx context.Context, id string, params UpdateParams) (*models.ServiceState, error) { + return s.store.ServiceStates().Update(ctx, id, params) +} diff --git a/internal/services/servicestate/service.go b/internal/services/servicestate/service.go new file mode 100644 index 0000000..4638592 --- /dev/null +++ b/internal/services/servicestate/service.go @@ -0,0 +1,15 @@ +package servicestate + +import ( + "github.com/sxwebdev/sentinel/internal/store" +) + +type Service struct { + store *store.Store +} + +func New(store *store.Store) *Service { + return &Service{ + store: store, + } +} diff --git a/internal/storage/services.go b/internal/storage/services.go index cf2c685..a4cc9e1 100644 --- a/internal/storage/services.go +++ b/internal/storage/services.go @@ -2,114 +2,110 @@ package storage import ( "context" - "database/sql" "encoding/json" - "errors" "fmt" - "strings" "time" "github.com/huandu/go-sqlbuilder" - "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/utils" ) // GetServiceStats calculates statistics for a service -func (o *Storage) GetServiceStats(ctx context.Context, params FindIncidentsParams) (*ServiceStats, error) { - if params.ServiceID == "" || params.StartTime == nil { - return nil, fmt.Errorf("service ID and start time are required for stats") - } - - // Get all incidents for the service since the specified time - sb := findIncidentsBuilder(params, - "i.id", - "i.service_id", - "i.start_time", - "i.end_time", - "i.error", - "i.duration_ns", - "i.resolved", - ) - - sql, args := sb.Build() - rows, err := o.db.QueryContext(ctx, sql, args...) - if err != nil { - return nil, fmt.Errorf("failed to query incidents: %w", err) - } - defer rows.Close() - - incidents := []*Incident{} - for rows.Next() { - var incidentRow IncidentRow - err := rows.Scan( - &incidentRow.ID, - &incidentRow.ServiceID, - &incidentRow.StartTime, - &incidentRow.EndTime, - &incidentRow.Error, - &incidentRow.DurationNS, - &incidentRow.Resolved, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan incident: %w", err) - } - - incidents = append(incidents, o.rowToIncident(&incidentRow)) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating rows: %w", err) - } - - // Calculate statistics - totalIncidents := len(incidents) - totalDowntime := time.Duration(0) - resolvedIncidents := 0 - - for _, incident := range incidents { - if incident.Resolved && incident.Duration != nil { - totalDowntime += *incident.Duration - resolvedIncidents++ - } - } - - // Calculate uptime percentage - period := time.Since(*params.StartTime) - uptimePercentage := 100.0 - if period > 0 { - uptimePercentage = 100.0 - (float64(totalDowntime) / float64(period) * 100.0) - if uptimePercentage < 0 { - uptimePercentage = 0 - } - } - - // Get average response time from service state - avgResponseTime := time.Duration(0) - serviceState, err := o.GetServiceState(ctx, params.ServiceID) - if err != nil { - // If service state not found, return stats without response time - return &ServiceStats{ - ServiceID: params.ServiceID, - TotalIncidents: totalIncidents, - TotalDowntime: totalDowntime, - UptimePercentage: uptimePercentage, - Period: period, - AvgResponseTime: 0, - }, nil - } - if serviceState != nil && serviceState.ResponseTimeNS != nil { - avgResponseTime = time.Duration(*serviceState.ResponseTimeNS) - } - - return &ServiceStats{ - ServiceID: params.ServiceID, - TotalIncidents: totalIncidents, - TotalDowntime: totalDowntime, - UptimePercentage: uptimePercentage, - Period: period, - AvgResponseTime: avgResponseTime, - }, nil -} +// func (o *Storage) GetServiceStats(ctx context.Context, params FindIncidentsParams) (*ServiceStats, error) { +// if params.ServiceID == "" || params.StartTime == nil { +// return nil, fmt.Errorf("service ID and start time are required for stats") +// } + +// // Get all incidents for the service since the specified time +// sb := findIncidentsBuilder(params, +// "i.id", +// "i.service_id", +// "i.start_time", +// "i.end_time", +// "i.error", +// "i.duration_ns", +// "i.resolved", +// ) + +// sql, args := sb.Build() +// rows, err := o.db.QueryContext(ctx, sql, args...) +// if err != nil { +// return nil, fmt.Errorf("failed to query incidents: %w", err) +// } +// defer rows.Close() + +// incidents := []*Incident{} +// for rows.Next() { +// var incidentRow IncidentRow +// err := rows.Scan( +// &incidentRow.ID, +// &incidentRow.ServiceID, +// &incidentRow.StartTime, +// &incidentRow.EndTime, +// &incidentRow.Error, +// &incidentRow.DurationNS, +// &incidentRow.Resolved, +// ) +// if err != nil { +// return nil, fmt.Errorf("failed to scan incident: %w", err) +// } + +// incidents = append(incidents, o.rowToIncident(&incidentRow)) +// } + +// if err := rows.Err(); err != nil { +// return nil, fmt.Errorf("error iterating rows: %w", err) +// } + +// // Calculate statistics +// totalIncidents := len(incidents) +// totalDowntime := time.Duration(0) +// resolvedIncidents := 0 + +// for _, incident := range incidents { +// if incident.Resolved && incident.Duration != nil { +// totalDowntime += *incident.Duration +// resolvedIncidents++ +// } +// } + +// // Calculate uptime percentage +// period := time.Since(*params.StartTime) +// uptimePercentage := 100.0 +// if period > 0 { +// uptimePercentage = 100.0 - (float64(totalDowntime) / float64(period) * 100.0) +// if uptimePercentage < 0 { +// uptimePercentage = 0 +// } +// } + +// // Get average response time from service state +// avgResponseTime := time.Duration(0) +// serviceState, err := o.GetServiceState(ctx, params.ServiceID) +// if err != nil { +// // If service state not found, return stats without response time +// return &ServiceStats{ +// ServiceID: params.ServiceID, +// TotalIncidents: totalIncidents, +// TotalDowntime: totalDowntime, +// UptimePercentage: uptimePercentage, +// Period: period, +// AvgResponseTime: 0, +// }, nil +// } +// if serviceState != nil && serviceState.ResponseTimeNS != nil { +// avgResponseTime = time.Duration(*serviceState.ResponseTimeNS) +// } + +// return &ServiceStats{ +// ServiceID: params.ServiceID, +// TotalIncidents: totalIncidents, +// TotalDowntime: totalDowntime, +// UptimePercentage: uptimePercentage, +// Period: period, +// AvgResponseTime: avgResponseTime, +// }, nil +// } // rowToIncident converts an IncidentRow to Incident func (o *Storage) rowToIncident(row *IncidentRow) *Incident { @@ -131,480 +127,480 @@ func (o *Storage) rowToIncident(row *IncidentRow) *Incident { } // GetServiceByID finds a service by ID using ORM -func (o *Storage) GetServiceByID(ctx context.Context, id string) (*Service, error) { - sb := sqlbuilder.NewSelectBuilder() - sb.Select( - "s.id", - "s.name", - "s.protocol", - "s.interval", - "s.timeout", - "s.retries", - "s.tags", - "s.config", - "s.is_enabled", - "s.created_at", - "s.updated_at", - "count(incidents.id) as total_incidents", - "sum(case when incidents.resolved = 0 then 1 else 0 end) as active_incidents", - "ss.status", - "ss.last_check", - "ss.next_check", - "ss.last_error", - "ss.consecutive_fails", - "ss.consecutive_success", - "ss.total_checks", - "ss.response_time_ns", - ) - sb.From("services s") - sb.JoinWithOption(sqlbuilder.LeftJoin, "incidents", "s.id = incidents.service_id") - sb.JoinWithOption(sqlbuilder.LeftJoin, "service_states ss", "s.id = ss.service_id") - sb.Where(sb.Equal("s.id", id)) - sb.GroupBy("s.id") - - query, args := sb.Build() - row := o.db.QueryRowContext(ctx, query, args...) - - var item serviceRow - err := row.Scan( - &item.ID, - &item.Name, - &item.Protocol, - &item.Interval, - &item.Timeout, - &item.Retries, - &item.Tags, - &item.Config, - &item.IsEnabled, - &item.CreatedAt, - &item.UpdatedAt, - &item.TotalIncidents, - &item.ActiveIncidents, - &item.Status, - &item.LastCheck, - &item.NextCheck, - &item.LastError, - &item.ConsecutiveFails, - &item.ConsecutiveSuccess, - &item.TotalChecks, - &item.ResponseTimeNS, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrNotFound - } - return nil, fmt.Errorf("failed to scan service: %w", err) - } - - svc, err := rowToService(&item) - if err != nil { - return nil, err - } - - return svc, nil -} - -func findServicesBuilder(params FindServicesParams, col ...string) *sqlbuilder.SelectBuilder { - sb := sqlbuilder.NewSelectBuilder() - sb.Select(col...) - sb.From("services s") - - if params.Name != "" { - sb.Where(sb.Like("s.name", "%"+params.Name+"%")) - } - - if params.Protocol != "" { - sb.Where(sb.Equal("s.protocol", params.Protocol)) - } - - if params.IsEnabled != nil { - sb.Where(sb.Equal("s.is_enabled", *params.IsEnabled)) - } - - if params.Status != "" { - switch params.Status { - case "up": - sb.Where(sb.Equal("ss.status", StatusUp)) - case "down": - sb.Where(sb.Equal("ss.status", StatusDown)) - } - } - - if len(params.Tags) > 0 { - var tagConditions []string - for _, tag := range params.Tags { - tagConditions = append(tagConditions, - fmt.Sprintf("EXISTS (SELECT 1 FROM json_each(s.tags) WHERE json_each.value = %s)", - sb.Args.Add(tag))) - } - - if len(tagConditions) > 0 { - sb.Where(fmt.Sprintf("(%s)", strings.Join(tagConditions, " AND "))) - } - } - - return sb -} - -type FindServicesParams struct { - Name string - IsEnabled *bool - Protocol string - Tags []string - Status string // e.g. "up", "down" - OrderBy string - Page *uint32 - PageSize *uint32 -} +// func (o *Storage) GetServiceByID(ctx context.Context, id string) (*Service, error) { +// sb := sqlbuilder.NewSelectBuilder() +// sb.Select( +// "s.id", +// "s.name", +// "s.protocol", +// "s.interval", +// "s.timeout", +// "s.retries", +// "s.tags", +// "s.config", +// "s.is_enabled", +// "s.created_at", +// "s.updated_at", +// "count(incidents.id) as total_incidents", +// "sum(case when incidents.resolved = 0 then 1 else 0 end) as active_incidents", +// "ss.status", +// "ss.last_check", +// "ss.next_check", +// "ss.last_error", +// "ss.consecutive_fails", +// "ss.consecutive_success", +// "ss.total_checks", +// "ss.response_time_ns", +// ) +// sb.From("services s") +// sb.JoinWithOption(sqlbuilder.LeftJoin, "incidents", "s.id = incidents.service_id") +// sb.JoinWithOption(sqlbuilder.LeftJoin, "service_states ss", "s.id = ss.service_id") +// sb.Where(sb.Equal("s.id", id)) +// sb.GroupBy("s.id") + +// query, args := sb.Build() +// row := o.db.QueryRowContext(ctx, query, args...) + +// var item serviceRow +// err := row.Scan( +// &item.ID, +// &item.Name, +// &item.Protocol, +// &item.Interval, +// &item.Timeout, +// &item.Retries, +// &item.Tags, +// &item.Config, +// &item.IsEnabled, +// &item.CreatedAt, +// &item.UpdatedAt, +// &item.TotalIncidents, +// &item.ActiveIncidents, +// &item.Status, +// &item.LastCheck, +// &item.NextCheck, +// &item.LastError, +// &item.ConsecutiveFails, +// &item.ConsecutiveSuccess, +// &item.TotalChecks, +// &item.ResponseTimeNS, +// ) +// if err != nil { +// if errors.Is(err, sql.ErrNoRows) { +// return nil, ErrNotFound +// } +// return nil, fmt.Errorf("failed to scan service: %w", err) +// } + +// svc, err := rowToService(&item) +// if err != nil { +// return nil, err +// } + +// return svc, nil +// } + +// func findServicesBuilder(params FindServicesParams, col ...string) *sqlbuilder.SelectBuilder { +// sb := sqlbuilder.NewSelectBuilder() +// sb.Select(col...) +// sb.From("services s") + +// if params.Name != "" { +// sb.Where(sb.Like("s.name", "%"+params.Name+"%")) +// } + +// if params.Protocol != "" { +// sb.Where(sb.Equal("s.protocol", params.Protocol)) +// } + +// if params.IsEnabled != nil { +// sb.Where(sb.Equal("s.is_enabled", *params.IsEnabled)) +// } + +// if params.Status != "" { +// switch params.Status { +// case "up": +// sb.Where(sb.Equal("ss.status", StatusUp)) +// case "down": +// sb.Where(sb.Equal("ss.status", StatusDown)) +// } +// } + +// if len(params.Tags) > 0 { +// var tagConditions []string +// for _, tag := range params.Tags { +// tagConditions = append(tagConditions, +// fmt.Sprintf("EXISTS (SELECT 1 FROM json_each(s.tags) WHERE json_each.value = %s)", +// sb.Args.Add(tag))) +// } + +// if len(tagConditions) > 0 { +// sb.Where(fmt.Sprintf("(%s)", strings.Join(tagConditions, " AND "))) +// } +// } + +// return sb +// } + +// type FindServicesParams struct { +// Name string +// IsEnabled *bool +// Protocol string +// Tags []string +// Status string // e.g. "up", "down" +// OrderBy string +// Page *uint32 +// PageSize *uint32 +// } // GetAllServices finds all services using ORM -func (o *Storage) FindServices(ctx context.Context, params FindServicesParams) (storecmn.FindResponseWithCount[*Service], error) { - sb := findServicesBuilder( - params, - "s.id", - "s.name", - "s.protocol", - "s.interval", - "s.timeout", - "s.retries", - "s.tags", - "s.config", - "s.is_enabled", - "s.created_at", - "s.updated_at", - "count(incidents.id) as total_incidents", - "sum(case when incidents.resolved = 0 then 1 else 0 end) as active_incidents", - "ss.status", - "ss.last_check", - "ss.next_check", - "ss.last_error", - "ss.consecutive_fails", - "ss.consecutive_success", - "ss.total_checks", - "ss.response_time_ns", - ) - sb.JoinWithOption(sqlbuilder.LeftJoin, "incidents", "s.id = incidents.service_id") - sb.JoinWithOption(sqlbuilder.LeftJoin, "service_states ss", "s.id = ss.service_id") - sb.GroupBy("s.id") - - if params.OrderBy != "" { - // Add table prefix for common column names to avoid ambiguity - orderBy := params.OrderBy - switch orderBy { - case "created_at": - orderBy = "s.created_at" - case "updated_at": - orderBy = "s.updated_at" - case "name": - orderBy = "s.name" - case "protocol": - orderBy = "s.protocol" - case "status": - orderBy = "ss.status" - case "last_check": - orderBy = "ss.last_check" - } - sb.OrderBy(orderBy) - } else { - sb.OrderBy("s.name") - } - - res := storecmn.FindResponseWithCount[*Service]{} - - limit, offset, err := storecmn.Pagination(params.Page, params.PageSize) - if err != nil { - return res, err - } - sb.Limit(int(limit)).Offset(int(offset)) - - sql, args := sb.Build() - rows, err := o.db.QueryContext(ctx, sql, args...) - if err != nil { - return res, fmt.Errorf("failed to query services: %w", err) - } - defer rows.Close() - - services := []*Service{} - for rows.Next() { - var item serviceRow - err := rows.Scan( - &item.ID, - &item.Name, - &item.Protocol, - &item.Interval, - &item.Timeout, - &item.Retries, - &item.Tags, - &item.Config, - &item.IsEnabled, - &item.CreatedAt, - &item.UpdatedAt, - &item.TotalIncidents, - &item.ActiveIncidents, - &item.Status, - &item.LastCheck, - &item.NextCheck, - &item.LastError, - &item.ConsecutiveFails, - &item.ConsecutiveSuccess, - &item.TotalChecks, - &item.ResponseTimeNS, - ) - if err != nil { - return res, fmt.Errorf("failed to scan service: %w", err) - } - - svc, err := rowToService(&item) - if err != nil { - return res, fmt.Errorf("failed to convert service row: %w", err) - } - - services = append(services, svc) - } - - if err := rows.Err(); err != nil { - return res, fmt.Errorf("error iterating rows: %w", err) - } - - // Get total count of services - countQuery := findServicesBuilder(params, "count(*)") - countQuery.JoinWithOption(sqlbuilder.LeftJoin, "service_states ss", "s.id = ss.service_id") - - countSQL, countArgs := countQuery.Build() - - var totalCount int - if err := o.db.QueryRowContext(ctx, countSQL, countArgs...).Scan(&totalCount); err != nil { - return res, fmt.Errorf("failed to count services: %w", err) - } - - res.Count = uint32(totalCount) - res.Items = services - - return res, nil -} +// func (o *Storage) FindServices(ctx context.Context, params FindServicesParams) (storecmn.FindResponseWithCount[*Service], error) { +// sb := findServicesBuilder( +// params, +// "s.id", +// "s.name", +// "s.protocol", +// "s.interval", +// "s.timeout", +// "s.retries", +// "s.tags", +// "s.config", +// "s.is_enabled", +// "s.created_at", +// "s.updated_at", +// "count(incidents.id) as total_incidents", +// "sum(case when incidents.resolved = 0 then 1 else 0 end) as active_incidents", +// "ss.status", +// "ss.last_check", +// "ss.next_check", +// "ss.last_error", +// "ss.consecutive_fails", +// "ss.consecutive_success", +// "ss.total_checks", +// "ss.response_time_ns", +// ) +// sb.JoinWithOption(sqlbuilder.LeftJoin, "incidents", "s.id = incidents.service_id") +// sb.JoinWithOption(sqlbuilder.LeftJoin, "service_states ss", "s.id = ss.service_id") +// sb.GroupBy("s.id") + +// if params.OrderBy != "" { +// // Add table prefix for common column names to avoid ambiguity +// orderBy := params.OrderBy +// switch orderBy { +// case "created_at": +// orderBy = "s.created_at" +// case "updated_at": +// orderBy = "s.updated_at" +// case "name": +// orderBy = "s.name" +// case "protocol": +// orderBy = "s.protocol" +// case "status": +// orderBy = "ss.status" +// case "last_check": +// orderBy = "ss.last_check" +// } +// sb.OrderBy(orderBy) +// } else { +// sb.OrderBy("s.name") +// } + +// res := storecmn.FindResponseWithCount[*Service]{} + +// limit, offset, err := storecmn.Pagination(params.Page, params.PageSize) +// if err != nil { +// return res, err +// } +// sb.Limit(int(limit)).Offset(int(offset)) + +// sql, args := sb.Build() +// rows, err := o.db.QueryContext(ctx, sql, args...) +// if err != nil { +// return res, fmt.Errorf("failed to query services: %w", err) +// } +// defer rows.Close() + +// services := []*Service{} +// for rows.Next() { +// var item serviceRow +// err := rows.Scan( +// &item.ID, +// &item.Name, +// &item.Protocol, +// &item.Interval, +// &item.Timeout, +// &item.Retries, +// &item.Tags, +// &item.Config, +// &item.IsEnabled, +// &item.CreatedAt, +// &item.UpdatedAt, +// &item.TotalIncidents, +// &item.ActiveIncidents, +// &item.Status, +// &item.LastCheck, +// &item.NextCheck, +// &item.LastError, +// &item.ConsecutiveFails, +// &item.ConsecutiveSuccess, +// &item.TotalChecks, +// &item.ResponseTimeNS, +// ) +// if err != nil { +// return res, fmt.Errorf("failed to scan service: %w", err) +// } + +// svc, err := rowToService(&item) +// if err != nil { +// return res, fmt.Errorf("failed to convert service row: %w", err) +// } + +// services = append(services, svc) +// } + +// if err := rows.Err(); err != nil { +// return res, fmt.Errorf("error iterating rows: %w", err) +// } + +// // Get total count of services +// countQuery := findServicesBuilder(params, "count(*)") +// countQuery.JoinWithOption(sqlbuilder.LeftJoin, "service_states ss", "s.id = ss.service_id") + +// countSQL, countArgs := countQuery.Build() + +// var totalCount int +// if err := o.db.QueryRowContext(ctx, countSQL, countArgs...).Scan(&totalCount); err != nil { +// return res, fmt.Errorf("failed to count services: %w", err) +// } + +// res.Count = uint32(totalCount) +// res.Items = services + +// return res, nil +// } // CreateService creates a new service using ORM with retry logic -func (o *Storage) CreateService(ctx context.Context, service CreateUpdateServiceRequest) (*Service, error) { - ib := sqlbuilder.NewInsertBuilder() - ib.InsertInto("services") - ib.Cols("id", "name", "protocol", "interval", "timeout", "retries", "tags", "config", "is_enabled") - - tagsJSON, err := json.Marshal(service.Tags) - if err != nil { - return nil, fmt.Errorf("failed to marshal tags: %w", err) - } - - configJSON, err := json.Marshal(service.Config) - if err != nil { - return nil, fmt.Errorf("failed to marshal config: %w", err) - } - - serviceID := utils.GenerateULID() - - ib.Values( - serviceID, - service.Name, - service.Protocol, - service.Interval.String(), - service.Timeout.String(), - service.Retries, - string(tagsJSON), - string(configJSON), - service.IsEnabled, - ) - - tx, err := o.db.BeginTx(ctx, nil) - if err != nil { - return nil, fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback() - - sql, args := ib.Build() - _, err = tx.ExecContext(ctx, sql, args...) - if err != nil { - return nil, fmt.Errorf("failed to create service: %w", err) - } - - nextCheck := time.Now().Add(service.Interval) - serviceState := &ServiceStateRecord{ - ID: utils.GenerateULID(), - ServiceID: serviceID, - Status: StatusUnknown, - NextCheck: &nextCheck, - } - - if err := o.CreateServiceState(ctx, tx, serviceState); err != nil { - return nil, fmt.Errorf("failed to create service state: %w", err) - } - - // Commit transaction - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("failed to commit transaction: %w", err) - } - - return o.GetServiceByID(ctx, serviceID) -} +// func (o *Storage) CreateService(ctx context.Context, service CreateUpdateServiceRequest) (*Service, error) { +// ib := sqlbuilder.NewInsertBuilder() +// ib.InsertInto("services") +// ib.Cols("id", "name", "protocol", "interval", "timeout", "retries", "tags", "config", "is_enabled") + +// tagsJSON, err := json.Marshal(service.Tags) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal tags: %w", err) +// } + +// configJSON, err := json.Marshal(service.Config) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal config: %w", err) +// } + +// serviceID := utils.GenerateULID() + +// ib.Values( +// serviceID, +// service.Name, +// service.Protocol, +// service.Interval.String(), +// service.Timeout.String(), +// service.Retries, +// string(tagsJSON), +// string(configJSON), +// service.IsEnabled, +// ) + +// tx, err := o.db.BeginTx(ctx, nil) +// if err != nil { +// return nil, fmt.Errorf("failed to begin transaction: %w", err) +// } +// defer tx.Rollback() + +// sql, args := ib.Build() +// _, err = tx.ExecContext(ctx, sql, args...) +// if err != nil { +// return nil, fmt.Errorf("failed to create service: %w", err) +// } + +// nextCheck := time.Now().Add(service.Interval) +// serviceState := &ServiceStateRecord{ +// ID: utils.GenerateULID(), +// ServiceID: serviceID, +// Status: StatusUnknown, +// NextCheck: &nextCheck, +// } + +// if err := o.CreateServiceState(ctx, tx, serviceState); err != nil { +// return nil, fmt.Errorf("failed to create service state: %w", err) +// } + +// // Commit transaction +// if err := tx.Commit(); err != nil { +// return nil, fmt.Errorf("failed to commit transaction: %w", err) +// } + +// return o.GetServiceByID(ctx, serviceID) +// } // UpdateService updates an existing service using ORM with retry logic -func (o *Storage) UpdateService(ctx context.Context, id string, service CreateUpdateServiceRequest) (*Service, error) { - ub := sqlbuilder.NewUpdateBuilder() - ub.Update("services") - - tagsJSON, err := json.Marshal(service.Tags) - if err != nil { - return nil, fmt.Errorf("failed to marshal tags: %w", err) - } - - configJSON, err := json.Marshal(service.Config) - if err != nil { - return nil, fmt.Errorf("failed to marshal config: %w", err) - } - - // Prepare all fields for update - assignments := []string{ - ub.Assign("name", service.Name), - ub.Assign("protocol", service.Protocol), - ub.Assign("interval", service.Interval.String()), - ub.Assign("timeout", service.Timeout.String()), - ub.Assign("retries", service.Retries), - ub.Assign("tags", string(tagsJSON)), - ub.Assign("config", string(configJSON)), - ub.Assign("is_enabled", service.IsEnabled), - ub.Assign("updated_at", time.Now()), - } - - // Set all assignments at once - ub.Set(assignments...) - ub.Where(ub.Equal("id", id)) - - sql, args := ub.Build() - result, err := o.db.ExecContext(ctx, sql, args...) - if err != nil { - return nil, fmt.Errorf("failed to update service: %w", err) - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return nil, fmt.Errorf("failed to get rows affected: %w", err) - } - - if rowsAffected == 0 { - return nil, fmt.Errorf("service not found") - } - - return o.GetServiceByID(ctx, id) -} +// func (o *Storage) UpdateService(ctx context.Context, id string, service CreateUpdateServiceRequest) (*Service, error) { +// ub := sqlbuilder.NewUpdateBuilder() +// ub.Update("services") + +// tagsJSON, err := json.Marshal(service.Tags) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal tags: %w", err) +// } + +// configJSON, err := json.Marshal(service.Config) +// if err != nil { +// return nil, fmt.Errorf("failed to marshal config: %w", err) +// } + +// // Prepare all fields for update +// assignments := []string{ +// ub.Assign("name", service.Name), +// ub.Assign("protocol", service.Protocol), +// ub.Assign("interval", service.Interval.String()), +// ub.Assign("timeout", service.Timeout.String()), +// ub.Assign("retries", service.Retries), +// ub.Assign("tags", string(tagsJSON)), +// ub.Assign("config", string(configJSON)), +// ub.Assign("is_enabled", service.IsEnabled), +// ub.Assign("updated_at", time.Now()), +// } + +// // Set all assignments at once +// ub.Set(assignments...) +// ub.Where(ub.Equal("id", id)) + +// sql, args := ub.Build() +// result, err := o.db.ExecContext(ctx, sql, args...) +// if err != nil { +// return nil, fmt.Errorf("failed to update service: %w", err) +// } + +// rowsAffected, err := result.RowsAffected() +// if err != nil { +// return nil, fmt.Errorf("failed to get rows affected: %w", err) +// } + +// if rowsAffected == 0 { +// return nil, fmt.Errorf("service not found") +// } + +// return o.GetServiceByID(ctx, id) +// } // DeleteService deletes a service by ID -func (o *Storage) DeleteService(ctx context.Context, id string) error { - // Start transaction - tx, err := o.db.BeginTx(ctx, nil) - if err != nil { - return fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback() - - // Delete related incidents first - incidentsQuery := `DELETE FROM incidents WHERE service_id = ?` - _, err = tx.ExecContext(ctx, incidentsQuery, id) - if err != nil { - return fmt.Errorf("failed to delete incidents: %w", err) - } - - // Delete service state - stateQuery := `DELETE FROM service_states WHERE service_id = ?` - _, err = tx.ExecContext(ctx, stateQuery, id) - if err != nil { - return fmt.Errorf("failed to delete service state: %w", err) - } - - // Delete the service - serviceQuery := `DELETE FROM services WHERE id = ?` - result, err := tx.ExecContext(ctx, serviceQuery, id) - if err != nil { - return fmt.Errorf("failed to delete service: %w", err) - } - - rowsAffected, err := result.RowsAffected() - if err != nil { - return fmt.Errorf("failed to get rows affected: %w", err) - } - - if rowsAffected == 0 { - return fmt.Errorf("service not found") - } - - // Commit transaction - if err := tx.Commit(); err != nil { - return fmt.Errorf("failed to commit transaction: %w", err) - } - - return nil -} +// func (o *Storage) DeleteService(ctx context.Context, id string) error { +// // Start transaction +// tx, err := o.db.BeginTx(ctx, nil) +// if err != nil { +// return fmt.Errorf("failed to begin transaction: %w", err) +// } +// defer tx.Rollback() + +// // Delete related incidents first +// incidentsQuery := `DELETE FROM incidents WHERE service_id = ?` +// _, err = tx.ExecContext(ctx, incidentsQuery, id) +// if err != nil { +// return fmt.Errorf("failed to delete incidents: %w", err) +// } + +// // Delete service state +// stateQuery := `DELETE FROM service_states WHERE service_id = ?` +// _, err = tx.ExecContext(ctx, stateQuery, id) +// if err != nil { +// return fmt.Errorf("failed to delete service state: %w", err) +// } + +// // Delete the service +// serviceQuery := `DELETE FROM services WHERE id = ?` +// result, err := tx.ExecContext(ctx, serviceQuery, id) +// if err != nil { +// return fmt.Errorf("failed to delete service: %w", err) +// } + +// rowsAffected, err := result.RowsAffected() +// if err != nil { +// return fmt.Errorf("failed to get rows affected: %w", err) +// } + +// if rowsAffected == 0 { +// return fmt.Errorf("service not found") +// } + +// // Commit transaction +// if err := tx.Commit(); err != nil { +// return fmt.Errorf("failed to commit transaction: %w", err) +// } + +// return nil +// } // Service state management methods // GetServiceState gets service state by service ID -func (o *Storage) GetServiceState(ctx context.Context, serviceID string) (*ServiceStateRecord, error) { - query := ` - SELECT id, service_id, status, last_check, next_check, last_error, - consecutive_fails, consecutive_success, total_checks, response_time_ns, - created_at, updated_at - FROM service_states - WHERE service_id = ? - ` - - var state ServiceStateRecord - err := o.db.QueryRowContext(ctx, query, serviceID).Scan( - &state.ID, - &state.ServiceID, - &state.Status, - &state.LastCheck, - &state.NextCheck, - &state.LastError, - &state.ConsecutiveFails, - &state.ConsecutiveSuccess, - &state.TotalChecks, - &state.ResponseTimeNS, - &state.CreatedAt, - &state.UpdatedAt, - ) - if err != nil { - if err == sql.ErrNoRows { - return nil, nil - } - return nil, fmt.Errorf("failed to get service state: %w", err) - } - - return &state, nil -} +// func (o *Storage) GetServiceState(ctx context.Context, serviceID string) (*ServiceStateRecord, error) { +// query := ` +// SELECT id, service_id, status, last_check, next_check, last_error, +// consecutive_fails, consecutive_success, total_checks, response_time_ns, +// created_at, updated_at +// FROM service_states +// WHERE service_id = ? +// ` + +// var state ServiceStateRecord +// err := o.db.QueryRowContext(ctx, query, serviceID).Scan( +// &state.ID, +// &state.ServiceID, +// &state.Status, +// &state.LastCheck, +// &state.NextCheck, +// &state.LastError, +// &state.ConsecutiveFails, +// &state.ConsecutiveSuccess, +// &state.TotalChecks, +// &state.ResponseTimeNS, +// &state.CreatedAt, +// &state.UpdatedAt, +// ) +// if err != nil { +// if err == sql.ErrNoRows { +// return nil, nil +// } +// return nil, fmt.Errorf("failed to get service state: %w", err) +// } + +// return &state, nil +// } // CreateServiceState creates a new service state -func (o *Storage) CreateServiceState(ctx context.Context, tx *sql.Tx, state *ServiceStateRecord) error { - query := ` - INSERT INTO service_states ( - id, service_id, status, last_check, next_check, last_error, - consecutive_fails, consecutive_success, total_checks, response_time_ns - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ` - - _, err := tx.ExecContext(ctx, query, - state.ID, - state.ServiceID, - state.Status, - state.LastCheck, - state.NextCheck, - state.LastError, - state.ConsecutiveFails, - state.ConsecutiveSuccess, - state.TotalChecks, - state.ResponseTimeNS, - ) - if err != nil { - return fmt.Errorf("failed to create service state: %w", err) - } - return nil -} +// func (o *Storage) CreateServiceState(ctx context.Context, tx *sql.Tx, state *ServiceStateRecord) error { +// query := ` +// INSERT INTO service_states ( +// id, service_id, status, last_check, next_check, last_error, +// consecutive_fails, consecutive_success, total_checks, response_time_ns +// ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +// ` + +// _, err := tx.ExecContext(ctx, query, +// state.ID, +// state.ServiceID, +// state.Status, +// state.LastCheck, +// state.NextCheck, +// state.LastError, +// state.ConsecutiveFails, +// state.ConsecutiveSuccess, +// state.TotalChecks, +// state.ResponseTimeNS, +// ) +// if err != nil { +// return fmt.Errorf("failed to create service state: %w", err) +// } +// return nil +// } // UpdateServiceState updates or creates service state func (o *Storage) UpdateServiceState(ctx context.Context, params *ServiceStateRecord) error { @@ -633,41 +629,41 @@ func (o *Storage) UpdateServiceState(ctx context.Context, params *ServiceStateRe } // GetAllServiceStates gets all service states -func (o *Storage) GetAllServiceStates(ctx context.Context) ([]*ServiceStateRecord, error) { - query := ` - SELECT id, service_id, status, last_check, next_check, last_error, - consecutive_fails, consecutive_success, total_checks, response_time_ns, - created_at, updated_at - FROM service_states - ORDER BY updated_at DESC - ` - - rows, err := o.db.QueryContext(ctx, query) - if err != nil { - return nil, fmt.Errorf("failed to query service states: %w", err) - } - defer rows.Close() - - states := []*ServiceStateRecord{} - for rows.Next() { - var state ServiceStateRecord - err := rows.Scan( - &state.ID, &state.ServiceID, &state.Status, &state.LastCheck, &state.NextCheck, - &state.LastError, &state.ConsecutiveFails, &state.ConsecutiveSuccess, - &state.TotalChecks, &state.ResponseTimeNS, &state.CreatedAt, &state.UpdatedAt, - ) - if err != nil { - return nil, fmt.Errorf("failed to scan service state: %w", err) - } - states = append(states, &state) - } - - if err = rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating service states: %w", err) - } - - return states, nil -} +// func (o *Storage) GetAllServiceStates(ctx context.Context) ([]*ServiceStateRecord, error) { +// query := ` +// SELECT id, service_id, status, last_check, next_check, last_error, +// consecutive_fails, consecutive_success, total_checks, response_time_ns, +// created_at, updated_at +// FROM service_states +// ORDER BY updated_at DESC +// ` + +// rows, err := o.db.QueryContext(ctx, query) +// if err != nil { +// return nil, fmt.Errorf("failed to query service states: %w", err) +// } +// defer rows.Close() + +// states := []*ServiceStateRecord{} +// for rows.Next() { +// var state ServiceStateRecord +// err := rows.Scan( +// &state.ID, &state.ServiceID, &state.Status, &state.LastCheck, &state.NextCheck, +// &state.LastError, &state.ConsecutiveFails, &state.ConsecutiveSuccess, +// &state.TotalChecks, &state.ResponseTimeNS, &state.CreatedAt, &state.UpdatedAt, +// ) +// if err != nil { +// return nil, fmt.Errorf("failed to scan service state: %w", err) +// } +// states = append(states, &state) +// } + +// if err = rows.Err(); err != nil { +// return nil, fmt.Errorf("error iterating service states: %w", err) +// } + +// return states, nil +// } // DeleteServiceState deletes service state by service ID func (o *Storage) DeleteServiceState(ctx context.Context, serviceID string) error { diff --git a/internal/store/repos/repo_agents/agents_gen.sql.go b/internal/store/repos/repo_agents/agents_gen.sql.go index e51613f..ef4730d 100644 --- a/internal/store/repos/repo_agents/agents_gen.sql.go +++ b/internal/store/repos/repo_agents/agents_gen.sql.go @@ -13,8 +13,8 @@ import ( ) const create = `-- name: Create :one -INSERT INTO agents (id, name, description, host, port, token_ct, token_nonce, token_hint, status, is_enabled, tags, config) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO agents (id, name, description, host, port, token_ct, token_nonce, token_hint, tags, config) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id, name, description, host, port, token_ct, token_nonce, token_hint, fingerprint, status, is_enabled, json(tags), json(config), json(system_info), last_seen_at, created_at, updated_at ` @@ -27,8 +27,6 @@ type CreateParams struct { TokenCt []byte `db:"token_ct" json:"token_ct"` TokenNonce []byte `db:"token_nonce" json:"token_nonce"` TokenHint string `db:"token_hint" json:"token_hint"` - Status string `db:"status" json:"status"` - IsEnabled bool `db:"is_enabled" json:"is_enabled"` Tags storecmn.JSONField `db:"tags" json:"tags"` Config storecmn.JSONField `db:"config" json:"config"` } @@ -43,8 +41,6 @@ func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.Agent, arg.TokenCt, arg.TokenNonce, arg.TokenHint, - arg.Status, - arg.IsEnabled, arg.Tags, arg.Config, ) @@ -80,51 +76,6 @@ func (q *Queries) Delete(ctx context.Context, id string) error { return err } -const getAll = `-- name: GetAll :many -SELECT id, name, description, host, port, token_ct, token_nonce, token_hint, fingerprint, status, is_enabled, json(tags), json(config), json(system_info), last_seen_at, created_at, updated_at FROM agents -` - -func (q *Queries) GetAll(ctx context.Context) ([]*models.Agent, error) { - rows, err := q.db.QueryContext(ctx, getAll) - if err != nil { - return nil, err - } - defer rows.Close() - items := []*models.Agent{} - for rows.Next() { - var i models.Agent - if err := rows.Scan( - &i.ID, - &i.Name, - &i.Description, - &i.Host, - &i.Port, - &i.TokenCt, - &i.TokenNonce, - &i.TokenHint, - &i.Fingerprint, - &i.Status, - &i.IsEnabled, - &i.Tags, - &i.Config, - &i.SystemInfo, - &i.LastSeenAt, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, &i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const getByID = `-- name: GetByID :one SELECT id, name, description, host, port, token_ct, token_nonce, token_hint, fingerprint, status, is_enabled, json(tags), json(config), json(system_info), last_seen_at, created_at, updated_at FROM agents WHERE id=? LIMIT 1 ` diff --git a/internal/store/repos/repo_agents/querier.go b/internal/store/repos/repo_agents/querier.go index 969251d..4e753a1 100644 --- a/internal/store/repos/repo_agents/querier.go +++ b/internal/store/repos/repo_agents/querier.go @@ -13,7 +13,6 @@ import ( type Querier interface { Create(ctx context.Context, arg CreateParams) (*models.Agent, error) Delete(ctx context.Context, id string) error - GetAll(ctx context.Context) ([]*models.Agent, error) GetByID(ctx context.Context, id string) (*models.Agent, error) } diff --git a/internal/store/repos/repo_agents/update.go b/internal/store/repos/repo_agents/update.go index 7535639..8da3a6c 100644 --- a/internal/store/repos/repo_agents/update.go +++ b/internal/store/repos/repo_agents/update.go @@ -41,7 +41,7 @@ func (s *CustomQueries) Update(ctx context.Context, id string, params UpdateRequ ub := sqlbuilder.NewUpdateBuilder() ub.Update(TableNameAgents.String()). Where(ub.Equal("id", id)). - Set(ub.Assign("updated_at", time.Now())) + Set(ub.Assign(ColumnNameAgentsUpdatedAt.String(), time.Now())) values, err := utils.StructToMap(params.Agent, "json") if err != nil { diff --git a/internal/store/repos/repo_incidents/incidents.sql.go b/internal/store/repos/repo_incidents/incidents.sql.go index 2ddc7bf..3159cdf 100644 --- a/internal/store/repos/repo_incidents/incidents.sql.go +++ b/internal/store/repos/repo_incidents/incidents.sql.go @@ -7,6 +7,7 @@ package repo_incidents import ( "context" + "time" ) const deleteByServiceID = `-- name: DeleteByServiceID :exec @@ -17,3 +18,35 @@ func (q *Queries) DeleteByServiceID(ctx context.Context, serviceID string) error _, err := q.db.ExecContext(ctx, deleteByServiceID, serviceID) return err } + +const statsByServiceID = `-- name: StatsByServiceID :one +SELECT + COUNT(*) AS total_incidents, + SUM(duration_ns) AS total_downtime, + AVG(duration_ns) AS avg_downtime, + SUM(CASE WHEN resolved THEN 1 ELSE 0 END) AS resolved_incidents, + SUM(CASE WHEN NOT resolved THEN 1 ELSE 0 END) AS unresolved_incidents +FROM incidents +WHERE service_id=? AND start_time >= ? +` + +type StatsByServiceIDRow struct { + TotalIncidents int64 `db:"total_incidents" json:"total_incidents"` + TotalDowntime *float64 `db:"total_downtime" json:"total_downtime"` + AvgDowntime *float64 `db:"avg_downtime" json:"avg_downtime"` + ResolvedIncidents *float64 `db:"resolved_incidents" json:"resolved_incidents"` + UnresolvedIncidents *float64 `db:"unresolved_incidents" json:"unresolved_incidents"` +} + +func (q *Queries) StatsByServiceID(ctx context.Context, serviceID string, startTime time.Time) (*StatsByServiceIDRow, error) { + row := q.db.QueryRowContext(ctx, statsByServiceID, serviceID, startTime) + var i StatsByServiceIDRow + err := row.Scan( + &i.TotalIncidents, + &i.TotalDowntime, + &i.AvgDowntime, + &i.ResolvedIncidents, + &i.UnresolvedIncidents, + ) + return &i, err +} diff --git a/internal/store/repos/repo_incidents/incidents_gen.sql.go b/internal/store/repos/repo_incidents/incidents_gen.sql.go index 30bb715..4636799 100644 --- a/internal/store/repos/repo_incidents/incidents_gen.sql.go +++ b/internal/store/repos/repo_incidents/incidents_gen.sql.go @@ -62,43 +62,6 @@ func (q *Queries) Delete(ctx context.Context, id string) error { return err } -const getAll = `-- name: GetAll :many -SELECT id, service_id, start_time, end_time, error, duration_ns, resolved, created_at, updated_at FROM incidents -` - -func (q *Queries) GetAll(ctx context.Context) ([]*models.Incident, error) { - rows, err := q.db.QueryContext(ctx, getAll) - if err != nil { - return nil, err - } - defer rows.Close() - items := []*models.Incident{} - for rows.Next() { - var i models.Incident - if err := rows.Scan( - &i.ID, - &i.ServiceID, - &i.StartTime, - &i.EndTime, - &i.Error, - &i.DurationNs, - &i.Resolved, - &i.CreatedAt, - &i.UpdatedAt, - ); err != nil { - return nil, err - } - items = append(items, &i) - } - if err := rows.Close(); err != nil { - return nil, err - } - if err := rows.Err(); err != nil { - return nil, err - } - return items, nil -} - const getByID = `-- name: GetByID :one SELECT id, service_id, start_time, end_time, error, duration_ns, resolved, created_at, updated_at FROM incidents WHERE id=? LIMIT 1 ` diff --git a/internal/store/repos/repo_incidents/querier.go b/internal/store/repos/repo_incidents/querier.go index 19c4e9f..6ead2c3 100644 --- a/internal/store/repos/repo_incidents/querier.go +++ b/internal/store/repos/repo_incidents/querier.go @@ -6,6 +6,7 @@ package repo_incidents import ( "context" + "time" "github.com/sxwebdev/sentinel/internal/models" ) @@ -14,8 +15,8 @@ type Querier interface { Create(ctx context.Context, arg CreateParams) (*models.Incident, error) Delete(ctx context.Context, id string) error DeleteByServiceID(ctx context.Context, serviceID string) error - GetAll(ctx context.Context) ([]*models.Incident, error) GetByID(ctx context.Context, id string) (*models.Incident, error) + StatsByServiceID(ctx context.Context, serviceID string, startTime time.Time) (*StatsByServiceIDRow, error) } var _ Querier = (*Queries)(nil) diff --git a/internal/store/repos/repo_incidents/types.go b/internal/store/repos/repo_incidents/types.go new file mode 100644 index 0000000..cfa81e3 --- /dev/null +++ b/internal/store/repos/repo_incidents/types.go @@ -0,0 +1,41 @@ +package repo_incidents + +import "time" + +type StatsByServiceID struct { + TotalIncidents int64 + TotalDowntime time.Duration + AvgDowntime time.Duration + ResolvedIncidents int64 + UnresolvedIncidents int64 +} + +func (s StatsByServiceIDRow) ToDomain() *StatsByServiceID { + var totalDowntime time.Duration + if s.TotalDowntime != nil { + totalDowntime = time.Duration(*s.TotalDowntime) + } + + var avgDowntime time.Duration + if s.AvgDowntime != nil { + avgDowntime = time.Duration(*s.AvgDowntime) + } + + var resolvedIncidents int64 + if s.ResolvedIncidents != nil { + resolvedIncidents = int64(*s.ResolvedIncidents) + } + + var unresolvedIncidents int64 + if s.UnresolvedIncidents != nil { + unresolvedIncidents = int64(*s.UnresolvedIncidents) + } + + return &StatsByServiceID{ + TotalIncidents: s.TotalIncidents, + TotalDowntime: totalDowntime, + AvgDowntime: avgDowntime, + ResolvedIncidents: resolvedIncidents, + UnresolvedIncidents: unresolvedIncidents, + } +} diff --git a/internal/store/repos/repo_service_states/custom.go b/internal/store/repos/repo_service_states/custom.go new file mode 100644 index 0000000..8c6e304 --- /dev/null +++ b/internal/store/repos/repo_service_states/custom.go @@ -0,0 +1,34 @@ +package repo_service_states + +import ( + "context" + "database/sql" + + "github.com/sxwebdev/sentinel/internal/models" +) + +type ICustomQuerier interface { + Querier + Update(ctx context.Context, id string, params UpdateRequest) (*models.ServiceState, error) +} + +type CustomQueries struct { + *Queries + db DBTX +} + +func NewCustom(db DBTX) *CustomQueries { + return &CustomQueries{ + Queries: New(db), + db: db, + } +} + +func (s *CustomQueries) WithTx(tx *sql.Tx) *CustomQueries { + return &CustomQueries{ + Queries: New(tx), + db: tx, + } +} + +var _ ICustomQuerier = (*CustomQueries)(nil) diff --git a/internal/store/repos/repo_service_states/querier.go b/internal/store/repos/repo_service_states/querier.go index 3c9c4c9..f834c4e 100644 --- a/internal/store/repos/repo_service_states/querier.go +++ b/internal/store/repos/repo_service_states/querier.go @@ -14,7 +14,9 @@ type Querier interface { Create(ctx context.Context, arg CreateParams) (*models.ServiceState, error) Delete(ctx context.Context, id string) error DeleteByServiceID(ctx context.Context, serviceID string) error + GetAll(ctx context.Context) ([]*models.ServiceState, error) GetByID(ctx context.Context, id string) (*models.ServiceState, error) + GetByServiceID(ctx context.Context, serviceID string) (*models.ServiceState, error) } var _ Querier = (*Queries)(nil) diff --git a/internal/store/repos/repo_service_states/service_states.sql.go b/internal/store/repos/repo_service_states/service_states.sql.go index 43a8cde..0b9771f 100644 --- a/internal/store/repos/repo_service_states/service_states.sql.go +++ b/internal/store/repos/repo_service_states/service_states.sql.go @@ -7,6 +7,8 @@ package repo_service_states import ( "context" + + "github.com/sxwebdev/sentinel/internal/models" ) const deleteByServiceID = `-- name: DeleteByServiceID :exec @@ -17,3 +19,27 @@ func (q *Queries) DeleteByServiceID(ctx context.Context, serviceID string) error _, err := q.db.ExecContext(ctx, deleteByServiceID, serviceID) return err } + +const getByServiceID = `-- name: GetByServiceID :one +SELECT id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time_ns, created_at, updated_at FROM service_states WHERE service_id=? LIMIT 1 +` + +func (q *Queries) GetByServiceID(ctx context.Context, serviceID string) (*models.ServiceState, error) { + row := q.db.QueryRowContext(ctx, getByServiceID, serviceID) + var i models.ServiceState + err := row.Scan( + &i.ID, + &i.ServiceID, + &i.Status, + &i.LastCheck, + &i.NextCheck, + &i.LastError, + &i.ConsecutiveFails, + &i.ConsecutiveSuccess, + &i.TotalChecks, + &i.ResponseTimeNs, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} diff --git a/internal/store/repos/repo_service_states/service_states_gen.sql.go b/internal/store/repos/repo_service_states/service_states_gen.sql.go index bdd0f84..e7b5ac6 100644 --- a/internal/store/repos/repo_service_states/service_states_gen.sql.go +++ b/internal/store/repos/repo_service_states/service_states_gen.sql.go @@ -71,6 +71,46 @@ func (q *Queries) Delete(ctx context.Context, id string) error { return err } +const getAll = `-- name: GetAll :many +SELECT id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time_ns, created_at, updated_at FROM service_states +` + +func (q *Queries) GetAll(ctx context.Context) ([]*models.ServiceState, error) { + rows, err := q.db.QueryContext(ctx, getAll) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*models.ServiceState{} + for rows.Next() { + var i models.ServiceState + if err := rows.Scan( + &i.ID, + &i.ServiceID, + &i.Status, + &i.LastCheck, + &i.NextCheck, + &i.LastError, + &i.ConsecutiveFails, + &i.ConsecutiveSuccess, + &i.TotalChecks, + &i.ResponseTimeNs, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getByID = `-- name: GetByID :one SELECT id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time_ns, created_at, updated_at FROM service_states WHERE id=? LIMIT 1 ` diff --git a/internal/store/repos/repo_service_states/update.go b/internal/store/repos/repo_service_states/update.go new file mode 100644 index 0000000..e269674 --- /dev/null +++ b/internal/store/repos/repo_service_states/update.go @@ -0,0 +1,75 @@ +package repo_service_states + +import ( + "context" + "fmt" + "slices" + "time" + + "github.com/huandu/go-sqlbuilder" + "github.com/samber/lo" + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/storecmn" + "github.com/tkcrm/modules/pkg/db/dbutils" + "github.com/tkcrm/modules/pkg/utils" +) + +var availableUpdateColumns = lo.Filter(ServiceStatesColumnNames(), func(item ColumnName, _ int) bool { + return !slices.Contains([]ColumnName{ + ColumnNameServiceStatesId, + ColumnNameServiceStatesCreatedAt, + ColumnNameServiceStatesUpdatedAt, + }, item) +}) + +type UpdateRequest struct { + models.ServiceState + FieldMask dbutils.FieldMask[ColumnName] +} + +func (s *CustomQueries) Update(ctx context.Context, id string, params UpdateRequest) (*models.ServiceState, error) { + if id == "" { + return nil, storecmn.ErrEmptyID + } + + for _, path := range params.FieldMask.Items() { + if !slices.Contains(availableUpdateColumns, path) { + return nil, fmt.Errorf("unavailable field %s for this method", path) + } + } + + ub := sqlbuilder.NewUpdateBuilder() + ub.Update(TableNameServiceStates.String()). + Where(ub.Equal("id", id)). + Set(ub.Assign(ColumnNameServiceStatesUpdatedAt.String(), time.Now())) + + values, err := utils.StructToMap(params.ServiceState, "json") + if err != nil { + return nil, err + } + + for _, path := range params.FieldMask { + idx := slices.IndexFunc(ServiceStatesColumnNames(), func(i ColumnName) bool { + return path == i + }) + + if idx == -1 { + return nil, fmt.Errorf("unavailable path: %s", path) + } + + value, ok := values[path.String()] + if !ok { + return nil, fmt.Errorf("value not found for path: %s", path) + } + + ub.SetMore(ub.Assign(path.String(), value)) + } + + // execute query + sql, args := ub.Build() + if _, err := s.db.ExecContext(ctx, sql, args...); err != nil { + return nil, err + } + + return s.GetByID(ctx, id) +} diff --git a/internal/store/repos/repos.go b/internal/store/repos/repos.go index 1a27b34..8bdb9ab 100644 --- a/internal/store/repos/repos.go +++ b/internal/store/repos/repos.go @@ -12,7 +12,7 @@ import ( type Repos struct { agents *repo_agents.CustomQueries services *repo_services.CustomQueries - serviceStates *repo_service_states.Queries + serviceStates *repo_service_states.CustomQueries incidents *repo_incidents.Queries } @@ -20,7 +20,7 @@ func New(sqlite *sql.DB) *Repos { return &Repos{ agents: repo_agents.NewCustom(sqlite), services: repo_services.NewCustom(sqlite), - serviceStates: repo_service_states.New(sqlite), + serviceStates: repo_service_states.NewCustom(sqlite), incidents: repo_incidents.New(sqlite), } } @@ -48,7 +48,7 @@ func (s *Repos) Services(opts ...Option) repo_services.ICustomQuerier { } // ServiceStates returns repo for service states -func (s *Repos) ServiceStates(opts ...Option) repo_service_states.Querier { +func (s *Repos) ServiceStates(opts ...Option) repo_service_states.ICustomQuerier { options := parseOptions(opts...) if options.Tx != nil { diff --git a/internal/web/dto.go b/internal/web/dto.go index 8372fb4..981bc35 100644 --- a/internal/web/dto.go +++ b/internal/web/dto.go @@ -18,7 +18,7 @@ type DashboardStats struct { Protocols map[models.ServiceProtocolType]int `json:"protocols"` ActiveIncidents int `json:"active_incidents" example:"2"` AvgResponseTime int64 `json:"avg_response_time" example:"150"` - TotalChecks int `json:"total_checks" example:"1000"` + TotalChecks int64 `json:"total_checks" example:"1000"` UptimePercentage float64 `json:"uptime_percentage" example:"95.5"` LastCheckTime *time.Time `json:"last_check_time"` ChecksPerMinute int `json:"checks_per_minute" example:"60"` diff --git a/internal/web/handlers.go b/internal/web/handlers.go index b3506bd..2f9e3ab 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -476,11 +476,11 @@ func (s *Server) handleAPIServiceIncidents(c *fiber.Ctx) error { // @Tags statistics // @Accept json // @Produce json -// @Param id path string true "Service ID" -// @Param days query int false "Number of days (default 30)" -// @Success 200 {object} storage.ServiceStats "Service statistics" -// @Failure 400 {object} ErrorResponse "Bad request" -// @Failure 500 {object} ErrorResponse "Internal server error" +// @Param id path string true "Service ID" +// @Param days query int false "Number of days (default 30)" +// @Success 200 {object} service.Stats "Service statistics" +// @Failure 400 {object} ErrorResponse "Bad request" +// @Failure 500 {object} ErrorResponse "Internal server error" // @Router /services/{id}/stats [get] func (s *Server) handleAPIServiceStats(c *fiber.Ctx) error { serviceID := c.Params("id") @@ -505,7 +505,7 @@ func (s *Server) handleAPIServiceStats(c *fiber.Ctx) error { } since := time.Now().AddDate(0, 0, -days) - stats, err := s.monitorService.GetServiceStats(c.Context(), serviceID, since) + stats, err := s.baseServices.Services().Stats(c.Context(), serviceID, since) if err != nil { return newErrorResponse(c, fiber.StatusInternalServerError, err) } diff --git a/internal/web/helpers.go b/internal/web/helpers.go index 4a6503b..3f5126d 100644 --- a/internal/web/helpers.go +++ b/internal/web/helpers.go @@ -68,13 +68,13 @@ func (s *Server) getDashboardStats(ctx context.Context) (*DashboardStats, error) } // Get all service states - serviceStates, err := s.storage.GetAllServiceStates(ctx) + serviceStates, err := s.baseServices.ServiceStates().GetAll(ctx) if err != nil { return nil, err } // Create a map for quick lookup of service states by service ID - stateMap := make(map[string]*storage.ServiceStateRecord) + stateMap := make(map[string]*models.ServiceState) for _, state := range serviceStates { stateMap[state.ServiceID] = state } @@ -95,7 +95,7 @@ func (s *Server) getDashboardStats(ctx context.Context) (*DashboardStats, error) } // Calculate statistics - totalChecks := 0 + var totalChecks int64 upServices := 0 var lastCheckTime *time.Time var totalResponseTimeMs int64 @@ -112,18 +112,18 @@ func (s *Server) getDashboardStats(ctx context.Context) (*DashboardStats, error) // Count by status if serviceState != nil { switch serviceState.Status { - case storage.StatusUp: + case models.StatusUp: stats.ServicesUp++ upServices++ - case storage.StatusDown: + case models.StatusDown: stats.ServicesDown++ - case storage.StatusUnknown: + case models.StatusUnknown: stats.ServicesUnknown++ } // Add response time to total (only from services that have response time data) - if serviceState.ResponseTimeNS != nil && *serviceState.ResponseTimeNS > 0 { - totalResponseTimeMs += *serviceState.ResponseTimeNS / 1000000 // Convert to milliseconds + if serviceState.ResponseTimeNs != nil && *serviceState.ResponseTimeNs > 0 { + totalResponseTimeMs += *serviceState.ResponseTimeNs / 1000000 // Convert to milliseconds responseTimeCount++ } totalChecks += serviceState.TotalChecks diff --git a/pgxgen.yaml b/pgxgen.yaml index 419330b..0559e96 100644 --- a/pgxgen.yaml +++ b/pgxgen.yaml @@ -17,13 +17,18 @@ sqlc: auto_remove_generated_files: true exclude_table_name_from_methods: true tables: - # Services - services: - output_dir: sql/queries/services + # agents + agents: + output_dir: sql/queries/agents primary_column: id methods: create: skip_columns: + - fingerprint + - is_enabled + - status + - system_info + - last_seen_at - created_at - updated_at returning: "*" @@ -33,9 +38,9 @@ sqlc: get: name: GetByID - # service_states - service_states: - output_dir: sql/queries/service_states + # Services + services: + output_dir: sql/queries/services primary_column: id methods: create: @@ -49,9 +54,9 @@ sqlc: get: name: GetByID - # incidents - incidents: - output_dir: sql/queries/incidents + # service_states + service_states: + output_dir: sql/queries/service_states primary_column: id methods: create: @@ -67,17 +72,13 @@ sqlc: find: name: GetAll - # agents - agents: - output_dir: sql/queries/agents + # incidents + incidents: + output_dir: sql/queries/incidents primary_column: id methods: create: skip_columns: - - fingerprint - - is_active - - system_info - - last_seen_at - created_at - updated_at returning: "*" @@ -86,8 +87,6 @@ sqlc: delete: get: name: GetByID - find: - name: GetAll constants: tables: diff --git a/sql/queries/agents/agents_gen.sql b/sql/queries/agents/agents_gen.sql index e526eba..39c5bd2 100755 --- a/sql/queries/agents/agents_gen.sql +++ b/sql/queries/agents/agents_gen.sql @@ -1,14 +1,11 @@ -- name: Create :one -INSERT INTO agents (id, name, description, host, port, token_ct, token_nonce, token_hint, status, is_enabled, tags, config) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) +INSERT INTO agents (id, name, description, host, port, token_ct, token_nonce, token_hint, tags, config) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; -- name: Delete :exec DELETE FROM agents WHERE id=?; --- name: GetAll :many -SELECT * FROM agents; - -- name: GetByID :one SELECT * FROM agents WHERE id=? LIMIT 1; diff --git a/sql/queries/incidents/incidents.sql b/sql/queries/incidents/incidents.sql index 1710e6d..7b3e864 100755 --- a/sql/queries/incidents/incidents.sql +++ b/sql/queries/incidents/incidents.sql @@ -1,2 +1,12 @@ -- name: DeleteByServiceID :exec DELETE FROM incidents WHERE service_id=?; + +-- name: StatsByServiceID :one +SELECT + COUNT(*) AS total_incidents, + SUM(duration_ns) AS total_downtime, + AVG(duration_ns) AS avg_downtime, + SUM(CASE WHEN resolved THEN 1 ELSE 0 END) AS resolved_incidents, + SUM(CASE WHEN NOT resolved THEN 1 ELSE 0 END) AS unresolved_incidents +FROM incidents +WHERE service_id=? AND start_time >= ?; diff --git a/sql/queries/incidents/incidents_gen.sql b/sql/queries/incidents/incidents_gen.sql index 6d225a2..53953cb 100755 --- a/sql/queries/incidents/incidents_gen.sql +++ b/sql/queries/incidents/incidents_gen.sql @@ -6,9 +6,6 @@ INSERT INTO incidents (id, service_id, start_time, end_time, error, duration_ns, -- name: Delete :exec DELETE FROM incidents WHERE id=?; --- name: GetAll :many -SELECT * FROM incidents; - -- name: GetByID :one SELECT * FROM incidents WHERE id=? LIMIT 1; diff --git a/sql/queries/service_states/service_states.sql b/sql/queries/service_states/service_states.sql index 0c7358a..de210ef 100755 --- a/sql/queries/service_states/service_states.sql +++ b/sql/queries/service_states/service_states.sql @@ -1,2 +1,5 @@ +-- name: GetByServiceID :one +SELECT * FROM service_states WHERE service_id=? LIMIT 1; + -- name: DeleteByServiceID :exec DELETE FROM service_states WHERE service_id=?; diff --git a/sql/queries/service_states/service_states_gen.sql b/sql/queries/service_states/service_states_gen.sql index be774d5..68b4ae4 100755 --- a/sql/queries/service_states/service_states_gen.sql +++ b/sql/queries/service_states/service_states_gen.sql @@ -6,6 +6,9 @@ INSERT INTO service_states (id, service_id, status, last_check, next_check, last -- name: Delete :exec DELETE FROM service_states WHERE id=?; +-- name: GetAll :many +SELECT * FROM service_states; + -- name: GetByID :one SELECT * FROM service_states WHERE id=? LIMIT 1; From d06e84d161ce7cb330756598928dfc010f398a77 Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Fri, 19 Sep 2025 16:03:42 +0300 Subject: [PATCH 10/71] Refactor incident and service state handling - Updated incident creation to remove unnecessary parameters and simplify the SQL query. - Changed incident duration and service state response time from nanoseconds to milliseconds and renamed the corresponding columns. - Added new methods to retrieve unresolved incidents and resolve incidents by ID. - Enhanced service methods to include fetching all tags and their counts. - Updated API endpoints to reflect changes in the incident and service state models. - Generated new TypeScript API definitions for dashboard, incidents, services, and statistics. --- cmd/sentinel/hub.go | 2 +- docs/docsv1/docs.go | 44 - docs/docsv1/swagger.json | 44 - docs/docsv1/swagger.yaml | 29 - frontend/orval.config.ts | 2 +- frontend/package.json | 2 +- frontend/src/pages/dashboard/dashboard.tsx | 2 +- .../src/pages/dashboard/incidents-stats.tsx | 2 +- .../dashboard/store/useDashboardStore.ts | 2 +- .../pages/dashboard/store/useServerStore.ts | 2 +- .../service/components/serviceOverview.tsx | 1 - .../pages/service/components/serviceStats.tsx | 2 +- .../service/hooks/useCreateFromService.ts | 2 +- .../pages/service/hooks/useServiceCreate.ts | 2 +- .../pages/service/hooks/useServiceDetail.ts | 35 +- .../pages/service/hooks/useServiceTable.tsx | 4 +- .../pages/service/hooks/useServiceUpdate.ts | 2 +- frontend/src/pages/service/serviceDetail.tsx | 12 - .../service/store/useServiceDeteilStore.ts | 4 - .../service/store/useServiceTableStore.ts | 7 +- frontend/src/routes/incidents.tsx | 2 +- .../api/{ => gen}/dashboard/dashboard.ts | 4 +- .../api/{ => gen}/incidents/incidents.ts | 19 +- .../src/shared/api/{ => gen}/server/server.ts | 4 +- .../shared/api/{ => gen}/services/services.ts | 4 +- .../api/{ => gen}/statistics/statistics.ts | 7 +- .../src/shared/api/{ => gen}/tags/tags.ts | 4 +- frontend/src/shared/api/info/info.ts | 27 - frontend/src/shared/api/upgrade/upgrade.ts | 27 - frontend/src/shared/utils/duration.ts | 10 +- internal/models/models_gen.go | 20 +- internal/monitor/monitor.go | 101 +-- internal/notifier/shoutrrr.go | 11 +- internal/services/incidents/methods.go | 64 ++ internal/services/service/check.go | 23 + internal/services/service/stats.go | 10 +- internal/services/service/tags.go | 13 + internal/storage/incidents.go | 114 +-- internal/storage/models.go | 58 -- internal/storage/services.go | 797 ------------------ .../repos/repo_incidents/constants_gen.go | 20 +- .../repos/repo_incidents/incidents.sql.go | 60 +- .../repos/repo_incidents/incidents_gen.sql.go | 35 +- .../store/repos/repo_incidents/querier.go | 4 +- internal/store/repos/repo_incidents/types.go | 14 +- .../repo_service_states/constants_gen.go | 4 +- .../repo_service_states/service_states.sql.go | 4 +- .../service_states_gen.sql.go | 18 +- internal/store/repos/repo_services/custom.go | 2 + internal/store/repos/repo_services/find.go | 2 +- internal/store/repos/repo_services/get.go | 2 +- internal/store/repos/repo_services/tags.go | 68 ++ internal/store/repos/repo_services/types.go | 6 +- internal/web/handlers.go | 47 +- internal/web/helpers.go | 4 +- internal/web/tags.go | 4 +- pgxgen.yaml | 6 +- sql/migrations/2_agents.up.sql | 8 + sql/queries/incidents/incidents.sql | 18 +- sql/queries/incidents/incidents_gen.sql | 4 +- .../service_states/service_states_gen.sql | 2 +- 61 files changed, 484 insertions(+), 1369 deletions(-) rename frontend/src/shared/api/{ => gen}/dashboard/dashboard.ts (84%) rename frontend/src/shared/api/{ => gen}/incidents/incidents.ts (81%) rename frontend/src/shared/api/{ => gen}/server/server.ts (94%) rename frontend/src/shared/api/{ => gen}/services/services.ts (97%) rename frontend/src/shared/api/{ => gen}/statistics/statistics.ts (83%) rename frontend/src/shared/api/{ => gen}/tags/tags.ts (89%) delete mode 100644 frontend/src/shared/api/info/info.ts delete mode 100644 frontend/src/shared/api/upgrade/upgrade.ts create mode 100644 internal/services/incidents/methods.go create mode 100644 internal/services/service/check.go create mode 100644 internal/services/service/tags.go delete mode 100644 internal/storage/services.go create mode 100644 internal/store/repos/repo_services/tags.go diff --git a/cmd/sentinel/hub.go b/cmd/sentinel/hub.go index 733ed83..1c03ac0 100644 --- a/cmd/sentinel/hub.go +++ b/cmd/sentinel/hub.go @@ -116,7 +116,7 @@ func hubStartCMD() *cli.Command { baseServices := baseservices.New(st, rc) // Create monitor service - monitorService := monitor.NewMonitorService(st, storage, conf, notif, rc, baseServices) + monitorService := monitor.NewMonitorService(l, storage, conf, notif, rc, baseServices) // Initialize scheduler sched := scheduler.New(l, monitorService, rc, baseServices) diff --git a/docs/docsv1/docs.go b/docs/docsv1/docs.go index 4acf5b7..f9d99f1 100644 --- a/docs/docsv1/docs.go +++ b/docs/docsv1/docs.go @@ -688,50 +688,6 @@ const docTemplate = `{ } } }, - "/services/{id}/resolve": { - "post": { - "description": "Forcefully resolves all active incidents for a service", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "incidents" - ], - "summary": "Resolve service incidents", - "parameters": [ - { - "type": "string", - "description": "Service ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Incidents resolved successfully", - "schema": { - "$ref": "#/definitions/web.SuccessResponse" - } - }, - "400": { - "description": "Bad request", - "schema": { - "$ref": "#/definitions/web.ErrorResponse" - } - }, - "500": { - "description": "Internal server error", - "schema": { - "$ref": "#/definitions/web.ErrorResponse" - } - } - } - } - }, "/services/{id}/stats": { "get": { "description": "Returns service statistics for the specified period", diff --git a/docs/docsv1/swagger.json b/docs/docsv1/swagger.json index 528047d..57fd85b 100644 --- a/docs/docsv1/swagger.json +++ b/docs/docsv1/swagger.json @@ -681,50 +681,6 @@ } } }, - "/services/{id}/resolve": { - "post": { - "description": "Forcefully resolves all active incidents for a service", - "consumes": [ - "application/json" - ], - "produces": [ - "application/json" - ], - "tags": [ - "incidents" - ], - "summary": "Resolve service incidents", - "parameters": [ - { - "type": "string", - "description": "Service ID", - "name": "id", - "in": "path", - "required": true - } - ], - "responses": { - "200": { - "description": "Incidents resolved successfully", - "schema": { - "$ref": "#/definitions/web.SuccessResponse" - } - }, - "400": { - "description": "Bad request", - "schema": { - "$ref": "#/definitions/web.ErrorResponse" - } - }, - "500": { - "description": "Internal server error", - "schema": { - "$ref": "#/definitions/web.ErrorResponse" - } - } - } - } - }, "/services/{id}/stats": { "get": { "description": "Returns service statistics for the specified period", diff --git a/docs/docsv1/swagger.yaml b/docs/docsv1/swagger.yaml index f77b6e6..990d761 100644 --- a/docs/docsv1/swagger.yaml +++ b/docs/docsv1/swagger.yaml @@ -828,35 +828,6 @@ paths: summary: Delete incident tags: - incidents - /services/{id}/resolve: - post: - consumes: - - application/json - description: Forcefully resolves all active incidents for a service - parameters: - - description: Service ID - in: path - name: id - required: true - type: string - produces: - - application/json - responses: - "200": - description: Incidents resolved successfully - schema: - $ref: '#/definitions/web.SuccessResponse' - "400": - description: Bad request - schema: - $ref: '#/definitions/web.ErrorResponse' - "500": - description: Internal server error - schema: - $ref: '#/definitions/web.ErrorResponse' - summary: Resolve service incidents - tags: - - incidents /services/{id}/stats: get: consumes: diff --git a/frontend/orval.config.ts b/frontend/orval.config.ts index 2db046e..e6412b1 100644 --- a/frontend/orval.config.ts +++ b/frontend/orval.config.ts @@ -5,7 +5,7 @@ export default defineConfig({ input: "../docs/docsv1/swagger.json", // путь к Swagger JSON output: { mode: "tags-split", // или 'split' / 'single' - target: "./src/shared/api/generated.ts", // куда будет сгенерировано + target: "./src/shared/api/gen", // куда будет сгенерировано schemas: "./src/shared/types/model", // типы client: "axios", override: { diff --git a/frontend/package.json b/frontend/package.json index 326a6da..42b0998 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,7 @@ "preview": "vite preview", "format": "prettier --config .prettierrc --write .", "format-check": "prettier --config .prettierrc --check .", - "genapi": "rm src/shared/types/model/* && npx orval" + "genapi": "rm -rf src/shared/types/model/* && npx orval" }, "dependencies": { "@tanstack/react-router": "^1.131.36", diff --git a/frontend/src/pages/dashboard/dashboard.tsx b/frontend/src/pages/dashboard/dashboard.tsx index 3794b7b..1797920 100644 --- a/frontend/src/pages/dashboard/dashboard.tsx +++ b/frontend/src/pages/dashboard/dashboard.tsx @@ -9,7 +9,7 @@ import { import { useDashboardLogic } from "./hooks/useDashboardLogic"; import { InfoCardStats } from "@/entities/infoStatsCard/infoCardStats"; import { Loader } from "@/entities/loader/loader"; -import type { GetDashboardStatsResult } from "@/shared/api/dashboard/dashboard"; +import type { GetDashboardStatsResult } from "@/shared/api/gen/dashboard/dashboard"; import { getProtocolDisplayName } from "@/shared/lib/getProtocolDisplayName"; import { ServiceTable } from "../service/serviceTable"; import { useWsLogic } from "./hooks/useWsLogic"; diff --git a/frontend/src/pages/dashboard/incidents-stats.tsx b/frontend/src/pages/dashboard/incidents-stats.tsx index 22242a0..bcad302 100644 --- a/frontend/src/pages/dashboard/incidents-stats.tsx +++ b/frontend/src/pages/dashboard/incidents-stats.tsx @@ -17,7 +17,7 @@ import { SelectTrigger, SelectValue, } from "@/shared/components/ui/select"; -import { getIncidents } from "@/shared/api/incidents/incidents"; +import { getIncidents } from "@/shared/api/gen/incidents/incidents"; import { useEffect, useMemo } from "react"; import { toast } from "sonner"; diff --git a/frontend/src/pages/dashboard/store/useDashboardStore.ts b/frontend/src/pages/dashboard/store/useDashboardStore.ts index eda93c7..c7cc495 100644 --- a/frontend/src/pages/dashboard/store/useDashboardStore.ts +++ b/frontend/src/pages/dashboard/store/useDashboardStore.ts @@ -2,7 +2,7 @@ import { create } from "zustand"; import { type GetDashboardStatsResult, getDashboard, -} from "@/shared/api/dashboard/dashboard"; +} from "@/shared/api/gen/dashboard/dashboard"; interface DashboardStore { dashboardInfo: GetDashboardStatsResult | null; diff --git a/frontend/src/pages/dashboard/store/useServerStore.ts b/frontend/src/pages/dashboard/store/useServerStore.ts index a2e1d96..64af4a3 100644 --- a/frontend/src/pages/dashboard/store/useServerStore.ts +++ b/frontend/src/pages/dashboard/store/useServerStore.ts @@ -1,6 +1,6 @@ import { create } from "zustand"; import type { WebServerInfoResponse } from "@/shared/types/model"; -import { getServer } from "@/shared/api/server/server"; +import { getServer } from "@/shared/api/gen/server/server"; import { toast } from "sonner"; interface ServerStore { diff --git a/frontend/src/pages/service/components/serviceOverview.tsx b/frontend/src/pages/service/components/serviceOverview.tsx index a4c1581..69bc597 100644 --- a/frontend/src/pages/service/components/serviceOverview.tsx +++ b/frontend/src/pages/service/components/serviceOverview.tsx @@ -23,7 +23,6 @@ import type { WebServiceDTO } from "@/shared/types/model"; interface ServiceOverviewProps { serviceDetailData: WebServiceDTO; onCheckService: (serviceId: string) => void; - setResolveIncident: (value: boolean) => void; } export const ServiceOverview = ({ diff --git a/frontend/src/pages/service/components/serviceStats.tsx b/frontend/src/pages/service/components/serviceStats.tsx index 1ab3b07..5e90cb7 100644 --- a/frontend/src/pages/service/components/serviceStats.tsx +++ b/frontend/src/pages/service/components/serviceStats.tsx @@ -25,7 +25,7 @@ export const ServiceStats = ({ description: "Total Checks", }, { - value: `${((serviceStatsData?.avg_response_time ?? 0) / 1000000).toFixed(1)} ms`, + value: `${(serviceStatsData?.avg_response_time ?? 0).toFixed(1)} ms`, key: "avg_response_time", description: "Avg Response Time", }, diff --git a/frontend/src/pages/service/hooks/useCreateFromService.ts b/frontend/src/pages/service/hooks/useCreateFromService.ts index 5d39eda..15b2844 100644 --- a/frontend/src/pages/service/hooks/useCreateFromService.ts +++ b/frontend/src/pages/service/hooks/useCreateFromService.ts @@ -1,6 +1,6 @@ import { useMemo } from "react"; import { useServiceTableStore } from "../store/useServiceTableStore"; -import { getServices } from "@/shared/api/services/services"; +import { getServices } from "@/shared/api/gen/services/services"; import { toast } from "sonner"; import type { WebCreateUpdateServiceRequest } from "@/shared/types/model"; diff --git a/frontend/src/pages/service/hooks/useServiceCreate.ts b/frontend/src/pages/service/hooks/useServiceCreate.ts index dd1ac76..a1e75fd 100644 --- a/frontend/src/pages/service/hooks/useServiceCreate.ts +++ b/frontend/src/pages/service/hooks/useServiceCreate.ts @@ -1,7 +1,7 @@ import { useState } from "react"; import { toast } from "sonner"; import type { WebCreateUpdateServiceRequest } from "@/shared/types/model"; -import { getServices } from "@/shared/api/services/services"; +import { getServices } from "@/shared/api/gen/services/services"; export const useServiceCreate = () => { const [isOpenModal, setIsOpenModal] = useState(false); diff --git a/frontend/src/pages/service/hooks/useServiceDetail.ts b/frontend/src/pages/service/hooks/useServiceDetail.ts index d5a452e..f8f10e6 100644 --- a/frontend/src/pages/service/hooks/useServiceDetail.ts +++ b/frontend/src/pages/service/hooks/useServiceDetail.ts @@ -3,9 +3,9 @@ import { useEffect } from "react"; import { toast } from "sonner"; import { useServiceDetailStore } from "../store/useServiceDeteilStore"; import useWebSocket from "react-use-websocket"; -import { getServices } from "@/shared/api/services/services"; -import { getIncidents } from "@/shared/api/incidents/incidents"; -import { getStatistics } from "@/shared/api/statistics/statistics"; +import { getServices } from "@/shared/api/gen/services/services"; +import { getIncidents } from "@/shared/api/gen/incidents/incidents"; +import { getStatistics } from "@/shared/api/gen/statistics/statistics"; export const useServiceDetail = (serviceID: string) => { const { @@ -13,7 +13,6 @@ export const useServiceDetail = (serviceID: string) => { serviceDetailData, incidentsData, serviceStatsData, - resolveIncident, filters, setDeleteIncident, setServiceDetailData, @@ -21,16 +20,12 @@ export const useServiceDetail = (serviceID: string) => { setServiceStatsData, setFilters, setUpdateServiceStatsData, - setResolveIncident, } = useServiceDetailStore(); const { postServicesIdCheck, getServicesId } = getServices(); const { getServicesIdStats } = getStatistics(); - const { - getServicesIdIncidents, - deleteServicesIdIncidentsIncidentId, - postServicesIdResolve, - } = getIncidents(); + const { getServicesIdIncidents, deleteServicesIdIncidentsIncidentId } = + getIncidents(); // Get service const getServiceDetail = async () => { @@ -91,23 +86,6 @@ export const useServiceDetail = (serviceID: string) => { }); }; - // Resolve incident - const onResolveIncident = async () => { - await postServicesIdResolve(serviceID ?? "") - .then(() => { - getServiceDetail(); - getAllIncidents(); - getServiceStats(); - toast.success("Incident resolved"); - }) - .catch((err) => { - toast.error(err.response.data.error); - }) - .finally(() => { - setResolveIncident(false); - }); - }; - // WebSocket connection to update service stats const { lastMessage } = useWebSocket(socketUrl, { shouldReconnect: () => true, @@ -147,13 +125,10 @@ export const useServiceDetail = (serviceID: string) => { serviceDetailData, incidentsData, serviceStatsData, - resolveIncident, filters, onCheckService, onDeleteIncident, - onResolveIncident, setFilters, setDeleteIncident, - setResolveIncident, }; }; diff --git a/frontend/src/pages/service/hooks/useServiceTable.tsx b/frontend/src/pages/service/hooks/useServiceTable.tsx index 9c36121..ee9bff9 100644 --- a/frontend/src/pages/service/hooks/useServiceTable.tsx +++ b/frontend/src/pages/service/hooks/useServiceTable.tsx @@ -29,8 +29,8 @@ import { import { toast } from "sonner"; import { useServiceTableStore } from "../store/useServiceTableStore"; import { ActivityIndicatorSVG } from "@/entities/ActivityIndicatorSVG/ActivityIndicatorSVG"; -import { getServices } from "@/shared/api/services/services"; -import { getTags } from "@/shared/api/tags/tags"; +import { getServices } from "@/shared/api/gen/services/services"; +import { getTags } from "@/shared/api/gen/tags/tags"; import type { WebServiceDTO } from "@/shared/types/model"; export const useServiceTable = () => { diff --git a/frontend/src/pages/service/hooks/useServiceUpdate.ts b/frontend/src/pages/service/hooks/useServiceUpdate.ts index 529bc1e..0fd8796 100644 --- a/frontend/src/pages/service/hooks/useServiceUpdate.ts +++ b/frontend/src/pages/service/hooks/useServiceUpdate.ts @@ -6,7 +6,7 @@ import type { WebCreateUpdateServiceRequest, WebServiceDTO, } from "@/shared/types/model"; -import { getServices } from "@/shared/api/services/services"; +import { getServices } from "@/shared/api/gen/services/services"; export const useServiceUpdate = () => { const [serviceData, setServiceData] = useState(null); diff --git a/frontend/src/pages/service/serviceDetail.tsx b/frontend/src/pages/service/serviceDetail.tsx index d6334b0..a8d6818 100644 --- a/frontend/src/pages/service/serviceDetail.tsx +++ b/frontend/src/pages/service/serviceDetail.tsx @@ -14,15 +14,12 @@ const ServiceDetail = ({ serviceID }: ServiceDetailProps) => { filters, incidentsData, deleteIncident, - resolveIncident, serviceDetailData, serviceStatsData, setFilters, onCheckService, setDeleteIncident, onDeleteIncident, - setResolveIncident, - onResolveIncident, } = useServiceDetail(serviceID); if (!serviceDetailData || !incidentsData || !serviceStatsData) @@ -30,14 +27,6 @@ const ServiceDetail = ({ serviceID }: ServiceDetailProps) => { return ( <> - setResolveIncident(false)} - onSubmit={onResolveIncident} - title="Resolve Incident" - description="Are you sure you want to resolve this incident?" - type="default" - /> setDeleteIncident(null)} @@ -51,7 +40,6 @@ const ServiceDetail = ({ serviceID }: ServiceDetailProps) => { ) => void; setDeleteIncident: (deleteIncident: StorageIncident | null) => void; - setResolveIncident: (resolveIncident: boolean) => void; setServiceDetailData: (serviceDetailData: WebServiceDTO | null) => void; setIncidentsData: ( incidentsData: StorecmnFindResponseWithCountStorageIncident | null, @@ -28,7 +26,6 @@ interface ServiceDetailStore { const initialState = { deleteIncident: null, serviceDetailData: null, - resolveIncident: false, incidentsData: null, filters: { @@ -43,7 +40,6 @@ export const useServiceDetailStore = create((set) => ({ setDeleteIncident: (deleteIncident) => set({ deleteIncident }), setFilters: (filters) => set((state) => ({ filters: { ...state.filters, ...filters } })), - setResolveIncident: (resolveIncident) => set({ resolveIncident }), setServiceDetailData: (serviceDetailData) => set({ serviceDetailData }), setIncidentsData: (incidentsData) => set({ incidentsData }), setServiceStatsData: (serviceStatsData) => set({ serviceStatsData }), diff --git a/frontend/src/pages/service/store/useServiceTableStore.ts b/frontend/src/pages/service/store/useServiceTableStore.ts index ec45285..390293f 100644 --- a/frontend/src/pages/service/store/useServiceTableStore.ts +++ b/frontend/src/pages/service/store/useServiceTableStore.ts @@ -1,6 +1,9 @@ import { create } from "zustand"; -import type { GetTagsCountResult, GetTagsResult } from "@/shared/api/tags/tags"; -import type { GetServicesResult } from "@/shared/api/services/services"; +import type { + GetTagsCountResult, + GetTagsResult, +} from "@/shared/api/gen/tags/tags"; +import type { GetServicesResult } from "@/shared/api/gen/services/services"; import type { WebServiceDTO } from "@/shared/types/model"; interface ServiceTableStore { diff --git a/frontend/src/routes/incidents.tsx b/frontend/src/routes/incidents.tsx index 2978c4c..46dd902 100644 --- a/frontend/src/routes/incidents.tsx +++ b/frontend/src/routes/incidents.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { getIncidents } from "@/shared/api/incidents/incidents"; +import { getIncidents } from "@/shared/api/gen/incidents/incidents"; import { ExpandableText } from "@/shared/components/expandableText"; import PaginationTable from "@/shared/components/paginationTable"; import { diff --git a/frontend/src/shared/api/dashboard/dashboard.ts b/frontend/src/shared/api/gen/dashboard/dashboard.ts similarity index 84% rename from frontend/src/shared/api/dashboard/dashboard.ts rename to frontend/src/shared/api/gen/dashboard/dashboard.ts index f0794cb..7f8be29 100644 --- a/frontend/src/shared/api/dashboard/dashboard.ts +++ b/frontend/src/shared/api/gen/dashboard/dashboard.ts @@ -5,9 +5,9 @@ * API for service monitoring and incident management * OpenAPI spec version: 1.0 */ -import type { WebDashboardStats } from "../../types/model"; +import type { WebDashboardStats } from "../../../types/model"; -import { customFetcher } from ".././baseApi"; +import { customFetcher } from "../../baseApi"; export const getDashboard = () => { /** diff --git a/frontend/src/shared/api/incidents/incidents.ts b/frontend/src/shared/api/gen/incidents/incidents.ts similarity index 81% rename from frontend/src/shared/api/incidents/incidents.ts rename to frontend/src/shared/api/gen/incidents/incidents.ts index b012b1a..8bd3e94 100644 --- a/frontend/src/shared/api/incidents/incidents.ts +++ b/frontend/src/shared/api/gen/incidents/incidents.ts @@ -11,10 +11,9 @@ import type { GetServicesIdIncidentsParams, StorecmnFindResponseWithCountStorageIncident, WebGetIncidentsStatsItem, - WebSuccessResponse, -} from "../../types/model"; +} from "../../../types/model"; -import { customFetcher } from ".././baseApi"; +import { customFetcher } from "../../baseApi"; export const getIncidents = () => { /** @@ -66,22 +65,11 @@ export const getIncidents = () => { method: "DELETE", }); }; - /** - * Forcefully resolves all active incidents for a service - * @summary Resolve service incidents - */ - const postServicesIdResolve = (id: string) => { - return customFetcher({ - url: `/services/${id}/resolve`, - method: "POST", - }); - }; return { getIncidents, getIncidentsStats, getServicesIdIncidents, deleteServicesIdIncidentsIncidentId, - postServicesIdResolve, }; }; export type GetIncidentsResult = NonNullable< @@ -100,6 +88,3 @@ export type DeleteServicesIdIncidentsIncidentIdResult = NonNullable< > > >; -export type PostServicesIdResolveResult = NonNullable< - Awaited["postServicesIdResolve"]>> ->; diff --git a/frontend/src/shared/api/server/server.ts b/frontend/src/shared/api/gen/server/server.ts similarity index 94% rename from frontend/src/shared/api/server/server.ts rename to frontend/src/shared/api/gen/server/server.ts index ae6b17b..70caa99 100644 --- a/frontend/src/shared/api/server/server.ts +++ b/frontend/src/shared/api/gen/server/server.ts @@ -9,9 +9,9 @@ import type { WebHealthCheckResponse, WebServerInfoResponse, WebSuccessResponse, -} from "../../types/model"; +} from "../../../types/model"; -import { customFetcher } from ".././baseApi"; +import { customFetcher } from "../../baseApi"; export const getServer = () => { /** diff --git a/frontend/src/shared/api/services/services.ts b/frontend/src/shared/api/gen/services/services.ts similarity index 97% rename from frontend/src/shared/api/services/services.ts rename to frontend/src/shared/api/gen/services/services.ts index 39d8b18..32f2ab3 100644 --- a/frontend/src/shared/api/services/services.ts +++ b/frontend/src/shared/api/gen/services/services.ts @@ -11,9 +11,9 @@ import type { WebCreateUpdateServiceRequest, WebServiceDTO, WebSuccessResponse, -} from "../../types/model"; +} from "../../../types/model"; -import { customFetcher } from ".././baseApi"; +import { customFetcher } from "../../baseApi"; export const getServices = () => { /** diff --git a/frontend/src/shared/api/statistics/statistics.ts b/frontend/src/shared/api/gen/statistics/statistics.ts similarity index 83% rename from frontend/src/shared/api/statistics/statistics.ts rename to frontend/src/shared/api/gen/statistics/statistics.ts index 72a21ca..95e4a72 100644 --- a/frontend/src/shared/api/statistics/statistics.ts +++ b/frontend/src/shared/api/gen/statistics/statistics.ts @@ -5,9 +5,12 @@ * API for service monitoring and incident management * OpenAPI spec version: 1.0 */ -import type { GetServicesIdStatsParams, ServiceStats } from "../../types/model"; +import type { + GetServicesIdStatsParams, + ServiceStats, +} from "../../../types/model"; -import { customFetcher } from ".././baseApi"; +import { customFetcher } from "../../baseApi"; export const getStatistics = () => { /** diff --git a/frontend/src/shared/api/tags/tags.ts b/frontend/src/shared/api/gen/tags/tags.ts similarity index 89% rename from frontend/src/shared/api/tags/tags.ts rename to frontend/src/shared/api/gen/tags/tags.ts index 1d865d4..5590885 100644 --- a/frontend/src/shared/api/tags/tags.ts +++ b/frontend/src/shared/api/gen/tags/tags.ts @@ -5,9 +5,9 @@ * API for service monitoring and incident management * OpenAPI spec version: 1.0 */ -import type { GetTagsCount200 } from "../../types/model"; +import type { GetTagsCount200 } from "../../../types/model"; -import { customFetcher } from ".././baseApi"; +import { customFetcher } from "../../baseApi"; export const getTags = () => { /** diff --git a/frontend/src/shared/api/info/info.ts b/frontend/src/shared/api/info/info.ts deleted file mode 100644 index e3aa9bb..0000000 --- a/frontend/src/shared/api/info/info.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Generated by orval v7.11.1 🍺 - * Do not edit manually. - * Sentinel Monitoring API - * API for service monitoring and incident management - * OpenAPI spec version: 1.0 - */ -import type { WebServerInfoResponse } from "../../types/model"; - -import { customFetcher } from ".././baseApi"; - -export const getInfo = () => { - /** - * Returns basic information about the server - * @summary Get server info - */ - const getServerInfo = () => { - return customFetcher({ - url: `/server/info`, - method: "GET", - }); - }; - return { getServerInfo }; -}; -export type GetServerInfoResult = NonNullable< - Awaited["getServerInfo"]>> ->; diff --git a/frontend/src/shared/api/upgrade/upgrade.ts b/frontend/src/shared/api/upgrade/upgrade.ts deleted file mode 100644 index f3ad448..0000000 --- a/frontend/src/shared/api/upgrade/upgrade.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Generated by orval v7.11.1 🍺 - * Do not edit manually. - * Sentinel Monitoring API - * API for service monitoring and incident management - * OpenAPI spec version: 1.0 - */ -import type { WebSuccessResponse } from "../../types/model"; - -import { customFetcher } from ".././baseApi"; - -export const getUpgrade = () => { - /** - * Triggers a manual upgrade of the server - * @summary Manual upgrade - */ - const getServerUpgrade = () => { - return customFetcher({ - url: `/server/upgrade`, - method: "GET", - }); - }; - return { getServerUpgrade }; -}; -export type GetServerUpgradeResult = NonNullable< - Awaited["getServerUpgrade"]>> ->; diff --git a/frontend/src/shared/utils/duration.ts b/frontend/src/shared/utils/duration.ts index 5191464..4b1da25 100644 --- a/frontend/src/shared/utils/duration.ts +++ b/frontend/src/shared/utils/duration.ts @@ -1,6 +1,6 @@ -// Helper function to format duration from nanoseconds to human readable format -export const formatDuration = (nanoseconds: number) => { - const seconds = Math.floor(nanoseconds / 1000000000); +// Helper function to format duration from milliseconds to human readable format +export const formatDuration = (milliseconds: number) => { + const seconds = Math.floor(milliseconds / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); @@ -16,7 +16,9 @@ export const formatDuration = (nanoseconds: number) => { } else if (minutes > 0) { const remainingSeconds = seconds % 60; return `${minutes}m ${remainingSeconds}s`; - } else { + } else if (seconds > 0) { return `${seconds}s`; + } else { + return `${milliseconds}ms`; } }; diff --git a/internal/models/models_gen.go b/internal/models/models_gen.go index e012ac5..7d32330 100644 --- a/internal/models/models_gen.go +++ b/internal/models/models_gen.go @@ -31,15 +31,15 @@ type Agent struct { } type Incident struct { - ID string `db:"id" json:"id"` - ServiceID string `db:"service_id" json:"service_id"` - StartTime time.Time `db:"start_time" json:"start_time"` - EndTime *time.Time `db:"end_time" json:"end_time"` - Error string `db:"error" json:"error"` - DurationNs *int64 `db:"duration_ns" json:"duration_ns"` - Resolved bool `db:"resolved" json:"resolved"` - CreatedAt *time.Time `db:"created_at" json:"created_at"` - UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` + ID string `db:"id" json:"id"` + ServiceID string `db:"service_id" json:"service_id"` + StartTime time.Time `db:"start_time" json:"start_time"` + EndTime *time.Time `db:"end_time" json:"end_time"` + Error string `db:"error" json:"error"` + Duration *int64 `db:"duration" json:"duration"` + Resolved bool `db:"resolved" json:"resolved"` + CreatedAt *time.Time `db:"created_at" json:"created_at"` + UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` } type Service struct { @@ -66,7 +66,7 @@ type ServiceState struct { ConsecutiveFails int64 `db:"consecutive_fails" json:"consecutive_fails"` ConsecutiveSuccess int64 `db:"consecutive_success" json:"consecutive_success"` TotalChecks int64 `db:"total_checks" json:"total_checks"` - ResponseTimeNs *int64 `db:"response_time_ns" json:"response_time_ns"` + ResponseTime *int64 `db:"response_time" json:"response_time"` CreatedAt *time.Time `db:"created_at" json:"created_at"` UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` } diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 7ec2b6d..87781a6 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -2,6 +2,7 @@ package monitor import ( "context" + "database/sql" "fmt" "log" "time" @@ -11,17 +12,19 @@ import ( "github.com/sxwebdev/sentinel/internal/notifier" "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/services/baseservices" + "github.com/sxwebdev/sentinel/internal/services/incidents" "github.com/sxwebdev/sentinel/internal/services/servicestate" "github.com/sxwebdev/sentinel/internal/storage" - "github.com/sxwebdev/sentinel/internal/store" "github.com/sxwebdev/sentinel/internal/store/repos/repo_service_states" + "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/utils" "github.com/tkcrm/modules/pkg/db/dbutils" + "github.com/tkcrm/mx/logger" ) // MonitorService handles service monitoring type MonitorService struct { - store *store.Store + logger logger.Logger storage *storage.Storage config *config.ConfigHub notifier *notifier.Notifier @@ -31,7 +34,7 @@ type MonitorService struct { // NewMonitorService creates a new monitor service func NewMonitorService( - store *store.Store, + logger logger.Logger, storage *storage.Storage, config *config.ConfigHub, notifier *notifier.Notifier, @@ -39,7 +42,7 @@ func NewMonitorService( baseservices *baseservices.BaseServices, ) *MonitorService { return &MonitorService{ - store: store, + logger: logger, storage: storage, config: config, notifier: notifier, @@ -139,7 +142,7 @@ func (m *MonitorService) RecordSuccess(ctx context.Context, serviceID string, re ServiceState: models.ServiceState{ Status: models.StatusUp, LastCheck: &now, - ResponseTimeNs: utils.Pointer(responseTime.Nanoseconds()), + ResponseTime: utils.Pointer(responseTime.Milliseconds()), ConsecutiveFails: 0, ConsecutiveSuccess: serviceState.ConsecutiveSuccess + 1, TotalChecks: serviceState.TotalChecks + 1, @@ -148,7 +151,7 @@ func (m *MonitorService) RecordSuccess(ctx context.Context, serviceID string, re FieldMask: dbutils.FieldMask[repo_service_states.ColumnName]{ repo_service_states.ColumnNameServiceStatesStatus, repo_service_states.ColumnNameServiceStatesLastCheck, - repo_service_states.ColumnNameServiceStatesResponseTimeNs, + repo_service_states.ColumnNameServiceStatesResponseTime, repo_service_states.ColumnNameServiceStatesConsecutiveFails, repo_service_states.ColumnNameServiceStatesConsecutiveSuccess, repo_service_states.ColumnNameServiceStatesTotalChecks, @@ -172,7 +175,7 @@ func (m *MonitorService) RecordSuccess(ctx context.Context, serviceID string, re // RecordFailure records a failed check for a service func (m *MonitorService) RecordFailure(ctx context.Context, serviceID string, checkErr error, responseTime time.Duration) error { // Get current service from database - service, err := m.store.Services().GetViewByID(ctx, serviceID) + service, err := m.baseservices.Services().GetViewByID(ctx, serviceID) if err != nil { return fmt.Errorf("service %s not found in database: %w", serviceID, err) } @@ -191,7 +194,7 @@ func (m *MonitorService) RecordFailure(ctx context.Context, serviceID string, ch ServiceState: models.ServiceState{ Status: models.StatusDown, LastCheck: &now, - ResponseTimeNs: utils.Pointer(responseTime.Nanoseconds()), + ResponseTime: utils.Pointer(responseTime.Milliseconds()), ConsecutiveFails: serviceState.ConsecutiveFails + 1, ConsecutiveSuccess: 0, TotalChecks: serviceState.TotalChecks + 1, @@ -200,7 +203,7 @@ func (m *MonitorService) RecordFailure(ctx context.Context, serviceID string, ch FieldMask: dbutils.FieldMask[repo_service_states.ColumnName]{ repo_service_states.ColumnNameServiceStatesStatus, repo_service_states.ColumnNameServiceStatesLastCheck, - repo_service_states.ColumnNameServiceStatesResponseTimeNs, + repo_service_states.ColumnNameServiceStatesResponseTime, repo_service_states.ColumnNameServiceStatesConsecutiveFails, repo_service_states.ColumnNameServiceStatesConsecutiveSuccess, repo_service_states.ColumnNameServiceStatesTotalChecks, @@ -225,16 +228,12 @@ func (m *MonitorService) RecordFailure(ctx context.Context, serviceID string, ch // createIncident creates a new incident when a service goes down func (m *MonitorService) createIncident(ctx context.Context, svc *models.ServiceFullView, err error) error { - incident := &storage.Incident{ - ID: utils.GenerateULID(), + // Save incident to storage + incident, err := m.baseservices.Incidents().Create(ctx, incidents.CreateParams{ ServiceID: svc.ID, - StartTime: time.Now(), Error: err.Error(), - Resolved: false, - } - - // Save incident to storage - if err := m.storage.SaveIncident(ctx, incident); err != nil { + }) + if err != nil { return fmt.Errorf("failed to save incident for %s: %w", svc.Name, err) } @@ -253,66 +252,50 @@ func (m *MonitorService) createIncident(ctx context.Context, svc *models.Service // resolveActiveIncidents resolves the active incident when a service recovers func (m *MonitorService) resolveActiveIncidents(ctx context.Context, serviceID string) error { // Get service - svc, err := m.store.Services().GetViewByID(ctx, serviceID) + svc, err := m.baseservices.Services().GetViewByID(ctx, serviceID) if err != nil { return fmt.Errorf("failed to get service: %w", err) } // Resolve all active incidents for this service - incidents, err := m.storage.ResolveAllIncidents(ctx, serviceID) + incidents, err := m.baseservices.Incidents().GetAllUnresolvedByServiceID(ctx, serviceID) if err != nil { return fmt.Errorf("failed to resolve incidents: %w", err) } - for _, incident := range incidents { - // Send recovery notification - if m.notifier != nil { - if err := m.notifier.SendRecovery(svc, incident); err != nil { - err := fmt.Errorf("failed to send recovery notification for %s: %w", svc.Name, err) - log.Println(err) - return nil + err = storecmn.WrapTx(ctx, m.storage.SQLiteDB(), func(txCtx *sql.Tx) error { + for _, incident := range incidents { + resolverIncident, err := m.baseservices.Incidents().ResolveByID(ctx, incident.ID) + if err != nil { + return fmt.Errorf("failed to resolve incident %s: %w", incident.ID, err) } - } - } - - return nil -} - -// DeleteIncident deletes a specific incident -func (m *MonitorService) DeleteIncident(ctx context.Context, serviceID, incidentID string) error { - // Delete the incident - if err := m.storage.DeleteIncident(ctx, incidentID); err != nil { - return fmt.Errorf("failed to delete incident: %w", err) - } - return nil -} + if m.notifier != nil { + if err := m.notifier.SendRecovery(svc, resolverIncident); err != nil { + m.logger.Errorf("failed to send recovery notification for %s: %v", svc.Name, err) + return nil + } + } + } -// TriggerCheck triggers a manual check for a service -func (m *MonitorService) TriggerCheck(ctx context.Context, id string) error { - // Get service to check if it exists - svc, err := m.store.Services().GetViewByID(ctx, id) + return nil + }) if err != nil { - return fmt.Errorf("failed to get service: %w", err) + return fmt.Errorf("failed to resolve incidents in transaction: %w", err) } - m.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( - receiver.TriggerServiceEventTypeCheck, - svc, - )) - return nil } // resolveAllActiveIncidents resolves all active incidents for a service -func (m *MonitorService) resolveAllActiveIncidents(ctx context.Context, serviceID string) error { - return m.resolveActiveIncidents(ctx, serviceID) -} +// func (m *MonitorService) resolveAllActiveIncidents(ctx context.Context, serviceID string) error { +// return m.resolveActiveIncidents(ctx, serviceID) +// } -// ForceResolveIncidents manually resolves all active incidents for a service -func (m *MonitorService) ForceResolveIncidents(ctx context.Context, serviceID string) error { - return m.resolveAllActiveIncidents(ctx, serviceID) -} +// // ForceResolveIncidents manually resolves all active incidents for a service +// func (m *MonitorService) ForceResolveIncidents(ctx context.Context, serviceID string) error { +// return m.resolveAllActiveIncidents(ctx, serviceID) +// } // CheckService performs a health check on a service func (m *MonitorService) CheckService(ctx context.Context, service *storage.Service) error { @@ -334,7 +317,7 @@ func (m *MonitorService) CheckService(ctx context.Context, service *storage.Serv ServiceState: models.ServiceState{ Status: models.StatusUp, LastCheck: &now, - ResponseTimeNs: utils.Pointer(responseTime.Nanoseconds()), + ResponseTime: utils.Pointer(responseTime.Milliseconds()), ConsecutiveFails: 0, ConsecutiveSuccess: serviceState.ConsecutiveSuccess + 1, TotalChecks: serviceState.TotalChecks + 1, @@ -343,7 +326,7 @@ func (m *MonitorService) CheckService(ctx context.Context, service *storage.Serv FieldMask: dbutils.FieldMask[repo_service_states.ColumnName]{ repo_service_states.ColumnNameServiceStatesStatus, repo_service_states.ColumnNameServiceStatesLastCheck, - repo_service_states.ColumnNameServiceStatesResponseTimeNs, + repo_service_states.ColumnNameServiceStatesResponseTime, repo_service_states.ColumnNameServiceStatesConsecutiveFails, repo_service_states.ColumnNameServiceStatesConsecutiveSuccess, repo_service_states.ColumnNameServiceStatesTotalChecks, diff --git a/internal/notifier/shoutrrr.go b/internal/notifier/shoutrrr.go index b224e3f..b965e7f 100644 --- a/internal/notifier/shoutrrr.go +++ b/internal/notifier/shoutrrr.go @@ -9,7 +9,6 @@ import ( "github.com/containrrr/shoutrrr" "github.com/sxwebdev/sentinel/internal/models" - "github.com/sxwebdev/sentinel/internal/storage" "github.com/tkcrm/mx/logger" ) @@ -149,7 +148,7 @@ func (s *Notifier) processNotification(req *notificationRequest) { } // SendAlert sends an alert notification when a service goes down -func (s *Notifier) SendAlert(service *models.ServiceFullView, incident *storage.Incident) error { +func (s *Notifier) SendAlert(service *models.ServiceFullView, incident *models.Incident) error { s.mu.RLock() if !s.isStarted { s.mu.RUnlock() @@ -162,7 +161,7 @@ func (s *Notifier) SendAlert(service *models.ServiceFullView, incident *storage. } // SendRecovery sends a recovery notification when a service comes back up -func (s *Notifier) SendRecovery(service *models.ServiceFullView, incident *storage.Incident) error { +func (s *Notifier) SendRecovery(service *models.ServiceFullView, incident *models.Incident) error { s.mu.RLock() if !s.isStarted { s.mu.RUnlock() @@ -273,7 +272,7 @@ func (s *Notifier) sendMessageSync(message string) error { } // formatAlertMessage formats an alert message -func (s *Notifier) formatAlertMessage(service *models.ServiceFullView, incident *storage.Incident) string { +func (s *Notifier) formatAlertMessage(service *models.ServiceFullView, incident *models.Incident) string { tags := "-" if len(service.Tags) > 0 { tags = strings.Join(service.Tags, ", ") @@ -296,10 +295,10 @@ func (s *Notifier) formatAlertMessage(service *models.ServiceFullView, incident } // formatRecoveryMessage formats a recovery message -func (s *Notifier) formatRecoveryMessage(service *models.ServiceFullView, incident *storage.Incident) string { +func (s *Notifier) formatRecoveryMessage(service *models.ServiceFullView, incident *models.Incident) string { var duration string if incident.Duration != nil { - duration = formatDuration(*incident.Duration) + duration = formatDuration(time.Duration(*incident.Duration) * time.Millisecond) } else { duration = formatDuration(time.Since(incident.StartTime)) } diff --git a/internal/services/incidents/methods.go b/internal/services/incidents/methods.go new file mode 100644 index 0000000..5d72b79 --- /dev/null +++ b/internal/services/incidents/methods.go @@ -0,0 +1,64 @@ +package incidents + +import ( + "context" + + "github.com/go-playground/validator/v10" + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/storecmn" + "github.com/sxwebdev/sentinel/internal/utils" +) + +// GetByID retrieves an incident by its ID. +func (s *Service) GetByID(ctx context.Context, id string) (*models.Incident, error) { + if id == "" { + return nil, storecmn.ErrEmptyID + } + + return s.store.Incidents().GetByID(ctx, id) +} + +// Delete removes an incident by its ID. +func (s *Service) Delete(ctx context.Context, id string) error { + if id == "" { + return storecmn.ErrEmptyID + } + + return s.store.Incidents().Delete(ctx, id) +} + +// GetAllUnresolvedByServiceID retrieves all unresolved incidents associated with a specific service ID. +func (s *Service) GetAllUnresolvedByServiceID(ctx context.Context, serviceID string) ([]*models.Incident, error) { + if serviceID == "" { + return nil, storecmn.ErrEmptyID + } + + return s.store.Incidents().GetAllUnresolvedByServiceID(ctx, serviceID) +} + +// ResolveByID resolves a specific incident by its ID. +func (s *Service) ResolveByID(ctx context.Context, id string) (*models.Incident, error) { + if id == "" { + return nil, storecmn.ErrEmptyID + } + + if err := s.store.Incidents().ResolveByID(ctx, id); err != nil { + return nil, err + } + + return s.GetByID(ctx, id) +} + +type CreateParams struct { + ServiceID string `db:"service_id" json:"service_id" validate:"required"` + Error string `db:"error" json:"error" validate:"required"` +} + +// Create creates a new incident. +func (s *Service) Create(ctx context.Context, params CreateParams) (*models.Incident, error) { + if err := validator.New().Struct(params); err != nil { + return nil, err + } + + return s.store.Incidents().Create(ctx, utils.GenerateULID(), params.ServiceID, params.Error) +} diff --git a/internal/services/service/check.go b/internal/services/service/check.go new file mode 100644 index 0000000..b91bc06 --- /dev/null +++ b/internal/services/service/check.go @@ -0,0 +1,23 @@ +package service + +import ( + "context" + "fmt" + + "github.com/sxwebdev/sentinel/internal/receiver" +) + +// TriggerCheck triggers a manual check for a service +func (s *Service) TriggerCheck(ctx context.Context, id string) error { + svc, err := s.GetViewByID(ctx, id) + if err != nil { + return fmt.Errorf("failed to get service: %w", err) + } + + s.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( + receiver.TriggerServiceEventTypeCheck, + svc, + )) + + return nil +} diff --git a/internal/services/service/stats.go b/internal/services/service/stats.go index 66ffe71..573dc72 100644 --- a/internal/services/service/stats.go +++ b/internal/services/service/stats.go @@ -10,9 +10,9 @@ type Stats struct { ServiceID string `json:"service_id"` Period time.Duration `json:"period" swaggertype:"primitive,integer"` TotalIncidents int64 `json:"total_incidents"` - TotalDowntime time.Duration `json:"total_downtime" swaggertype:"primitive,integer"` + TotalDowntime int64 `json:"total_downtime" swaggertype:"primitive,integer"` UptimePercentage float64 `json:"uptime_percentage"` - AvgResponseTime time.Duration `json:"avg_response_time" swaggertype:"primitive,integer"` + AvgResponseTime int64 `json:"avg_response_time" swaggertype:"primitive,integer"` ResolvedIncidents int64 `json:"resolved_incidents"` UnresolvedIncidents int64 `json:"unresolved_incidents"` } @@ -40,14 +40,14 @@ func (s *Service) Stats(ctx context.Context, serviceID string, since time.Time) } // Get average response time from service state - var avgResponseTime time.Duration + var avgResponseTime int64 serviceState, err := s.store.ServiceStates().GetByServiceID(ctx, serviceID) if err != nil { return nil, fmt.Errorf("failed to get service state: %w", err) } - if serviceState.ResponseTimeNs != nil { - avgResponseTime = time.Duration(*serviceState.ResponseTimeNs) + if serviceState.ResponseTime != nil { + avgResponseTime = *serviceState.ResponseTime } return &Stats{ diff --git a/internal/services/service/tags.go b/internal/services/service/tags.go new file mode 100644 index 0000000..65256ab --- /dev/null +++ b/internal/services/service/tags.go @@ -0,0 +1,13 @@ +package service + +import "context" + +// GetAllTags retrieves all unique tags associated with services. +func (s *Service) GetAllTags(ctx context.Context) ([]string, error) { + return s.store.Services().GetAllTags(ctx) +} + +// GetAllTagsWithCount retrieves all unique tags along with their occurrence counts. +func (s *Service) GetAllTagsWithCount(ctx context.Context) (map[string]int, error) { + return s.store.Services().GetAllTagsWithCount(ctx) +} diff --git a/internal/storage/incidents.go b/internal/storage/incidents.go index a31c5cc..82b4726 100644 --- a/internal/storage/incidents.go +++ b/internal/storage/incidents.go @@ -19,7 +19,7 @@ type IncidentRow struct { StartTime time.Time `db:"start_time"` EndTime *time.Time `db:"end_time"` Error string `db:"error"` - DurationNS *int64 `db:"duration_ns"` + DurationNS *int64 `db:"duration"` Resolved bool `db:"resolved"` CreatedAt time.Time `db:"created_at"` UpdatedAt time.Time `db:"updated_at"` @@ -38,7 +38,7 @@ func (o *Storage) GetIncidentByID(ctx context.Context, id string) (*Incident, er "i.start_time", "i.end_time", "i.error", - "i.duration_ns", + "i.duration", "i.resolved", "i.created_at", "i.updated_at", @@ -127,7 +127,7 @@ func (o *Storage) FindIncidents(ctx context.Context, params FindIncidentsParams) "i.start_time", "i.end_time", "i.error", - "i.duration_ns", + "i.duration", "i.resolved", "i.created_at", "i.updated_at", @@ -236,7 +236,7 @@ func (o *Storage) ResolveAllIncidents(ctx context.Context, serviceID string) ([] Set( ub.Assign("resolved", true), ub.Assign("end_time", now), - ub.Assign("duration_ns", now.Sub(item.StartTime)), + ub.Assign("duration", now.Sub(item.StartTime)), ub.Assign("updated_at", now), ). Where( @@ -268,29 +268,29 @@ func (o *Storage) ResolveAllIncidents(ctx context.Context, serviceID string) ([] } // SaveIncident creates a new incident using ORM with retry logic -func (o *Storage) SaveIncident(ctx context.Context, incident *Incident) error { - ib := sqlbuilder.NewInsertBuilder() - ib.InsertInto("incidents") - ib.Cols("id", "service_id", "start_time", "end_time", "error", "duration_ns", "resolved") - - ib.Values( - utils.GenerateULID(), - incident.ServiceID, - incident.StartTime, - incident.EndTime, - incident.Error, - durationToNS(incident.Duration), - incident.Resolved, - ) - - sql, args := ib.Build() - _, err := o.db.ExecContext(ctx, sql, args...) - if err != nil { - return fmt.Errorf("failed to create incident: %w", err) - } - - return nil -} +// func (o *Storage) SaveIncident(ctx context.Context, incident *Incident) error { +// ib := sqlbuilder.NewInsertBuilder() +// ib.InsertInto("incidents") +// ib.Cols("id", "service_id", "start_time", "end_time", "error", "duration", "resolved") + +// ib.Values( +// utils.GenerateULID(), +// incident.ServiceID, +// incident.StartTime, +// incident.EndTime, +// incident.Error, +// durationToNS(incident.Duration), +// incident.Resolved, +// ) + +// sql, args := ib.Build() +// _, err := o.db.ExecContext(ctx, sql, args...) +// if err != nil { +// return fmt.Errorf("failed to create incident: %w", err) +// } + +// return nil +// } // UpdateIncident updates an existing incident using ORM with retry logic func (o *Storage) UpdateIncident(ctx context.Context, incident *Incident) error { @@ -301,7 +301,7 @@ func (o *Storage) UpdateIncident(ctx context.Context, incident *Incident) error ub.Assign("start_time", incident.StartTime), ub.Assign("end_time", incident.EndTime), ub.Assign("error", incident.Error), - ub.Assign("duration_ns", durationToNS(incident.Duration)), + ub.Assign("duration", durationToNS(incident.Duration)), ub.Assign("resolved", incident.Resolved), ub.Assign("updated_at", time.Now()), ) @@ -317,19 +317,19 @@ func (o *Storage) UpdateIncident(ctx context.Context, incident *Incident) error } // DeleteIncident deletes an incident by ID using ORM with retry logic -func (o *Storage) DeleteIncident(ctx context.Context, incidentID string) error { - db := sqlbuilder.NewDeleteBuilder() - db.DeleteFrom("incidents") - db.Where(db.Equal("id", incidentID)) +// func (o *Storage) DeleteIncident(ctx context.Context, incidentID string) error { +// db := sqlbuilder.NewDeleteBuilder() +// db.DeleteFrom("incidents") +// db.Where(db.Equal("id", incidentID)) - sql, args := db.Build() - _, err := o.db.ExecContext(ctx, sql, args...) - if err != nil { - return fmt.Errorf("failed to delete incident: %w", err) - } +// sql, args := db.Build() +// _, err := o.db.ExecContext(ctx, sql, args...) +// if err != nil { +// return fmt.Errorf("failed to delete incident: %w", err) +// } - return nil -} +// return nil +// } type GetIncidentsStatsByDateRangeItem struct { Date time.Time `json:"date"` @@ -355,8 +355,8 @@ func (o *Storage) GetIncidentsStatsByDateRange(ctx context.Context, startTime, e sb := sqlbuilder.NewSelectBuilder() sb.Select( "COUNT(*) as count", - "AVG(CASE WHEN resolved = true THEN duration_ns ELSE (strftime('%s', 'now') - strftime('%s', start_time)) * 1000000000 END) as avg_duration", - "SUM(CASE WHEN resolved = true THEN duration_ns ELSE (strftime('%s', 'now') - strftime('%s', start_time)) * 1000000000 END) as total_duration", + "AVG(CASE WHEN resolved = true THEN duration ELSE (strftime('%s', 'now') - strftime('%s', start_time)) * 1000 END) as avg_duration", + "SUM(CASE WHEN resolved = true THEN duration ELSE (strftime('%s', 'now') - strftime('%s', start_time)) * 1000 END) as total_duration", ) sb.From("incidents") sb.Where(sb.GreaterEqualThan("start_time", dayStart)) @@ -393,12 +393,30 @@ func (o *Storage) GetIncidentsStatsByDateRange(ctx context.Context, startTime, e return result, nil } -// GetSQLiteVersion returns the SQLite version -func (o *Storage) GetSQLiteVersion(ctx context.Context) (string, error) { - var version string - err := o.db.QueryRowContext(ctx, "SELECT sqlite_version()").Scan(&version) - if err != nil { - return "", fmt.Errorf("failed to get SQLite version: %w", err) +// rowToIncident converts an IncidentRow to Incident +func (o *Storage) rowToIncident(row *IncidentRow) *Incident { + incident := &Incident{ + ID: row.ID, + ServiceID: row.ServiceID, + StartTime: row.StartTime, + EndTime: row.EndTime, + Error: row.Error, + Resolved: row.Resolved, + } + + if row.DurationNS != nil { + duration := time.Duration(*row.DurationNS) + incident.Duration = &duration + } + + return incident +} + +// durationToNS converts a duration pointer to nanoseconds +func durationToNS(d *time.Duration) *int64 { + if d == nil { + return nil } - return version, nil + ns := d.Nanoseconds() + return &ns } diff --git a/internal/storage/models.go b/internal/storage/models.go index 58d5c26..5c64e15 100644 --- a/internal/storage/models.go +++ b/internal/storage/models.go @@ -12,31 +12,6 @@ const ( ServiceProtocolTypeGRPC ServiceProtocolType = "grpc" ) -// serviceRow represents a database row for services -type serviceRow struct { - ID string - Name string - Protocol string - Interval string - Timeout string - Retries int - Tags string - Config string - IsEnabled bool - CreatedAt time.Time - UpdatedAt time.Time - ActiveIncidents int - TotalIncidents int - Status ServiceStatus - LastCheck *time.Time - NextCheck *time.Time - LastError *string - ConsecutiveFails int - ConsecutiveSuccess int - TotalChecks int - ResponseTimeNS *int64 -} - // Service represents a monitored service type Service struct { ID string `json:"id"` @@ -86,36 +61,3 @@ type Incident struct { Duration *time.Duration `json:"duration,omitempty" swaggertype:"primitive,integer"` Resolved bool `json:"resolved"` } - -// ServiceStats holds statistics for a service -type ServiceStats struct { - ServiceID string `json:"service_id"` - TotalIncidents int `json:"total_incidents"` - TotalDowntime time.Duration `json:"total_downtime" swaggertype:"primitive,integer"` - UptimePercentage float64 `json:"uptime_percentage"` - Period time.Duration `json:"period" swaggertype:"primitive,integer"` - AvgResponseTime time.Duration `json:"avg_response_time" swaggertype:"primitive,integer"` -} - -// ServiceIncidentStats holds incident statistics for a service -type ServiceIncidentStats struct { - ServiceID string `json:"service_id"` - ActiveIncidents int `json:"active_incidents"` - TotalIncidents int `json:"total_incidents"` -} - -// ServiceStateRecord represents a service state record in the database -type ServiceStateRecord struct { - ID string `json:"id"` - ServiceID string `json:"service_id"` - Status ServiceStatus `json:"status"` // "up", "down", "unknown" - LastCheck *time.Time `json:"last_check,omitempty"` - NextCheck *time.Time `json:"next_check,omitempty"` - LastError *string `json:"last_error,omitempty"` - ConsecutiveFails int `json:"consecutive_fails"` - ConsecutiveSuccess int `json:"consecutive_success"` - TotalChecks int `json:"total_checks"` - ResponseTimeNS *int64 `json:"response_time_ns,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` -} diff --git a/internal/storage/services.go b/internal/storage/services.go deleted file mode 100644 index a4cc9e1..0000000 --- a/internal/storage/services.go +++ /dev/null @@ -1,797 +0,0 @@ -package storage - -import ( - "context" - "encoding/json" - "fmt" - "time" - - "github.com/huandu/go-sqlbuilder" - "github.com/sxwebdev/sentinel/internal/utils" -) - -// GetServiceStats calculates statistics for a service -// func (o *Storage) GetServiceStats(ctx context.Context, params FindIncidentsParams) (*ServiceStats, error) { -// if params.ServiceID == "" || params.StartTime == nil { -// return nil, fmt.Errorf("service ID and start time are required for stats") -// } - -// // Get all incidents for the service since the specified time -// sb := findIncidentsBuilder(params, -// "i.id", -// "i.service_id", -// "i.start_time", -// "i.end_time", -// "i.error", -// "i.duration_ns", -// "i.resolved", -// ) - -// sql, args := sb.Build() -// rows, err := o.db.QueryContext(ctx, sql, args...) -// if err != nil { -// return nil, fmt.Errorf("failed to query incidents: %w", err) -// } -// defer rows.Close() - -// incidents := []*Incident{} -// for rows.Next() { -// var incidentRow IncidentRow -// err := rows.Scan( -// &incidentRow.ID, -// &incidentRow.ServiceID, -// &incidentRow.StartTime, -// &incidentRow.EndTime, -// &incidentRow.Error, -// &incidentRow.DurationNS, -// &incidentRow.Resolved, -// ) -// if err != nil { -// return nil, fmt.Errorf("failed to scan incident: %w", err) -// } - -// incidents = append(incidents, o.rowToIncident(&incidentRow)) -// } - -// if err := rows.Err(); err != nil { -// return nil, fmt.Errorf("error iterating rows: %w", err) -// } - -// // Calculate statistics -// totalIncidents := len(incidents) -// totalDowntime := time.Duration(0) -// resolvedIncidents := 0 - -// for _, incident := range incidents { -// if incident.Resolved && incident.Duration != nil { -// totalDowntime += *incident.Duration -// resolvedIncidents++ -// } -// } - -// // Calculate uptime percentage -// period := time.Since(*params.StartTime) -// uptimePercentage := 100.0 -// if period > 0 { -// uptimePercentage = 100.0 - (float64(totalDowntime) / float64(period) * 100.0) -// if uptimePercentage < 0 { -// uptimePercentage = 0 -// } -// } - -// // Get average response time from service state -// avgResponseTime := time.Duration(0) -// serviceState, err := o.GetServiceState(ctx, params.ServiceID) -// if err != nil { -// // If service state not found, return stats without response time -// return &ServiceStats{ -// ServiceID: params.ServiceID, -// TotalIncidents: totalIncidents, -// TotalDowntime: totalDowntime, -// UptimePercentage: uptimePercentage, -// Period: period, -// AvgResponseTime: 0, -// }, nil -// } -// if serviceState != nil && serviceState.ResponseTimeNS != nil { -// avgResponseTime = time.Duration(*serviceState.ResponseTimeNS) -// } - -// return &ServiceStats{ -// ServiceID: params.ServiceID, -// TotalIncidents: totalIncidents, -// TotalDowntime: totalDowntime, -// UptimePercentage: uptimePercentage, -// Period: period, -// AvgResponseTime: avgResponseTime, -// }, nil -// } - -// rowToIncident converts an IncidentRow to Incident -func (o *Storage) rowToIncident(row *IncidentRow) *Incident { - incident := &Incident{ - ID: row.ID, - ServiceID: row.ServiceID, - StartTime: row.StartTime, - EndTime: row.EndTime, - Error: row.Error, - Resolved: row.Resolved, - } - - if row.DurationNS != nil { - duration := time.Duration(*row.DurationNS) - incident.Duration = &duration - } - - return incident -} - -// GetServiceByID finds a service by ID using ORM -// func (o *Storage) GetServiceByID(ctx context.Context, id string) (*Service, error) { -// sb := sqlbuilder.NewSelectBuilder() -// sb.Select( -// "s.id", -// "s.name", -// "s.protocol", -// "s.interval", -// "s.timeout", -// "s.retries", -// "s.tags", -// "s.config", -// "s.is_enabled", -// "s.created_at", -// "s.updated_at", -// "count(incidents.id) as total_incidents", -// "sum(case when incidents.resolved = 0 then 1 else 0 end) as active_incidents", -// "ss.status", -// "ss.last_check", -// "ss.next_check", -// "ss.last_error", -// "ss.consecutive_fails", -// "ss.consecutive_success", -// "ss.total_checks", -// "ss.response_time_ns", -// ) -// sb.From("services s") -// sb.JoinWithOption(sqlbuilder.LeftJoin, "incidents", "s.id = incidents.service_id") -// sb.JoinWithOption(sqlbuilder.LeftJoin, "service_states ss", "s.id = ss.service_id") -// sb.Where(sb.Equal("s.id", id)) -// sb.GroupBy("s.id") - -// query, args := sb.Build() -// row := o.db.QueryRowContext(ctx, query, args...) - -// var item serviceRow -// err := row.Scan( -// &item.ID, -// &item.Name, -// &item.Protocol, -// &item.Interval, -// &item.Timeout, -// &item.Retries, -// &item.Tags, -// &item.Config, -// &item.IsEnabled, -// &item.CreatedAt, -// &item.UpdatedAt, -// &item.TotalIncidents, -// &item.ActiveIncidents, -// &item.Status, -// &item.LastCheck, -// &item.NextCheck, -// &item.LastError, -// &item.ConsecutiveFails, -// &item.ConsecutiveSuccess, -// &item.TotalChecks, -// &item.ResponseTimeNS, -// ) -// if err != nil { -// if errors.Is(err, sql.ErrNoRows) { -// return nil, ErrNotFound -// } -// return nil, fmt.Errorf("failed to scan service: %w", err) -// } - -// svc, err := rowToService(&item) -// if err != nil { -// return nil, err -// } - -// return svc, nil -// } - -// func findServicesBuilder(params FindServicesParams, col ...string) *sqlbuilder.SelectBuilder { -// sb := sqlbuilder.NewSelectBuilder() -// sb.Select(col...) -// sb.From("services s") - -// if params.Name != "" { -// sb.Where(sb.Like("s.name", "%"+params.Name+"%")) -// } - -// if params.Protocol != "" { -// sb.Where(sb.Equal("s.protocol", params.Protocol)) -// } - -// if params.IsEnabled != nil { -// sb.Where(sb.Equal("s.is_enabled", *params.IsEnabled)) -// } - -// if params.Status != "" { -// switch params.Status { -// case "up": -// sb.Where(sb.Equal("ss.status", StatusUp)) -// case "down": -// sb.Where(sb.Equal("ss.status", StatusDown)) -// } -// } - -// if len(params.Tags) > 0 { -// var tagConditions []string -// for _, tag := range params.Tags { -// tagConditions = append(tagConditions, -// fmt.Sprintf("EXISTS (SELECT 1 FROM json_each(s.tags) WHERE json_each.value = %s)", -// sb.Args.Add(tag))) -// } - -// if len(tagConditions) > 0 { -// sb.Where(fmt.Sprintf("(%s)", strings.Join(tagConditions, " AND "))) -// } -// } - -// return sb -// } - -// type FindServicesParams struct { -// Name string -// IsEnabled *bool -// Protocol string -// Tags []string -// Status string // e.g. "up", "down" -// OrderBy string -// Page *uint32 -// PageSize *uint32 -// } - -// GetAllServices finds all services using ORM -// func (o *Storage) FindServices(ctx context.Context, params FindServicesParams) (storecmn.FindResponseWithCount[*Service], error) { -// sb := findServicesBuilder( -// params, -// "s.id", -// "s.name", -// "s.protocol", -// "s.interval", -// "s.timeout", -// "s.retries", -// "s.tags", -// "s.config", -// "s.is_enabled", -// "s.created_at", -// "s.updated_at", -// "count(incidents.id) as total_incidents", -// "sum(case when incidents.resolved = 0 then 1 else 0 end) as active_incidents", -// "ss.status", -// "ss.last_check", -// "ss.next_check", -// "ss.last_error", -// "ss.consecutive_fails", -// "ss.consecutive_success", -// "ss.total_checks", -// "ss.response_time_ns", -// ) -// sb.JoinWithOption(sqlbuilder.LeftJoin, "incidents", "s.id = incidents.service_id") -// sb.JoinWithOption(sqlbuilder.LeftJoin, "service_states ss", "s.id = ss.service_id") -// sb.GroupBy("s.id") - -// if params.OrderBy != "" { -// // Add table prefix for common column names to avoid ambiguity -// orderBy := params.OrderBy -// switch orderBy { -// case "created_at": -// orderBy = "s.created_at" -// case "updated_at": -// orderBy = "s.updated_at" -// case "name": -// orderBy = "s.name" -// case "protocol": -// orderBy = "s.protocol" -// case "status": -// orderBy = "ss.status" -// case "last_check": -// orderBy = "ss.last_check" -// } -// sb.OrderBy(orderBy) -// } else { -// sb.OrderBy("s.name") -// } - -// res := storecmn.FindResponseWithCount[*Service]{} - -// limit, offset, err := storecmn.Pagination(params.Page, params.PageSize) -// if err != nil { -// return res, err -// } -// sb.Limit(int(limit)).Offset(int(offset)) - -// sql, args := sb.Build() -// rows, err := o.db.QueryContext(ctx, sql, args...) -// if err != nil { -// return res, fmt.Errorf("failed to query services: %w", err) -// } -// defer rows.Close() - -// services := []*Service{} -// for rows.Next() { -// var item serviceRow -// err := rows.Scan( -// &item.ID, -// &item.Name, -// &item.Protocol, -// &item.Interval, -// &item.Timeout, -// &item.Retries, -// &item.Tags, -// &item.Config, -// &item.IsEnabled, -// &item.CreatedAt, -// &item.UpdatedAt, -// &item.TotalIncidents, -// &item.ActiveIncidents, -// &item.Status, -// &item.LastCheck, -// &item.NextCheck, -// &item.LastError, -// &item.ConsecutiveFails, -// &item.ConsecutiveSuccess, -// &item.TotalChecks, -// &item.ResponseTimeNS, -// ) -// if err != nil { -// return res, fmt.Errorf("failed to scan service: %w", err) -// } - -// svc, err := rowToService(&item) -// if err != nil { -// return res, fmt.Errorf("failed to convert service row: %w", err) -// } - -// services = append(services, svc) -// } - -// if err := rows.Err(); err != nil { -// return res, fmt.Errorf("error iterating rows: %w", err) -// } - -// // Get total count of services -// countQuery := findServicesBuilder(params, "count(*)") -// countQuery.JoinWithOption(sqlbuilder.LeftJoin, "service_states ss", "s.id = ss.service_id") - -// countSQL, countArgs := countQuery.Build() - -// var totalCount int -// if err := o.db.QueryRowContext(ctx, countSQL, countArgs...).Scan(&totalCount); err != nil { -// return res, fmt.Errorf("failed to count services: %w", err) -// } - -// res.Count = uint32(totalCount) -// res.Items = services - -// return res, nil -// } - -// CreateService creates a new service using ORM with retry logic -// func (o *Storage) CreateService(ctx context.Context, service CreateUpdateServiceRequest) (*Service, error) { -// ib := sqlbuilder.NewInsertBuilder() -// ib.InsertInto("services") -// ib.Cols("id", "name", "protocol", "interval", "timeout", "retries", "tags", "config", "is_enabled") - -// tagsJSON, err := json.Marshal(service.Tags) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal tags: %w", err) -// } - -// configJSON, err := json.Marshal(service.Config) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal config: %w", err) -// } - -// serviceID := utils.GenerateULID() - -// ib.Values( -// serviceID, -// service.Name, -// service.Protocol, -// service.Interval.String(), -// service.Timeout.String(), -// service.Retries, -// string(tagsJSON), -// string(configJSON), -// service.IsEnabled, -// ) - -// tx, err := o.db.BeginTx(ctx, nil) -// if err != nil { -// return nil, fmt.Errorf("failed to begin transaction: %w", err) -// } -// defer tx.Rollback() - -// sql, args := ib.Build() -// _, err = tx.ExecContext(ctx, sql, args...) -// if err != nil { -// return nil, fmt.Errorf("failed to create service: %w", err) -// } - -// nextCheck := time.Now().Add(service.Interval) -// serviceState := &ServiceStateRecord{ -// ID: utils.GenerateULID(), -// ServiceID: serviceID, -// Status: StatusUnknown, -// NextCheck: &nextCheck, -// } - -// if err := o.CreateServiceState(ctx, tx, serviceState); err != nil { -// return nil, fmt.Errorf("failed to create service state: %w", err) -// } - -// // Commit transaction -// if err := tx.Commit(); err != nil { -// return nil, fmt.Errorf("failed to commit transaction: %w", err) -// } - -// return o.GetServiceByID(ctx, serviceID) -// } - -// UpdateService updates an existing service using ORM with retry logic -// func (o *Storage) UpdateService(ctx context.Context, id string, service CreateUpdateServiceRequest) (*Service, error) { -// ub := sqlbuilder.NewUpdateBuilder() -// ub.Update("services") - -// tagsJSON, err := json.Marshal(service.Tags) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal tags: %w", err) -// } - -// configJSON, err := json.Marshal(service.Config) -// if err != nil { -// return nil, fmt.Errorf("failed to marshal config: %w", err) -// } - -// // Prepare all fields for update -// assignments := []string{ -// ub.Assign("name", service.Name), -// ub.Assign("protocol", service.Protocol), -// ub.Assign("interval", service.Interval.String()), -// ub.Assign("timeout", service.Timeout.String()), -// ub.Assign("retries", service.Retries), -// ub.Assign("tags", string(tagsJSON)), -// ub.Assign("config", string(configJSON)), -// ub.Assign("is_enabled", service.IsEnabled), -// ub.Assign("updated_at", time.Now()), -// } - -// // Set all assignments at once -// ub.Set(assignments...) -// ub.Where(ub.Equal("id", id)) - -// sql, args := ub.Build() -// result, err := o.db.ExecContext(ctx, sql, args...) -// if err != nil { -// return nil, fmt.Errorf("failed to update service: %w", err) -// } - -// rowsAffected, err := result.RowsAffected() -// if err != nil { -// return nil, fmt.Errorf("failed to get rows affected: %w", err) -// } - -// if rowsAffected == 0 { -// return nil, fmt.Errorf("service not found") -// } - -// return o.GetServiceByID(ctx, id) -// } - -// DeleteService deletes a service by ID -// func (o *Storage) DeleteService(ctx context.Context, id string) error { -// // Start transaction -// tx, err := o.db.BeginTx(ctx, nil) -// if err != nil { -// return fmt.Errorf("failed to begin transaction: %w", err) -// } -// defer tx.Rollback() - -// // Delete related incidents first -// incidentsQuery := `DELETE FROM incidents WHERE service_id = ?` -// _, err = tx.ExecContext(ctx, incidentsQuery, id) -// if err != nil { -// return fmt.Errorf("failed to delete incidents: %w", err) -// } - -// // Delete service state -// stateQuery := `DELETE FROM service_states WHERE service_id = ?` -// _, err = tx.ExecContext(ctx, stateQuery, id) -// if err != nil { -// return fmt.Errorf("failed to delete service state: %w", err) -// } - -// // Delete the service -// serviceQuery := `DELETE FROM services WHERE id = ?` -// result, err := tx.ExecContext(ctx, serviceQuery, id) -// if err != nil { -// return fmt.Errorf("failed to delete service: %w", err) -// } - -// rowsAffected, err := result.RowsAffected() -// if err != nil { -// return fmt.Errorf("failed to get rows affected: %w", err) -// } - -// if rowsAffected == 0 { -// return fmt.Errorf("service not found") -// } - -// // Commit transaction -// if err := tx.Commit(); err != nil { -// return fmt.Errorf("failed to commit transaction: %w", err) -// } - -// return nil -// } - -// Service state management methods - -// GetServiceState gets service state by service ID -// func (o *Storage) GetServiceState(ctx context.Context, serviceID string) (*ServiceStateRecord, error) { -// query := ` -// SELECT id, service_id, status, last_check, next_check, last_error, -// consecutive_fails, consecutive_success, total_checks, response_time_ns, -// created_at, updated_at -// FROM service_states -// WHERE service_id = ? -// ` - -// var state ServiceStateRecord -// err := o.db.QueryRowContext(ctx, query, serviceID).Scan( -// &state.ID, -// &state.ServiceID, -// &state.Status, -// &state.LastCheck, -// &state.NextCheck, -// &state.LastError, -// &state.ConsecutiveFails, -// &state.ConsecutiveSuccess, -// &state.TotalChecks, -// &state.ResponseTimeNS, -// &state.CreatedAt, -// &state.UpdatedAt, -// ) -// if err != nil { -// if err == sql.ErrNoRows { -// return nil, nil -// } -// return nil, fmt.Errorf("failed to get service state: %w", err) -// } - -// return &state, nil -// } - -// CreateServiceState creates a new service state -// func (o *Storage) CreateServiceState(ctx context.Context, tx *sql.Tx, state *ServiceStateRecord) error { -// query := ` -// INSERT INTO service_states ( -// id, service_id, status, last_check, next_check, last_error, -// consecutive_fails, consecutive_success, total_checks, response_time_ns -// ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) -// ` - -// _, err := tx.ExecContext(ctx, query, -// state.ID, -// state.ServiceID, -// state.Status, -// state.LastCheck, -// state.NextCheck, -// state.LastError, -// state.ConsecutiveFails, -// state.ConsecutiveSuccess, -// state.TotalChecks, -// state.ResponseTimeNS, -// ) -// if err != nil { -// return fmt.Errorf("failed to create service state: %w", err) -// } -// return nil -// } - -// UpdateServiceState updates or creates service state -func (o *Storage) UpdateServiceState(ctx context.Context, params *ServiceStateRecord) error { - ub := sqlbuilder.NewUpdateBuilder() - ub.Update("service_states") - ub.Set( - ub.Assign("status", params.Status), - ub.Assign("last_check", params.LastCheck), - ub.Assign("next_check", params.NextCheck), - ub.Assign("last_error", params.LastError), - ub.Assign("consecutive_fails", params.ConsecutiveFails), - ub.Assign("consecutive_success", params.ConsecutiveSuccess), - ub.Assign("total_checks", params.TotalChecks), - ub.Assign("response_time_ns", params.ResponseTimeNS), - ub.Assign("updated_at", time.Now()), - ) - - ub.Where(ub.Equal("id", params.ID)) - - query, args := ub.Build() - if _, err := o.db.ExecContext(ctx, query, args...); err != nil { - return err - } - - return nil -} - -// GetAllServiceStates gets all service states -// func (o *Storage) GetAllServiceStates(ctx context.Context) ([]*ServiceStateRecord, error) { -// query := ` -// SELECT id, service_id, status, last_check, next_check, last_error, -// consecutive_fails, consecutive_success, total_checks, response_time_ns, -// created_at, updated_at -// FROM service_states -// ORDER BY updated_at DESC -// ` - -// rows, err := o.db.QueryContext(ctx, query) -// if err != nil { -// return nil, fmt.Errorf("failed to query service states: %w", err) -// } -// defer rows.Close() - -// states := []*ServiceStateRecord{} -// for rows.Next() { -// var state ServiceStateRecord -// err := rows.Scan( -// &state.ID, &state.ServiceID, &state.Status, &state.LastCheck, &state.NextCheck, -// &state.LastError, &state.ConsecutiveFails, &state.ConsecutiveSuccess, -// &state.TotalChecks, &state.ResponseTimeNS, &state.CreatedAt, &state.UpdatedAt, -// ) -// if err != nil { -// return nil, fmt.Errorf("failed to scan service state: %w", err) -// } -// states = append(states, &state) -// } - -// if err = rows.Err(); err != nil { -// return nil, fmt.Errorf("error iterating service states: %w", err) -// } - -// return states, nil -// } - -// DeleteServiceState deletes service state by service ID -func (o *Storage) DeleteServiceState(ctx context.Context, serviceID string) error { - query := `DELETE FROM service_states WHERE service_id = ?` - _, err := o.db.ExecContext(ctx, query, serviceID) - if err != nil { - return fmt.Errorf("failed to delete service state: %w", err) - } - return nil -} - -func (o *Storage) GetAllTags(ctx context.Context) ([]string, error) { - sb := sqlbuilder.NewSelectBuilder() - sb.Select("DISTINCT json_each.value") - sb.From("services, json_each(tags)") - sb.OrderBy("json_each.value") - - sql, args := sb.Build() - rows, err := o.db.QueryContext(ctx, sql, args...) - if err != nil { - return nil, fmt.Errorf("failed to query tags: %w", err) - } - defer rows.Close() - - var tags []string - for rows.Next() { - var tag string - if err := rows.Scan(&tag); err != nil { - return nil, fmt.Errorf("failed to scan tag: %w", err) - } - tags = append(tags, tag) - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating rows: %w", err) - } - - return tags, nil -} - -func (o *Storage) GetAllTagsWithCount(ctx context.Context) (map[string]int, error) { - sb := sqlbuilder.NewSelectBuilder() - sb.Select("json_each.value, COUNT(*)") - sb.From("services, json_each(tags)") - sb.GroupBy("json_each.value") - sb.OrderBy("json_each.value") - - sql, args := sb.Build() - rows, err := o.db.QueryContext(ctx, sql, args...) - if err != nil { - return nil, fmt.Errorf("failed to query tags with count: %w", err) - } - defer rows.Close() - - tagCounts := make(map[string]int) - for rows.Next() { - var tag string - var count int - if err := rows.Scan(&tag, &count); err != nil { - return nil, fmt.Errorf("failed to scan tag with count: %w", err) - } - tagCounts[tag] = count - } - - if err := rows.Err(); err != nil { - return nil, fmt.Errorf("error iterating rows: %w", err) - } - - return tagCounts, nil -} - -// rowToService converts a ServiceRow to Service -func rowToService(row *serviceRow) (*Service, error) { - interval, err := time.ParseDuration(row.Interval) - if err != nil { - return nil, fmt.Errorf("failed to parse interval: %w", err) - } - - timeout, err := time.ParseDuration(row.Timeout) - if err != nil { - return nil, fmt.Errorf("failed to parse timeout: %w", err) - } - - var tags []string - if err := json.Unmarshal([]byte(row.Tags), &tags); err != nil { - return nil, fmt.Errorf("failed to unmarshal tags: %w", err) - } - - var config map[string]any - if err := json.Unmarshal([]byte(row.Config), &config); err != nil { - return nil, fmt.Errorf("failed to unmarshal config: %w", err) - } - - svc := &Service{ - ID: row.ID, - Name: row.Name, - Protocol: ServiceProtocolType(row.Protocol), - Interval: interval, - Timeout: timeout, - Retries: row.Retries, - Tags: tags, - Config: config, - IsEnabled: row.IsEnabled, - CreatedAt: row.CreatedAt, - UpdatedAt: row.UpdatedAt, - TotalIncidents: row.TotalIncidents, - ActiveIncidents: row.ActiveIncidents, - Status: row.Status, - LastCheck: row.LastCheck, - NextCheck: row.NextCheck, - LastError: row.LastError, - ConsecutiveFails: row.ConsecutiveFails, - ConsecutiveSuccess: row.ConsecutiveSuccess, - TotalChecks: row.TotalChecks, - } - - if row.ResponseTimeNS != nil { - svc.ResponseTime = utils.Pointer(time.Duration(*row.ResponseTimeNS)) - } - - return svc, nil -} - -// durationToNS converts a duration pointer to nanoseconds -func durationToNS(d *time.Duration) *int64 { - if d == nil { - return nil - } - ns := d.Nanoseconds() - return &ns -} diff --git a/internal/store/repos/repo_incidents/constants_gen.go b/internal/store/repos/repo_incidents/constants_gen.go index 7a0af43..6255112 100755 --- a/internal/store/repos/repo_incidents/constants_gen.go +++ b/internal/store/repos/repo_incidents/constants_gen.go @@ -39,15 +39,15 @@ func (s ColumnNames) Strings() []string { } const ( - ColumnNameIncidentsId ColumnName = "id" - ColumnNameIncidentsServiceId ColumnName = "service_id" - ColumnNameIncidentsStartTime ColumnName = "start_time" - ColumnNameIncidentsEndTime ColumnName = "end_time" - ColumnNameIncidentsError ColumnName = "error" - ColumnNameIncidentsDurationNs ColumnName = "duration_ns" - ColumnNameIncidentsResolved ColumnName = "resolved" - ColumnNameIncidentsCreatedAt ColumnName = "created_at" - ColumnNameIncidentsUpdatedAt ColumnName = "updated_at" + ColumnNameIncidentsId ColumnName = "id" + ColumnNameIncidentsServiceId ColumnName = "service_id" + ColumnNameIncidentsStartTime ColumnName = "start_time" + ColumnNameIncidentsEndTime ColumnName = "end_time" + ColumnNameIncidentsError ColumnName = "error" + ColumnNameIncidentsDuration ColumnName = "duration" + ColumnNameIncidentsResolved ColumnName = "resolved" + ColumnNameIncidentsCreatedAt ColumnName = "created_at" + ColumnNameIncidentsUpdatedAt ColumnName = "updated_at" ) func IncidentsColumnNames() ColumnNames { @@ -57,7 +57,7 @@ func IncidentsColumnNames() ColumnNames { ColumnNameIncidentsStartTime, ColumnNameIncidentsEndTime, ColumnNameIncidentsError, - ColumnNameIncidentsDurationNs, + ColumnNameIncidentsDuration, ColumnNameIncidentsResolved, ColumnNameIncidentsCreatedAt, ColumnNameIncidentsUpdatedAt, diff --git a/internal/store/repos/repo_incidents/incidents.sql.go b/internal/store/repos/repo_incidents/incidents.sql.go index 3159cdf..fcc59b1 100644 --- a/internal/store/repos/repo_incidents/incidents.sql.go +++ b/internal/store/repos/repo_incidents/incidents.sql.go @@ -8,6 +8,8 @@ package repo_incidents import ( "context" "time" + + "github.com/sxwebdev/sentinel/internal/models" ) const deleteByServiceID = `-- name: DeleteByServiceID :exec @@ -19,11 +21,65 @@ func (q *Queries) DeleteByServiceID(ctx context.Context, serviceID string) error return err } +const getAllUnresolvedByServiceID = `-- name: GetAllUnresolvedByServiceID :many +SELECT id, service_id, start_time, end_time, error, duration, resolved, created_at, updated_at FROM incidents +WHERE service_id=? AND NOT resolved +ORDER BY start_time DESC +` + +func (q *Queries) GetAllUnresolvedByServiceID(ctx context.Context, serviceID string) ([]*models.Incident, error) { + rows, err := q.db.QueryContext(ctx, getAllUnresolvedByServiceID, serviceID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*models.Incident{} + for rows.Next() { + var i models.Incident + if err := rows.Scan( + &i.ID, + &i.ServiceID, + &i.StartTime, + &i.EndTime, + &i.Error, + &i.Duration, + &i.Resolved, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const resolveByID = `-- name: ResolveByID :exec +UPDATE incidents +SET + resolved = TRUE, + end_time = CURRENT_TIMESTAMP, + duration = CAST((julianday('now') - julianday(start_time)) * 86400000 AS INTEGER), + updated_at = CURRENT_TIMESTAMP +WHERE id=? AND NOT resolved +` + +func (q *Queries) ResolveByID(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, resolveByID, id) + return err +} + const statsByServiceID = `-- name: StatsByServiceID :one SELECT COUNT(*) AS total_incidents, - SUM(duration_ns) AS total_downtime, - AVG(duration_ns) AS avg_downtime, + SUM(duration) AS total_downtime, + AVG(duration) AS avg_downtime, SUM(CASE WHEN resolved THEN 1 ELSE 0 END) AS resolved_incidents, SUM(CASE WHEN NOT resolved THEN 1 ELSE 0 END) AS unresolved_incidents FROM incidents diff --git a/internal/store/repos/repo_incidents/incidents_gen.sql.go b/internal/store/repos/repo_incidents/incidents_gen.sql.go index 4636799..da625f4 100644 --- a/internal/store/repos/repo_incidents/incidents_gen.sql.go +++ b/internal/store/repos/repo_incidents/incidents_gen.sql.go @@ -7,37 +7,18 @@ package repo_incidents import ( "context" - "time" "github.com/sxwebdev/sentinel/internal/models" ) const create = `-- name: Create :one -INSERT INTO incidents (id, service_id, start_time, end_time, error, duration_ns, resolved) - VALUES (?, ?, ?, ?, ?, ?, ?) - RETURNING id, service_id, start_time, end_time, error, duration_ns, resolved, created_at, updated_at +INSERT INTO incidents (id, service_id, start_time, error) + VALUES (?, ?, CURRENT_TIMESTAMP, ?3) + RETURNING id, service_id, start_time, end_time, error, duration, resolved, created_at, updated_at ` -type CreateParams struct { - ID string `db:"id" json:"id"` - ServiceID string `db:"service_id" json:"service_id"` - StartTime time.Time `db:"start_time" json:"start_time"` - EndTime *time.Time `db:"end_time" json:"end_time"` - Error string `db:"error" json:"error"` - DurationNs *int64 `db:"duration_ns" json:"duration_ns"` - Resolved bool `db:"resolved" json:"resolved"` -} - -func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.Incident, error) { - row := q.db.QueryRowContext(ctx, create, - arg.ID, - arg.ServiceID, - arg.StartTime, - arg.EndTime, - arg.Error, - arg.DurationNs, - arg.Resolved, - ) +func (q *Queries) Create(ctx context.Context, iD string, serviceID string, incidentError string) (*models.Incident, error) { + row := q.db.QueryRowContext(ctx, create, iD, serviceID, incidentError) var i models.Incident err := row.Scan( &i.ID, @@ -45,7 +26,7 @@ func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.Inciden &i.StartTime, &i.EndTime, &i.Error, - &i.DurationNs, + &i.Duration, &i.Resolved, &i.CreatedAt, &i.UpdatedAt, @@ -63,7 +44,7 @@ func (q *Queries) Delete(ctx context.Context, id string) error { } const getByID = `-- name: GetByID :one -SELECT id, service_id, start_time, end_time, error, duration_ns, resolved, created_at, updated_at FROM incidents WHERE id=? LIMIT 1 +SELECT id, service_id, start_time, end_time, error, duration, resolved, created_at, updated_at FROM incidents WHERE id=? LIMIT 1 ` func (q *Queries) GetByID(ctx context.Context, id string) (*models.Incident, error) { @@ -75,7 +56,7 @@ func (q *Queries) GetByID(ctx context.Context, id string) (*models.Incident, err &i.StartTime, &i.EndTime, &i.Error, - &i.DurationNs, + &i.Duration, &i.Resolved, &i.CreatedAt, &i.UpdatedAt, diff --git a/internal/store/repos/repo_incidents/querier.go b/internal/store/repos/repo_incidents/querier.go index 6ead2c3..6a74db3 100644 --- a/internal/store/repos/repo_incidents/querier.go +++ b/internal/store/repos/repo_incidents/querier.go @@ -12,10 +12,12 @@ import ( ) type Querier interface { - Create(ctx context.Context, arg CreateParams) (*models.Incident, error) + Create(ctx context.Context, iD string, serviceID string, incidentError string) (*models.Incident, error) Delete(ctx context.Context, id string) error DeleteByServiceID(ctx context.Context, serviceID string) error + GetAllUnresolvedByServiceID(ctx context.Context, serviceID string) ([]*models.Incident, error) GetByID(ctx context.Context, id string) (*models.Incident, error) + ResolveByID(ctx context.Context, id string) error StatsByServiceID(ctx context.Context, serviceID string, startTime time.Time) (*StatsByServiceIDRow, error) } diff --git a/internal/store/repos/repo_incidents/types.go b/internal/store/repos/repo_incidents/types.go index cfa81e3..fda7e16 100644 --- a/internal/store/repos/repo_incidents/types.go +++ b/internal/store/repos/repo_incidents/types.go @@ -1,24 +1,22 @@ package repo_incidents -import "time" - type StatsByServiceID struct { TotalIncidents int64 - TotalDowntime time.Duration - AvgDowntime time.Duration + TotalDowntime int64 + AvgDowntime int64 ResolvedIncidents int64 UnresolvedIncidents int64 } func (s StatsByServiceIDRow) ToDomain() *StatsByServiceID { - var totalDowntime time.Duration + var totalDowntime int64 if s.TotalDowntime != nil { - totalDowntime = time.Duration(*s.TotalDowntime) + totalDowntime = int64(*s.TotalDowntime) } - var avgDowntime time.Duration + var avgDowntime int64 if s.AvgDowntime != nil { - avgDowntime = time.Duration(*s.AvgDowntime) + avgDowntime = int64(*s.AvgDowntime) } var resolvedIncidents int64 diff --git a/internal/store/repos/repo_service_states/constants_gen.go b/internal/store/repos/repo_service_states/constants_gen.go index 0baf26d..90c8a4e 100755 --- a/internal/store/repos/repo_service_states/constants_gen.go +++ b/internal/store/repos/repo_service_states/constants_gen.go @@ -48,7 +48,7 @@ const ( ColumnNameServiceStatesConsecutiveFails ColumnName = "consecutive_fails" ColumnNameServiceStatesConsecutiveSuccess ColumnName = "consecutive_success" ColumnNameServiceStatesTotalChecks ColumnName = "total_checks" - ColumnNameServiceStatesResponseTimeNs ColumnName = "response_time_ns" + ColumnNameServiceStatesResponseTime ColumnName = "response_time" ColumnNameServiceStatesCreatedAt ColumnName = "created_at" ColumnNameServiceStatesUpdatedAt ColumnName = "updated_at" ) @@ -64,7 +64,7 @@ func ServiceStatesColumnNames() ColumnNames { ColumnNameServiceStatesConsecutiveFails, ColumnNameServiceStatesConsecutiveSuccess, ColumnNameServiceStatesTotalChecks, - ColumnNameServiceStatesResponseTimeNs, + ColumnNameServiceStatesResponseTime, ColumnNameServiceStatesCreatedAt, ColumnNameServiceStatesUpdatedAt, } diff --git a/internal/store/repos/repo_service_states/service_states.sql.go b/internal/store/repos/repo_service_states/service_states.sql.go index 0b9771f..5b30646 100644 --- a/internal/store/repos/repo_service_states/service_states.sql.go +++ b/internal/store/repos/repo_service_states/service_states.sql.go @@ -21,7 +21,7 @@ func (q *Queries) DeleteByServiceID(ctx context.Context, serviceID string) error } const getByServiceID = `-- name: GetByServiceID :one -SELECT id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time_ns, created_at, updated_at FROM service_states WHERE service_id=? LIMIT 1 +SELECT id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time, created_at, updated_at FROM service_states WHERE service_id=? LIMIT 1 ` func (q *Queries) GetByServiceID(ctx context.Context, serviceID string) (*models.ServiceState, error) { @@ -37,7 +37,7 @@ func (q *Queries) GetByServiceID(ctx context.Context, serviceID string) (*models &i.ConsecutiveFails, &i.ConsecutiveSuccess, &i.TotalChecks, - &i.ResponseTimeNs, + &i.ResponseTime, &i.CreatedAt, &i.UpdatedAt, ) diff --git a/internal/store/repos/repo_service_states/service_states_gen.sql.go b/internal/store/repos/repo_service_states/service_states_gen.sql.go index e7b5ac6..f1d9afa 100644 --- a/internal/store/repos/repo_service_states/service_states_gen.sql.go +++ b/internal/store/repos/repo_service_states/service_states_gen.sql.go @@ -13,9 +13,9 @@ import ( ) const create = `-- name: Create :one -INSERT INTO service_states (id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time_ns) +INSERT INTO service_states (id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - RETURNING id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time_ns, created_at, updated_at + RETURNING id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time, created_at, updated_at ` type CreateParams struct { @@ -28,7 +28,7 @@ type CreateParams struct { ConsecutiveFails int64 `db:"consecutive_fails" json:"consecutive_fails"` ConsecutiveSuccess int64 `db:"consecutive_success" json:"consecutive_success"` TotalChecks int64 `db:"total_checks" json:"total_checks"` - ResponseTimeNs *int64 `db:"response_time_ns" json:"response_time_ns"` + ResponseTime *int64 `db:"response_time" json:"response_time"` } func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.ServiceState, error) { @@ -42,7 +42,7 @@ func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.Service arg.ConsecutiveFails, arg.ConsecutiveSuccess, arg.TotalChecks, - arg.ResponseTimeNs, + arg.ResponseTime, ) var i models.ServiceState err := row.Scan( @@ -55,7 +55,7 @@ func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.Service &i.ConsecutiveFails, &i.ConsecutiveSuccess, &i.TotalChecks, - &i.ResponseTimeNs, + &i.ResponseTime, &i.CreatedAt, &i.UpdatedAt, ) @@ -72,7 +72,7 @@ func (q *Queries) Delete(ctx context.Context, id string) error { } const getAll = `-- name: GetAll :many -SELECT id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time_ns, created_at, updated_at FROM service_states +SELECT id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time, created_at, updated_at FROM service_states ` func (q *Queries) GetAll(ctx context.Context) ([]*models.ServiceState, error) { @@ -94,7 +94,7 @@ func (q *Queries) GetAll(ctx context.Context) ([]*models.ServiceState, error) { &i.ConsecutiveFails, &i.ConsecutiveSuccess, &i.TotalChecks, - &i.ResponseTimeNs, + &i.ResponseTime, &i.CreatedAt, &i.UpdatedAt, ); err != nil { @@ -112,7 +112,7 @@ func (q *Queries) GetAll(ctx context.Context) ([]*models.ServiceState, error) { } const getByID = `-- name: GetByID :one -SELECT id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time_ns, created_at, updated_at FROM service_states WHERE id=? LIMIT 1 +SELECT id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time, created_at, updated_at FROM service_states WHERE id=? LIMIT 1 ` func (q *Queries) GetByID(ctx context.Context, id string) (*models.ServiceState, error) { @@ -128,7 +128,7 @@ func (q *Queries) GetByID(ctx context.Context, id string) (*models.ServiceState, &i.ConsecutiveFails, &i.ConsecutiveSuccess, &i.TotalChecks, - &i.ResponseTimeNs, + &i.ResponseTime, &i.CreatedAt, &i.UpdatedAt, ) diff --git a/internal/store/repos/repo_services/custom.go b/internal/store/repos/repo_services/custom.go index 0d4bc1d..4116580 100644 --- a/internal/store/repos/repo_services/custom.go +++ b/internal/store/repos/repo_services/custom.go @@ -13,6 +13,8 @@ type ICustomQuerier interface { GetViewByID(ctx context.Context, id string) (*models.ServiceFullView, error) Update(ctx context.Context, id string, service UpdateServiceRequest) (*models.ServiceFullView, error) FindView(ctx context.Context, params FindParams) (*storecmn.FindResponseWithCount[*models.ServiceFullView], error) + GetAllTags(ctx context.Context) ([]string, error) + GetAllTagsWithCount(ctx context.Context) (map[string]int, error) } type CustomQueries struct { diff --git a/internal/store/repos/repo_services/find.go b/internal/store/repos/repo_services/find.go index a5de9a6..a8d98dc 100644 --- a/internal/store/repos/repo_services/find.go +++ b/internal/store/repos/repo_services/find.go @@ -88,7 +88,7 @@ func (s *CustomQueries) FindView(ctx context.Context, params FindParams) (*store "ss.consecutive_fails", "ss.consecutive_success", "ss.total_checks", - "ss.response_time_ns", + "ss.response_time", ) sb.JoinWithOption(sqlbuilder.LeftJoin, "incidents", "s.id = incidents.service_id") sb.JoinWithOption(sqlbuilder.LeftJoin, "service_states ss", "s.id = ss.service_id") diff --git a/internal/store/repos/repo_services/get.go b/internal/store/repos/repo_services/get.go index 8bc2ed3..348858a 100644 --- a/internal/store/repos/repo_services/get.go +++ b/internal/store/repos/repo_services/get.go @@ -36,7 +36,7 @@ func (s *CustomQueries) GetViewByID(ctx context.Context, id string) (*models.Ser "ss.consecutive_fails", "ss.consecutive_success", "ss.total_checks", - "ss.response_time_ns", + "ss.response_time", ) sb.From("services s") sb.JoinWithOption(sqlbuilder.LeftJoin, "incidents", "s.id = incidents.service_id") diff --git a/internal/store/repos/repo_services/tags.go b/internal/store/repos/repo_services/tags.go new file mode 100644 index 0000000..4347eaa --- /dev/null +++ b/internal/store/repos/repo_services/tags.go @@ -0,0 +1,68 @@ +package repo_services + +import ( + "context" + "fmt" + + "github.com/huandu/go-sqlbuilder" +) + +func (o *CustomQueries) GetAllTags(ctx context.Context) ([]string, error) { + sb := sqlbuilder.NewSelectBuilder() + sb.Select("DISTINCT json_each.value") + sb.From("services, json_each(tags)") + sb.OrderBy("json_each.value") + + sql, args := sb.Build() + rows, err := o.db.QueryContext(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("failed to query tags: %w", err) + } + defer rows.Close() + + var tags []string + for rows.Next() { + var tag string + if err := rows.Scan(&tag); err != nil { + return nil, fmt.Errorf("failed to scan tag: %w", err) + } + tags = append(tags, tag) + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating rows: %w", err) + } + + return tags, nil +} + +func (o *CustomQueries) GetAllTagsWithCount(ctx context.Context) (map[string]int, error) { + sb := sqlbuilder.NewSelectBuilder() + sb.Select("json_each.value, COUNT(*)") + sb.From("services, json_each(tags)") + sb.GroupBy("json_each.value") + sb.OrderBy("json_each.value") + + sql, args := sb.Build() + rows, err := o.db.QueryContext(ctx, sql, args...) + if err != nil { + return nil, fmt.Errorf("failed to query tags with count: %w", err) + } + defer rows.Close() + + tagCounts := make(map[string]int) + for rows.Next() { + var tag string + var count int + if err := rows.Scan(&tag, &count); err != nil { + return nil, fmt.Errorf("failed to scan tag with count: %w", err) + } + tagCounts[tag] = count + } + + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("error iterating rows: %w", err) + } + + return tagCounts, nil +} diff --git a/internal/store/repos/repo_services/types.go b/internal/store/repos/repo_services/types.go index 16a9a0e..3798b5e 100644 --- a/internal/store/repos/repo_services/types.go +++ b/internal/store/repos/repo_services/types.go @@ -30,7 +30,7 @@ type itemViewRow struct { ConsecutiveFails int ConsecutiveSuccess int TotalChecks int - ResponseTimeNS *int64 + ResponseTime *int64 } // rowToModel converts a ServiceRow to Service @@ -78,8 +78,8 @@ func rowToModel(row *itemViewRow) (*models.ServiceFullView, error) { TotalChecks: row.TotalChecks, } - if row.ResponseTimeNS != nil { - svc.ResponseTime = utils.Pointer(time.Duration(*row.ResponseTimeNS)) + if row.ResponseTime != nil { + svc.ResponseTime = utils.Pointer(time.Duration(*row.ResponseTime)) } return svc, nil diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 2f9e3ab..2d92ab8 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -178,7 +178,6 @@ func (s *Server) setupRoutes() { api.Put("/services/:id", s.handleAPIUpdateService) api.Delete("/services/:id", s.handleAPIDeleteService) api.Post("/services/:id/check", s.handleAPIServiceCheck) - api.Post("/services/:id/resolve", s.handleAPIServiceResolve) // Service detail API api.Get("/services/:id", s.handleAPIServiceDetail) @@ -542,7 +541,7 @@ func (s *Server) handleAPIServiceCheck(c *fiber.Ctx) error { return newErrorResponse(c, fiber.StatusNotFound, storecmn.ErrNotFound) } - err = s.monitorService.TriggerCheck(c.Context(), serviceID) + err = s.baseServices.Services().TriggerCheck(c.Context(), serviceID) if err != nil { return newErrorResponse(c, fiber.StatusInternalServerError, err) } @@ -550,32 +549,6 @@ func (s *Server) handleAPIServiceCheck(c *fiber.Ctx) error { return newSuccessResponse(c, "check triggered successfully") } -// handleAPIServiceResolve resolves a service incident -// -// @Summary Resolve service incidents -// @Description Forcefully resolves all active incidents for a service -// @Tags incidents -// @Accept json -// @Produce json -// @Param id path string true "Service ID" -// @Success 200 {object} SuccessResponse "Incidents resolved successfully" -// @Failure 400 {object} ErrorResponse "Bad request" -// @Failure 500 {object} ErrorResponse "Internal server error" -// @Router /services/{id}/resolve [post] -func (s *Server) handleAPIServiceResolve(c *fiber.Ctx) error { - serviceID := c.Params("id") - if serviceID == "" { - return newErrorResponse(c, fiber.StatusBadRequest, ErrServiceIDRequired) - } - - err := s.monitorService.ForceResolveIncidents(c.Context(), serviceID) - if err != nil { - return newErrorResponse(c, fiber.StatusInternalServerError, err) - } - - return newSuccessResponse(c, "incidents resolved successfully") -} - // handleFindIncidents returns recent incidents // // @Summary Get recent incidents @@ -645,29 +618,13 @@ func (s *Server) handleFindIncidents(c *fiber.Ctx) error { // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /services/{id}/incidents/{incidentId} [delete] func (s *Server) handleAPIDeleteIncident(c *fiber.Ctx) error { - serviceID := c.Params("id") incidentID := c.Params("incidentId") - - if serviceID == "" { - return newErrorResponse(c, fiber.StatusBadRequest, ErrServiceIDRequired) - } - if incidentID == "" { return newErrorResponse(c, fiber.StatusBadRequest, ErrIncidentIDRequired) } - // Check if service exists - exists, err := s.baseServices.Services().Exists(c.Context(), serviceID) - if err != nil { - return newErrorResponse(c, fiber.StatusInternalServerError, err) - } - - if !exists { - return newErrorResponse(c, fiber.StatusNotFound, storecmn.ErrNotFound) - } - // Delete incident - err = s.monitorService.DeleteIncident(c.Context(), serviceID, incidentID) + err := s.baseServices.Incidents().Delete(c.Context(), incidentID) if err != nil { return newErrorResponse(c, fiber.StatusInternalServerError, err) } diff --git a/internal/web/helpers.go b/internal/web/helpers.go index 3f5126d..255edf2 100644 --- a/internal/web/helpers.go +++ b/internal/web/helpers.go @@ -122,8 +122,8 @@ func (s *Server) getDashboardStats(ctx context.Context) (*DashboardStats, error) } // Add response time to total (only from services that have response time data) - if serviceState.ResponseTimeNs != nil && *serviceState.ResponseTimeNs > 0 { - totalResponseTimeMs += *serviceState.ResponseTimeNs / 1000000 // Convert to milliseconds + if serviceState.ResponseTime != nil && *serviceState.ResponseTime > 0 { + totalResponseTimeMs += *serviceState.ResponseTime responseTimeCount++ } totalChecks += serviceState.TotalChecks diff --git a/internal/web/tags.go b/internal/web/tags.go index ecac484..be1b4ed 100644 --- a/internal/web/tags.go +++ b/internal/web/tags.go @@ -15,7 +15,7 @@ import ( // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /tags [get] func (h *Server) handleGetAllTags(c *fiber.Ctx) error { - tags, err := h.storage.GetAllTags(c.Context()) + tags, err := h.baseServices.Services().GetAllTags(c.Context()) if err != nil { return newErrorResponse(c, fiber.StatusInternalServerError, err) } @@ -33,7 +33,7 @@ func (h *Server) handleGetAllTags(c *fiber.Ctx) error { // @Failure 500 {object} ErrorResponse "Internal server error" // @Router /tags/count [get] func (h *Server) handleGetAllTagsWithCount(c *fiber.Ctx) error { - tagsWithCount, err := h.storage.GetAllTagsWithCount(c.Context()) + tagsWithCount, err := h.baseServices.Services().GetAllTagsWithCount(c.Context()) if err != nil { return newErrorResponse(c, fiber.StatusInternalServerError, err) } diff --git a/pgxgen.yaml b/pgxgen.yaml index 0559e96..8467be5 100644 --- a/pgxgen.yaml +++ b/pgxgen.yaml @@ -80,10 +80,14 @@ sqlc: create: skip_columns: - created_at + - end_time + - duration + - resolved - updated_at returning: "*" column_values: - created_at: now() + start_time: CURRENT_TIMESTAMP + error: sqlc.arg(incident_error) delete: get: name: GetByID diff --git a/sql/migrations/2_agents.up.sql b/sql/migrations/2_agents.up.sql index 1b96255..6abf817 100644 --- a/sql/migrations/2_agents.up.sql +++ b/sql/migrations/2_agents.up.sql @@ -24,3 +24,11 @@ CREATE TABLE IF NOT EXISTS agents ( -- Create indexes for performance CREATE INDEX IF NOT EXISTS idx_agents_name ON agents(name); CREATE INDEX IF NOT EXISTS idx_agents_enabled ON agents(is_enabled); + +-- Update incidents duration. Converting from nanoseconds to milliseconds and renaming the column to duration +UPDATE incidents SET duration_ns = duration_ns / 1000000 WHERE duration_ns IS NOT NULL AND duration_ns > 0; +ALTER TABLE incidents RENAME COLUMN duration_ns TO duration; + +-- Update service_states response_time_ns to response_time (from nanoseconds to milliseconds) +UPDATE service_states SET response_time_ns = response_time_ns / 1000000 WHERE response_time_ns IS NOT NULL AND response_time_ns > 0; +ALTER TABLE service_states RENAME COLUMN response_time_ns TO response_time; diff --git a/sql/queries/incidents/incidents.sql b/sql/queries/incidents/incidents.sql index 7b3e864..811dc01 100755 --- a/sql/queries/incidents/incidents.sql +++ b/sql/queries/incidents/incidents.sql @@ -4,9 +4,23 @@ DELETE FROM incidents WHERE service_id=?; -- name: StatsByServiceID :one SELECT COUNT(*) AS total_incidents, - SUM(duration_ns) AS total_downtime, - AVG(duration_ns) AS avg_downtime, + SUM(duration) AS total_downtime, + AVG(duration) AS avg_downtime, SUM(CASE WHEN resolved THEN 1 ELSE 0 END) AS resolved_incidents, SUM(CASE WHEN NOT resolved THEN 1 ELSE 0 END) AS unresolved_incidents FROM incidents WHERE service_id=? AND start_time >= ?; + +-- name: GetAllUnresolvedByServiceID :many +SELECT * FROM incidents +WHERE service_id=? AND NOT resolved +ORDER BY start_time DESC; + +-- name: ResolveByID :exec +UPDATE incidents +SET + resolved = TRUE, + end_time = CURRENT_TIMESTAMP, + duration = CAST((julianday('now') - julianday(start_time)) * 86400000 AS INTEGER), + updated_at = CURRENT_TIMESTAMP +WHERE id=? AND NOT resolved; diff --git a/sql/queries/incidents/incidents_gen.sql b/sql/queries/incidents/incidents_gen.sql index 53953cb..25398eb 100755 --- a/sql/queries/incidents/incidents_gen.sql +++ b/sql/queries/incidents/incidents_gen.sql @@ -1,6 +1,6 @@ -- name: Create :one -INSERT INTO incidents (id, service_id, start_time, end_time, error, duration_ns, resolved) - VALUES (?, ?, ?, ?, ?, ?, ?) +INSERT INTO incidents (id, service_id, start_time, error) + VALUES (?, ?, CURRENT_TIMESTAMP, sqlc.arg(incident_error)) RETURNING *; -- name: Delete :exec diff --git a/sql/queries/service_states/service_states_gen.sql b/sql/queries/service_states/service_states_gen.sql index 68b4ae4..1ff981c 100755 --- a/sql/queries/service_states/service_states_gen.sql +++ b/sql/queries/service_states/service_states_gen.sql @@ -1,5 +1,5 @@ -- name: Create :one -INSERT INTO service_states (id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time_ns) +INSERT INTO service_states (id, service_id, status, last_check, next_check, last_error, consecutive_fails, consecutive_success, total_checks, response_time) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *; From 6291fd20f207b041d43cbecb52b7aa3de6b9825a Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Fri, 19 Sep 2025 21:27:06 +0300 Subject: [PATCH 11/71] feat: Add service statistics and enable fetching of all enabled services - Implemented GetAllEnabled method in the Service struct to retrieve all enabled services. - Refactored Stats method in the Service struct to calculate uptime percentage using incidents statistics. - Added Stats method in the ServiceState struct to aggregate statistics about service states. - Introduced new SQL queries for fetching incident and service state statistics. - Updated the DashboardStats struct to include new statistics fields. - Enhanced the getDashboardStats method to calculate average uptime and other metrics. - Created custom repository methods for incidents to support advanced querying and statistics retrieval. - Updated relevant handlers and DTOs to accommodate new statistics and service fetching capabilities. --- frontend/src/pages/dashboard/dashboard.tsx | 2 +- .../pages/service/components/serviceStats.tsx | 4 +- go.mod | 2 +- internal/monitor/monitor.go | 77 ---- internal/services/incidents/methods.go | 28 ++ internal/services/service/methods.go | 5 + internal/services/service/stats.go | 14 +- internal/services/servicestate/methods.go | 5 + internal/storage/incidents.go | 334 +++++++++--------- internal/store/repos/repo_incidents/custom.go | 35 ++ internal/store/repos/repo_incidents/find.go | 90 +++++ .../repos/repo_incidents/incidents.sql.go | 36 +- .../store/repos/repo_incidents/querier.go | 1 + internal/store/repos/repo_incidents/types.go | 40 +++ .../repos/repo_service_states/querier.go | 1 + .../repo_service_states/service_states.sql.go | 34 ++ .../store/repos/repo_service_states/types.go | 48 +++ internal/store/repos/repo_services/querier.go | 1 + .../store/repos/repo_services/services.sql.go | 41 +++ internal/store/repos/repos.go | 6 +- internal/web/dto.go | 15 +- internal/web/handlers.go | 5 +- internal/web/helpers.go | 137 +++---- sql/queries/incidents/incidents.sql | 12 +- sql/queries/service_states/service_states.sql | 10 + sql/queries/services/services.sql | 3 + 26 files changed, 623 insertions(+), 363 deletions(-) create mode 100644 internal/store/repos/repo_incidents/custom.go create mode 100644 internal/store/repos/repo_incidents/find.go create mode 100644 internal/store/repos/repo_service_states/types.go diff --git a/frontend/src/pages/dashboard/dashboard.tsx b/frontend/src/pages/dashboard/dashboard.tsx index 1797920..a62b04c 100644 --- a/frontend/src/pages/dashboard/dashboard.tsx +++ b/frontend/src/pages/dashboard/dashboard.tsx @@ -36,7 +36,7 @@ const infoKeysDashboard = [ }, { key: "uptime_percentage", - label: "Uptime", + label: "Uptime (last 30 days)", valueFormatter: (value: string) => `${Number(value).toFixed(1)}%`, }, { diff --git a/frontend/src/pages/service/components/serviceStats.tsx b/frontend/src/pages/service/components/serviceStats.tsx index 5e90cb7..cd810ec 100644 --- a/frontend/src/pages/service/components/serviceStats.tsx +++ b/frontend/src/pages/service/components/serviceStats.tsx @@ -32,7 +32,7 @@ export const ServiceStats = ({ { value: `${(serviceStatsData?.uptime_percentage ?? 0).toFixed(1)}%`, key: "uptime", - description: "Uptime", + description: "Uptime (last 30 days)", }, { value: serviceDetailData?.consecutive_success, @@ -47,7 +47,7 @@ export const ServiceStats = ({ ]; return ( -
+
{cardStats.map((stat) => ( 0 { -// slices.Sort(params.Tags) -// } - -// // Save to storage -// svc, err := m.storage.CreateService(ctx, params) -// if err != nil { -// return nil, fmt.Errorf("failed to create service: %w", err) -// } - -// m.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( -// receiver.TriggerServiceEventTypeCreated, -// svc, -// )) - -// return svc, nil -// } - -// UpdateService updates an existing service -// func (m *MonitorService) UpdateService(ctx context.Context, id string, params storage.CreateUpdateServiceRequest) (*models.ServiceFullView, error) { -// if len(params.Tags) > 0 { -// slices.Sort(params.Tags) -// } - -// // Update in storage -// var err error -// _, err = m.storage.UpdateService(ctx, id, params) -// if err != nil { -// return nil, fmt.Errorf("failed to update service: %w", err) -// } - -// svc, err := m.store.Services().GetViewByID(ctx, id) -// if err != nil { -// return nil, fmt.Errorf("failed to get service: %w", err) -// } - -// m.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( -// receiver.TriggerServiceEventTypeUpdated, -// svc, -// )) - -// return svc, nil -// } - -// DeleteService removes a service and stops monitoring it -// func (m *MonitorService) DeleteService(ctx context.Context, id string) error { -// // Get service to find name for scheduler cleanup -// svc, err := m.store.Services().GetViewByID(ctx, id) -// if err != nil { -// return fmt.Errorf("failed to get service: %w", err) -// } - -// m.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( -// receiver.TriggerServiceEventTypeDeleted, -// svc, -// )) - -// // Delete from storage -// if err := m.storage.DeleteService(ctx, id); err != nil { -// return fmt.Errorf("failed to delete service: %w", err) -// } - -// return nil -// } - -// GetServiceByID gets a service by ID -// func (m *MonitorService) GetServiceByID(ctx context.Context, id string) (*storage.Service, error) { -// return m.storage.GetServiceByID(ctx, id) -// } - // RecordSuccess records a successful check for a service func (m *MonitorService) RecordSuccess(ctx context.Context, serviceID string, responseTime time.Duration) error { // Get current service state diff --git a/internal/services/incidents/methods.go b/internal/services/incidents/methods.go index 5d72b79..03b7799 100644 --- a/internal/services/incidents/methods.go +++ b/internal/services/incidents/methods.go @@ -2,9 +2,12 @@ package incidents import ( "context" + "fmt" + "time" "github.com/go-playground/validator/v10" "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/repos/repo_incidents" "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/utils" ) @@ -62,3 +65,28 @@ func (s *Service) Create(ctx context.Context, params CreateParams) (*models.Inci return s.store.Incidents().Create(ctx, utils.GenerateULID(), params.ServiceID, params.Error) } + +type FindParams = repo_incidents.FindParams + +// Find retrieves a list of incidents based on the provided filtering parameters. +func (s *Service) Find(ctx context.Context, params FindParams) (*storecmn.FindResponseWithCount[*models.Incident], error) { + return s.store.Incidents().Find(ctx, params) +} + +// Stats retrieves aggregated statistics about incidents. +func (s *Service) Stats(ctx context.Context) (*repo_incidents.StatsRow, error) { + return s.store.Incidents().Stats(ctx) +} + +// StatsByServiceID retrieves statistics about incidents for a specific service within a given time frame. +func (s *Service) StatsByServiceID(ctx context.Context, serviceID string, startTime time.Time) (*repo_incidents.StatsByServiceIDRow, error) { + if serviceID == "" { + return nil, storecmn.ErrEmptyID + } + + if startTime.IsZero() { + return nil, fmt.Errorf("start time is required") + } + + return s.store.Incidents().StatsByServiceID(ctx, serviceID, startTime) +} diff --git a/internal/services/service/methods.go b/internal/services/service/methods.go index ebe7505..f469c32 100644 --- a/internal/services/service/methods.go +++ b/internal/services/service/methods.go @@ -158,6 +158,11 @@ func (s *Service) GetByID(ctx context.Context, id string) (*models.Service, erro return svc, nil } +// GetAllEnabled returns all enabled services +func (s *Service) GetAllEnabled(ctx context.Context) ([]*models.Service, error) { + return s.store.Services().GetAllEnabled(ctx) +} + // GetViewByID returns service view by ID func (s *Service) GetViewByID(ctx context.Context, id string) (*models.ServiceFullView, error) { return s.store.Services().GetViewByID(ctx, id) diff --git a/internal/services/service/stats.go b/internal/services/service/stats.go index 573dc72..b970ce0 100644 --- a/internal/services/service/stats.go +++ b/internal/services/service/stats.go @@ -29,16 +29,6 @@ func (s *Service) Stats(ctx context.Context, serviceID string, since time.Time) incidentsStatsDomain := incidentsStats.ToDomain() - // Calculate uptime percentage - period := time.Since(since) - uptimePercentage := 100.0 - if period > 0 { - uptimePercentage = 100.0 - (float64(incidentsStatsDomain.TotalDowntime) / float64(period) * 100.0) - if uptimePercentage < 0 { - uptimePercentage = 0 - } - } - // Get average response time from service state var avgResponseTime int64 serviceState, err := s.store.ServiceStates().GetByServiceID(ctx, serviceID) @@ -52,10 +42,10 @@ func (s *Service) Stats(ctx context.Context, serviceID string, since time.Time) return &Stats{ ServiceID: serviceID, - Period: period, + Period: time.Since(since), TotalIncidents: incidentsStatsDomain.TotalIncidents, TotalDowntime: incidentsStatsDomain.TotalDowntime, - UptimePercentage: uptimePercentage, + UptimePercentage: incidentsStats.UptimePercentage30d, AvgResponseTime: avgResponseTime, ResolvedIncidents: incidentsStatsDomain.ResolvedIncidents, UnresolvedIncidents: incidentsStatsDomain.UnresolvedIncidents, diff --git a/internal/services/servicestate/methods.go b/internal/services/servicestate/methods.go index 60e7e8a..cf89714 100644 --- a/internal/services/servicestate/methods.go +++ b/internal/services/servicestate/methods.go @@ -23,3 +23,8 @@ type UpdateParams = repo_service_states.UpdateRequest func (s *Service) Update(ctx context.Context, id string, params UpdateParams) (*models.ServiceState, error) { return s.store.ServiceStates().Update(ctx, id, params) } + +// Stats represents aggregated statistics about service states +func (s *Service) Stats(ctx context.Context) (*repo_service_states.StatsRow, error) { + return s.store.ServiceStates().Stats(ctx) +} diff --git a/internal/storage/incidents.go b/internal/storage/incidents.go index 82b4726..c7291e7 100644 --- a/internal/storage/incidents.go +++ b/internal/storage/incidents.go @@ -8,8 +8,6 @@ import ( "time" "github.com/huandu/go-sqlbuilder" - "github.com/sxwebdev/sentinel/internal/store/storecmn" - "github.com/sxwebdev/sentinel/internal/utils" ) // IncidentRow represents a database row for incidents @@ -71,201 +69,201 @@ func (o *Storage) GetIncidentByID(ctx context.Context, id string) (*Incident, er return o.rowToIncident(&incidentRow), nil } -type FindIncidentsParams struct { - // Search by service id or incident id - Search string - ID string - ServiceID string - Resolved *bool - StartTime *time.Time - EndTime *time.Time - Page *uint32 - PageSize *uint32 -} - -func findIncidentsBuilder(params FindIncidentsParams, col ...string) *sqlbuilder.SelectBuilder { - sb := sqlbuilder.NewSelectBuilder() - sb.Select(col...) - sb.From("incidents i") +// type FindIncidentsParams struct { +// // Search by service id or incident id +// Search string +// ID string +// ServiceID string +// Resolved *bool +// StartTime *time.Time +// EndTime *time.Time +// Page *uint32 +// PageSize *uint32 +// } - if params.ID != "" { - sb.Where(sb.Equal("i.id", params.ID)) - } +// func findIncidentsBuilder(params FindIncidentsParams, col ...string) *sqlbuilder.SelectBuilder { +// sb := sqlbuilder.NewSelectBuilder() +// sb.Select(col...) +// sb.From("incidents i") - if params.ServiceID != "" { - sb.Where(sb.Equal("i.service_id", params.ServiceID)) - } +// if params.ID != "" { +// sb.Where(sb.Equal("i.id", params.ID)) +// } - if params.Search != "" { - likeCondition := fmt.Sprintf("%%%s%%", params.Search) - sb.Where(sb.Or( - sb.Like("i.id", likeCondition), - sb.Like("i.service_id", likeCondition), - )) - } +// if params.ServiceID != "" { +// sb.Where(sb.Equal("i.service_id", params.ServiceID)) +// } - if params.Resolved != nil { - sb.Where(sb.Equal("i.resolved", *params.Resolved)) - } +// if params.Search != "" { +// likeCondition := fmt.Sprintf("%%%s%%", params.Search) +// sb.Where(sb.Or( +// sb.Like("i.id", likeCondition), +// sb.Like("i.service_id", likeCondition), +// )) +// } - if params.StartTime != nil { - sb.Where(sb.GreaterEqualThan("i.start_time", *params.StartTime)) - } +// if params.Resolved != nil { +// sb.Where(sb.Equal("i.resolved", *params.Resolved)) +// } - if params.EndTime != nil { - sb.Where(sb.LessEqualThan("i.end_time", *params.EndTime)) - } +// if params.StartTime != nil { +// sb.Where(sb.GreaterEqualThan("i.start_time", *params.StartTime)) +// } - return sb -} +// if params.EndTime != nil { +// sb.Where(sb.LessEqualThan("i.end_time", *params.EndTime)) +// } -// FindIncidents finds incidents -func (o *Storage) FindIncidents(ctx context.Context, params FindIncidentsParams) (storecmn.FindResponseWithCount[*Incident], error) { - sb := findIncidentsBuilder(params, - "i.id", - "i.service_id", - "i.start_time", - "i.end_time", - "i.error", - "i.duration", - "i.resolved", - "i.created_at", - "i.updated_at", - ) - sb.OrderBy("i.start_time").Desc() +// return sb +// } - res := storecmn.FindResponseWithCount[*Incident]{} +// // FindIncidents finds incidents +// func (o *Storage) FindIncidents(ctx context.Context, params FindIncidentsParams) (storecmn.FindResponseWithCount[*Incident], error) { +// sb := findIncidentsBuilder(params, +// "i.id", +// "i.service_id", +// "i.start_time", +// "i.end_time", +// "i.error", +// "i.duration", +// "i.resolved", +// "i.created_at", +// "i.updated_at", +// ) +// sb.OrderBy("i.start_time").Desc() - limit, offset, err := storecmn.Pagination(params.Page, params.PageSize) - if err != nil { - return res, fmt.Errorf("failed to apply pagination: %w", err) - } +// res := storecmn.FindResponseWithCount[*Incident]{} - sb.Limit(int(limit)).Offset(int(offset)) +// limit, offset, err := storecmn.Pagination(params.Page, params.PageSize) +// if err != nil { +// return res, fmt.Errorf("failed to apply pagination: %w", err) +// } - sql, args := sb.Build() - rows, err := o.db.QueryContext(ctx, sql, args...) - if err != nil { - return res, fmt.Errorf("failed to query incidents: %w", err) - } - defer rows.Close() - - incidents := []*Incident{} - for rows.Next() { - var incidentRow IncidentRow - err := rows.Scan( - &incidentRow.ID, - &incidentRow.ServiceID, - &incidentRow.StartTime, - &incidentRow.EndTime, - &incidentRow.Error, - &incidentRow.DurationNS, - &incidentRow.Resolved, - &incidentRow.CreatedAt, - &incidentRow.UpdatedAt, - ) - if err != nil { - return res, fmt.Errorf("failed to scan incident: %w", err) - } +// sb.Limit(int(limit)).Offset(int(offset)) - incidents = append(incidents, o.rowToIncident(&incidentRow)) - } +// sql, args := sb.Build() +// rows, err := o.db.QueryContext(ctx, sql, args...) +// if err != nil { +// return res, fmt.Errorf("failed to query incidents: %w", err) +// } +// defer rows.Close() + +// incidents := []*Incident{} +// for rows.Next() { +// var incidentRow IncidentRow +// err := rows.Scan( +// &incidentRow.ID, +// &incidentRow.ServiceID, +// &incidentRow.StartTime, +// &incidentRow.EndTime, +// &incidentRow.Error, +// &incidentRow.DurationNS, +// &incidentRow.Resolved, +// &incidentRow.CreatedAt, +// &incidentRow.UpdatedAt, +// ) +// if err != nil { +// return res, fmt.Errorf("failed to scan incident: %w", err) +// } + +// incidents = append(incidents, o.rowToIncident(&incidentRow)) +// } - if err := rows.Err(); err != nil { - return res, fmt.Errorf("error iterating rows: %w", err) - } +// if err := rows.Err(); err != nil { +// return res, fmt.Errorf("error iterating rows: %w", err) +// } - // Get total count of incidents - var totalCount uint32 - countBuilder := findIncidentsBuilder(params, "COUNT(*)") +// // Get total count of incidents +// var totalCount uint32 +// countBuilder := findIncidentsBuilder(params, "COUNT(*)") - countQuery, countArgs := countBuilder.Build() - err = o.db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&totalCount) - if err != nil { - return res, fmt.Errorf("failed to count incidents: %w", err) - } +// countQuery, countArgs := countBuilder.Build() +// err = o.db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&totalCount) +// if err != nil { +// return res, fmt.Errorf("failed to count incidents: %w", err) +// } - res.Count = totalCount - res.Items = incidents +// res.Count = totalCount +// res.Items = incidents - return res, nil -} +// return res, nil +// } // FindIncidents finds incidents -func (o *Storage) IncidentsCount(ctx context.Context, params FindIncidentsParams) (uint32, error) { - // Get total count of incidents - var totalCount uint32 - countBuilder := findIncidentsBuilder(params, "COUNT(*)") +// func (o *Storage) IncidentsCount(ctx context.Context, params FindIncidentsParams) (uint32, error) { +// // Get total count of incidents +// var totalCount uint32 +// countBuilder := findIncidentsBuilder(params, "COUNT(*)") - countQuery, countArgs := countBuilder.Build() - err := o.db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&totalCount) - if err != nil { - return 0, fmt.Errorf("failed to count incidents: %w", err) - } +// countQuery, countArgs := countBuilder.Build() +// err := o.db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&totalCount) +// if err != nil { +// return 0, fmt.Errorf("failed to count incidents: %w", err) +// } - return totalCount, nil -} +// return totalCount, nil +// } -// ResolveAllIncidents resolves all incidents for a service -func (o *Storage) ResolveAllIncidents(ctx context.Context, serviceID string) ([]*Incident, error) { - if serviceID == "" { - return nil, fmt.Errorf("serviceID is required") - } +// // ResolveAllIncidents resolves all incidents for a service +// func (o *Storage) ResolveAllIncidents(ctx context.Context, serviceID string) ([]*Incident, error) { +// if serviceID == "" { +// return nil, fmt.Errorf("serviceID is required") +// } - items, err := o.FindIncidents(ctx, FindIncidentsParams{ - ServiceID: serviceID, - Resolved: utils.Pointer(false), - }) - if err != nil { - return nil, fmt.Errorf("failed to find incidents: %w", err) - } +// items, err := o.FindIncidents(ctx, FindIncidentsParams{ +// ServiceID: serviceID, +// Resolved: utils.Pointer(false), +// }) +// if err != nil { +// return nil, fmt.Errorf("failed to find incidents: %w", err) +// } - tx, err := o.db.BeginTx(ctx, nil) - if err != nil { - return nil, fmt.Errorf("failed to begin transaction: %w", err) - } - defer tx.Rollback() - - for _, item := range items.Items { - now := time.Now() - - // Update all incidents for the service to resolved - ub := sqlbuilder.NewUpdateBuilder() - - ub.Update("incidents"). - Set( - ub.Assign("resolved", true), - ub.Assign("end_time", now), - ub.Assign("duration", now.Sub(item.StartTime)), - ub.Assign("updated_at", now), - ). - Where( - ub.Equal("id", item.ID), - ) - - sql, args := ub.Build() - if _, err := tx.ExecContext(ctx, sql, args...); err != nil { - return nil, fmt.Errorf("failed to resolve incidents: %w", err) - } - } +// tx, err := o.db.BeginTx(ctx, nil) +// if err != nil { +// return nil, fmt.Errorf("failed to begin transaction: %w", err) +// } +// defer tx.Rollback() + +// for _, item := range items.Items { +// now := time.Now() + +// // Update all incidents for the service to resolved +// ub := sqlbuilder.NewUpdateBuilder() + +// ub.Update("incidents"). +// Set( +// ub.Assign("resolved", true), +// ub.Assign("end_time", now), +// ub.Assign("duration", now.Sub(item.StartTime)), +// ub.Assign("updated_at", now), +// ). +// Where( +// ub.Equal("id", item.ID), +// ) + +// sql, args := ub.Build() +// if _, err := tx.ExecContext(ctx, sql, args...); err != nil { +// return nil, fmt.Errorf("failed to resolve incidents: %w", err) +// } +// } - // Commit transaction - if err := tx.Commit(); err != nil { - return nil, fmt.Errorf("failed to commit transaction: %w", err) - } +// // Commit transaction +// if err := tx.Commit(); err != nil { +// return nil, fmt.Errorf("failed to commit transaction: %w", err) +// } - resolvedIncidents := []*Incident{} - for _, item := range items.Items { - incident, err := o.GetIncidentByID(ctx, item.ID) - if err != nil { - return nil, fmt.Errorf("failed to get incident by ID: %w", err) - } +// resolvedIncidents := []*Incident{} +// for _, item := range items.Items { +// incident, err := o.GetIncidentByID(ctx, item.ID) +// if err != nil { +// return nil, fmt.Errorf("failed to get incident by ID: %w", err) +// } - resolvedIncidents = append(resolvedIncidents, incident) - } +// resolvedIncidents = append(resolvedIncidents, incident) +// } - return resolvedIncidents, nil -} +// return resolvedIncidents, nil +// } // SaveIncident creates a new incident using ORM with retry logic // func (o *Storage) SaveIncident(ctx context.Context, incident *Incident) error { diff --git a/internal/store/repos/repo_incidents/custom.go b/internal/store/repos/repo_incidents/custom.go new file mode 100644 index 0000000..228e00a --- /dev/null +++ b/internal/store/repos/repo_incidents/custom.go @@ -0,0 +1,35 @@ +package repo_incidents + +import ( + "context" + "database/sql" + + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/storecmn" +) + +type ICustomQuerier interface { + Querier + Find(ctx context.Context, params FindParams) (*storecmn.FindResponseWithCount[*models.Incident], error) +} + +type CustomQueries struct { + *Queries + db DBTX +} + +func NewCustom(db DBTX) *CustomQueries { + return &CustomQueries{ + Queries: New(db), + db: db, + } +} + +func (s *CustomQueries) WithTx(tx *sql.Tx) *CustomQueries { + return &CustomQueries{ + Queries: New(tx), + db: tx, + } +} + +var _ ICustomQuerier = (*CustomQueries)(nil) diff --git a/internal/store/repos/repo_incidents/find.go b/internal/store/repos/repo_incidents/find.go new file mode 100644 index 0000000..a5fd8ca --- /dev/null +++ b/internal/store/repos/repo_incidents/find.go @@ -0,0 +1,90 @@ +package repo_incidents + +import ( + "context" + "fmt" + "time" + + "github.com/georgysavva/scany/v2/sqlscan" + "github.com/huandu/go-sqlbuilder" + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/storecmn" +) + +type FindParams struct { + Search string + ID string + ServiceID string + Resolved *bool + StartTime *time.Time + EndTime *time.Time + Page *uint32 + PageSize *uint32 +} + +func findBuilder(params FindParams, col ...string) *sqlbuilder.SelectBuilder { + sb := sqlbuilder.NewSelectBuilder() + sb.Select(col...) + sb.From(TableNameIncidents.String()) + + if params.ID != "" { + sb.Where(sb.Equal("id", params.ID)) + } + + if params.ServiceID != "" { + sb.Where(sb.Equal("service_id", params.ServiceID)) + } + + if params.Search != "" { + likeCondition := fmt.Sprintf("%%%s%%", params.Search) + sb.Where(sb.Or( + sb.Like("id", likeCondition), + sb.Like("service_id", likeCondition), + )) + } + + if params.Resolved != nil { + sb.Where(sb.Equal("resolved", *params.Resolved)) + } + + if params.StartTime != nil { + sb.Where(sb.GreaterEqualThan("start_time", *params.StartTime)) + } + + if params.EndTime != nil { + sb.Where(sb.LessEqualThan("end_time", *params.EndTime)) + } + + return sb +} + +// Find returns list of incidents by given filters with pagination +func (s *CustomQueries) Find(ctx context.Context, params FindParams) (*storecmn.FindResponseWithCount[*models.Incident], error) { + sb := findBuilder(params, IncidentsColumnNames().Strings()...) + + sb.OrderBy("start_time").Desc() + + limit, offset, err := storecmn.Pagination(params.Page, params.PageSize) + if err != nil { + return nil, err + } + sb.Limit(int(limit)).Offset(int(offset)) + + items := []*models.Incident{} + sql, args := sb.Build() + if err := sqlscan.Select(ctx, s.db, &items, sql, args...); err != nil { + return nil, err + } + + // Get total count of services + var totalCount uint32 + countSQL, countArgs := findBuilder(params, "count(*)").Build() + if err := sqlscan.Get(ctx, s.db, &totalCount, countSQL, countArgs...); err != nil { + return nil, err + } + + return &storecmn.FindResponseWithCount[*models.Incident]{ + Count: totalCount, + Items: items, + }, nil +} diff --git a/internal/store/repos/repo_incidents/incidents.sql.go b/internal/store/repos/repo_incidents/incidents.sql.go index fcc59b1..97f0ae5 100644 --- a/internal/store/repos/repo_incidents/incidents.sql.go +++ b/internal/store/repos/repo_incidents/incidents.sql.go @@ -75,7 +75,7 @@ func (q *Queries) ResolveByID(ctx context.Context, id string) error { return err } -const statsByServiceID = `-- name: StatsByServiceID :one +const stats = `-- name: Stats :one SELECT COUNT(*) AS total_incidents, SUM(duration) AS total_downtime, @@ -83,6 +83,38 @@ SELECT SUM(CASE WHEN resolved THEN 1 ELSE 0 END) AS resolved_incidents, SUM(CASE WHEN NOT resolved THEN 1 ELSE 0 END) AS unresolved_incidents FROM incidents +` + +type StatsRow struct { + TotalIncidents int64 `db:"total_incidents" json:"total_incidents"` + TotalDowntime *float64 `db:"total_downtime" json:"total_downtime"` + AvgDowntime *float64 `db:"avg_downtime" json:"avg_downtime"` + ResolvedIncidents *float64 `db:"resolved_incidents" json:"resolved_incidents"` + UnresolvedIncidents *float64 `db:"unresolved_incidents" json:"unresolved_incidents"` +} + +func (q *Queries) Stats(ctx context.Context) (*StatsRow, error) { + row := q.db.QueryRowContext(ctx, stats) + var i StatsRow + err := row.Scan( + &i.TotalIncidents, + &i.TotalDowntime, + &i.AvgDowntime, + &i.ResolvedIncidents, + &i.UnresolvedIncidents, + ) + return &i, err +} + +const statsByServiceID = `-- name: StatsByServiceID :one +SELECT + COUNT(*) AS total_incidents, + SUM(duration) AS total_downtime, + AVG(duration) AS avg_downtime, + SUM(CASE WHEN resolved THEN 1 ELSE 0 END) AS resolved_incidents, + SUM(CASE WHEN NOT resolved THEN 1 ELSE 0 END) AS unresolved_incidents, + ROUND(100.0 - (COALESCE(SUM(duration), 0) * 100.0 / (30 * 24 * 60 * 60 * 1000)), 3) AS uptime_percentage_30d +FROM incidents WHERE service_id=? AND start_time >= ? ` @@ -92,6 +124,7 @@ type StatsByServiceIDRow struct { AvgDowntime *float64 `db:"avg_downtime" json:"avg_downtime"` ResolvedIncidents *float64 `db:"resolved_incidents" json:"resolved_incidents"` UnresolvedIncidents *float64 `db:"unresolved_incidents" json:"unresolved_incidents"` + UptimePercentage30d float64 `db:"uptime_percentage_30d" json:"uptime_percentage_30d"` } func (q *Queries) StatsByServiceID(ctx context.Context, serviceID string, startTime time.Time) (*StatsByServiceIDRow, error) { @@ -103,6 +136,7 @@ func (q *Queries) StatsByServiceID(ctx context.Context, serviceID string, startT &i.AvgDowntime, &i.ResolvedIncidents, &i.UnresolvedIncidents, + &i.UptimePercentage30d, ) return &i, err } diff --git a/internal/store/repos/repo_incidents/querier.go b/internal/store/repos/repo_incidents/querier.go index 6a74db3..d8bfbf0 100644 --- a/internal/store/repos/repo_incidents/querier.go +++ b/internal/store/repos/repo_incidents/querier.go @@ -18,6 +18,7 @@ type Querier interface { GetAllUnresolvedByServiceID(ctx context.Context, serviceID string) ([]*models.Incident, error) GetByID(ctx context.Context, id string) (*models.Incident, error) ResolveByID(ctx context.Context, id string) error + Stats(ctx context.Context) (*StatsRow, error) StatsByServiceID(ctx context.Context, serviceID string, startTime time.Time) (*StatsByServiceIDRow, error) } diff --git a/internal/store/repos/repo_incidents/types.go b/internal/store/repos/repo_incidents/types.go index fda7e16..c5f59e5 100644 --- a/internal/store/repos/repo_incidents/types.go +++ b/internal/store/repos/repo_incidents/types.go @@ -6,6 +6,7 @@ type StatsByServiceID struct { AvgDowntime int64 ResolvedIncidents int64 UnresolvedIncidents int64 + UptimePercentage30d float64 } func (s StatsByServiceIDRow) ToDomain() *StatsByServiceID { @@ -35,5 +36,44 @@ func (s StatsByServiceIDRow) ToDomain() *StatsByServiceID { AvgDowntime: avgDowntime, ResolvedIncidents: resolvedIncidents, UnresolvedIncidents: unresolvedIncidents, + UptimePercentage30d: s.UptimePercentage30d, + } +} + +type Stats struct { + TotalIncidents int64 + TotalDowntime int64 + AvgDowntime int64 + ResolvedIncidents int64 + UnresolvedIncidents int64 +} + +func (s StatsRow) ToDomain() *Stats { + var totalDowntime int64 + if s.TotalDowntime != nil { + totalDowntime = int64(*s.TotalDowntime) + } + + var avgDowntime int64 + if s.AvgDowntime != nil { + avgDowntime = int64(*s.AvgDowntime) + } + + var resolvedIncidents int64 + if s.ResolvedIncidents != nil { + resolvedIncidents = int64(*s.ResolvedIncidents) + } + + var unresolvedIncidents int64 + if s.UnresolvedIncidents != nil { + unresolvedIncidents = int64(*s.UnresolvedIncidents) + } + + return &Stats{ + TotalIncidents: s.TotalIncidents, + TotalDowntime: totalDowntime, + AvgDowntime: avgDowntime, + ResolvedIncidents: resolvedIncidents, + UnresolvedIncidents: unresolvedIncidents, } } diff --git a/internal/store/repos/repo_service_states/querier.go b/internal/store/repos/repo_service_states/querier.go index f834c4e..101bd54 100644 --- a/internal/store/repos/repo_service_states/querier.go +++ b/internal/store/repos/repo_service_states/querier.go @@ -17,6 +17,7 @@ type Querier interface { GetAll(ctx context.Context) ([]*models.ServiceState, error) GetByID(ctx context.Context, id string) (*models.ServiceState, error) GetByServiceID(ctx context.Context, serviceID string) (*models.ServiceState, error) + Stats(ctx context.Context) (*StatsRow, error) } var _ Querier = (*Queries)(nil) diff --git a/internal/store/repos/repo_service_states/service_states.sql.go b/internal/store/repos/repo_service_states/service_states.sql.go index 5b30646..15a5a3a 100644 --- a/internal/store/repos/repo_service_states/service_states.sql.go +++ b/internal/store/repos/repo_service_states/service_states.sql.go @@ -43,3 +43,37 @@ func (q *Queries) GetByServiceID(ctx context.Context, serviceID string) (*models ) return &i, err } + +const stats = `-- name: Stats :one +SELECT + COUNT(*) AS total_services, + SUM(CASE WHEN status='up' THEN 1 ELSE 0 END) AS services_up, + SUM(CASE WHEN status='down' THEN 1 ELSE 0 END) AS services_down, + SUM(CASE WHEN status='unknown' THEN 1 ELSE 0 END) AS services_unknown, + AVG(response_time) AS avg_response_time, + SUM(total_checks) AS total_checks +FROM service_states +` + +type StatsRow struct { + TotalServices int64 `db:"total_services" json:"total_services"` + ServicesUp *float64 `db:"services_up" json:"services_up"` + ServicesDown *float64 `db:"services_down" json:"services_down"` + ServicesUnknown *float64 `db:"services_unknown" json:"services_unknown"` + AvgResponseTime *float64 `db:"avg_response_time" json:"avg_response_time"` + TotalChecks *float64 `db:"total_checks" json:"total_checks"` +} + +func (q *Queries) Stats(ctx context.Context) (*StatsRow, error) { + row := q.db.QueryRowContext(ctx, stats) + var i StatsRow + err := row.Scan( + &i.TotalServices, + &i.ServicesUp, + &i.ServicesDown, + &i.ServicesUnknown, + &i.AvgResponseTime, + &i.TotalChecks, + ) + return &i, err +} diff --git a/internal/store/repos/repo_service_states/types.go b/internal/store/repos/repo_service_states/types.go new file mode 100644 index 0000000..41eb78f --- /dev/null +++ b/internal/store/repos/repo_service_states/types.go @@ -0,0 +1,48 @@ +package repo_service_states + +type ServicesStats struct { + TotalServices int64 + ServicesUp int64 + ServicesDown int64 + ServicesUnknown int64 + AvgResponseTime int64 + TotalChecks int64 +} + +func (s StatsRow) ToDomain() *ServicesStats { + var servicesUp int64 + if s.ServicesUp != nil { + servicesUp = int64(*s.ServicesUp) + } + + var servicesDown int64 + if s.ServicesDown != nil { + servicesDown = int64(*s.ServicesDown) + } + + var servicesUnknown int64 + if s.ServicesUnknown != nil { + servicesUnknown = int64(*s.ServicesUnknown) + } + + var avgResponseTime int64 + if s.AvgResponseTime != nil { + avgResponseTime = int64(*s.AvgResponseTime) + } + + var totalChecks int64 + if s.TotalChecks != nil { + totalChecks = int64(*s.TotalChecks) + } + + dto := &ServicesStats{ + TotalServices: s.TotalServices, + ServicesUp: servicesUp, + ServicesDown: servicesDown, + ServicesUnknown: servicesUnknown, + AvgResponseTime: avgResponseTime, + TotalChecks: totalChecks, + } + + return dto +} diff --git a/internal/store/repos/repo_services/querier.go b/internal/store/repos/repo_services/querier.go index 070e575..672122a 100644 --- a/internal/store/repos/repo_services/querier.go +++ b/internal/store/repos/repo_services/querier.go @@ -14,6 +14,7 @@ type Querier interface { Create(ctx context.Context, arg CreateParams) (*models.Service, error) Delete(ctx context.Context, id string) error Exist(ctx context.Context, id string) (int64, error) + GetAllEnabled(ctx context.Context) ([]*models.Service, error) GetByID(ctx context.Context, id string) (*models.Service, error) } diff --git a/internal/store/repos/repo_services/services.sql.go b/internal/store/repos/repo_services/services.sql.go index fd17c4e..fc2e296 100644 --- a/internal/store/repos/repo_services/services.sql.go +++ b/internal/store/repos/repo_services/services.sql.go @@ -7,6 +7,8 @@ package repo_services import ( "context" + + "github.com/sxwebdev/sentinel/internal/models" ) const exist = `-- name: Exist :one @@ -19,3 +21,42 @@ func (q *Queries) Exist(ctx context.Context, id string) (int64, error) { err := row.Scan(&column_1) return column_1, err } + +const getAllEnabled = `-- name: GetAllEnabled :many +SELECT id, name, protocol, interval, timeout, retries, json(tags), json(config), is_enabled, created_at, updated_at FROM services WHERE is_enabled=TRUE ORDER BY name +` + +func (q *Queries) GetAllEnabled(ctx context.Context) ([]*models.Service, error) { + rows, err := q.db.QueryContext(ctx, getAllEnabled) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*models.Service{} + for rows.Next() { + var i models.Service + if err := rows.Scan( + &i.ID, + &i.Name, + &i.Protocol, + &i.Interval, + &i.Timeout, + &i.Retries, + &i.Tags, + &i.Config, + &i.IsEnabled, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/store/repos/repos.go b/internal/store/repos/repos.go index 8bdb9ab..f9dfc93 100644 --- a/internal/store/repos/repos.go +++ b/internal/store/repos/repos.go @@ -13,7 +13,7 @@ type Repos struct { agents *repo_agents.CustomQueries services *repo_services.CustomQueries serviceStates *repo_service_states.CustomQueries - incidents *repo_incidents.Queries + incidents *repo_incidents.CustomQueries } func New(sqlite *sql.DB) *Repos { @@ -21,7 +21,7 @@ func New(sqlite *sql.DB) *Repos { agents: repo_agents.NewCustom(sqlite), services: repo_services.NewCustom(sqlite), serviceStates: repo_service_states.NewCustom(sqlite), - incidents: repo_incidents.New(sqlite), + incidents: repo_incidents.NewCustom(sqlite), } } @@ -59,7 +59,7 @@ func (s *Repos) ServiceStates(opts ...Option) repo_service_states.ICustomQuerier } // Incidents returns repo for incidents -func (s *Repos) Incidents(opts ...Option) repo_incidents.Querier { +func (s *Repos) Incidents(opts ...Option) repo_incidents.ICustomQuerier { options := parseOptions(opts...) if options.Tx != nil { diff --git a/internal/web/dto.go b/internal/web/dto.go index 981bc35..502b2d9 100644 --- a/internal/web/dto.go +++ b/internal/web/dto.go @@ -11,17 +11,16 @@ import ( // // @Description Dashboard statistics type DashboardStats struct { - TotalServices int `json:"total_services" example:"10"` - ServicesUp int `json:"services_up" example:"8"` - ServicesDown int `json:"services_down" example:"1"` - ServicesUnknown int `json:"services_unknown" example:"1"` - Protocols map[models.ServiceProtocolType]int `json:"protocols"` - ActiveIncidents int `json:"active_incidents" example:"2"` + TotalServices int64 `json:"total_services" example:"10"` + ServicesUp int64 `json:"services_up" example:"8"` + ServicesDown int64 `json:"services_down" example:"1"` + ServicesUnknown int64 `json:"services_unknown" example:"1"` + ActiveIncidents int64 `json:"active_incidents" example:"2"` AvgResponseTime int64 `json:"avg_response_time" example:"150"` TotalChecks int64 `json:"total_checks" example:"1000"` UptimePercentage float64 `json:"uptime_percentage" example:"95.5"` - LastCheckTime *time.Time `json:"last_check_time"` - ChecksPerMinute int `json:"checks_per_minute" example:"60"` + ChecksPerMinute int64 `json:"checks_per_minute" example:"60"` + Protocols map[models.ServiceProtocolType]int `json:"protocols"` } // Incident represents an incident diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 2d92ab8..59a0363 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -36,6 +36,7 @@ import ( "github.com/sxwebdev/sentinel/internal/monitor" "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/services/baseservices" + "github.com/sxwebdev/sentinel/internal/services/incidents" "github.com/sxwebdev/sentinel/internal/services/service" "github.com/sxwebdev/sentinel/internal/storage" "github.com/sxwebdev/sentinel/internal/store/storecmn" @@ -448,7 +449,7 @@ func (s *Server) handleAPIServiceIncidents(c *fiber.Ctx) error { return newErrorResponse(c, fiber.StatusNotFound, storecmn.ErrNotFound) } - incidents, err := s.storage.FindIncidents(c.Context(), storage.FindIncidentsParams{ + incidents, err := s.baseServices.Incidents().Find(c.Context(), incidents.FindParams{ ID: params.IncidentID, ServiceID: serviceID, Resolved: params.Resolved, @@ -584,7 +585,7 @@ func (s *Server) handleFindIncidents(c *fiber.Ctx) error { return newErrorResponse(c, fiber.StatusBadRequest, err) } - incidents, err := s.storage.FindIncidents(c.Context(), storage.FindIncidentsParams{ + incidents, err := s.baseServices.Incidents().Find(c.Context(), incidents.FindParams{ Search: params.Search, Resolved: params.Resolved, StartTime: params.StartTime, diff --git a/internal/web/helpers.go b/internal/web/helpers.go index 255edf2..f91f5da 100644 --- a/internal/web/helpers.go +++ b/internal/web/helpers.go @@ -3,13 +3,12 @@ package web import ( "context" "fmt" + "sync/atomic" "time" "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/monitors" - "github.com/sxwebdev/sentinel/internal/services/service" - "github.com/sxwebdev/sentinel/internal/storage" - "github.com/sxwebdev/sentinel/internal/utils" + "golang.org/x/sync/errgroup" ) // convertServiceToDTO converts a models.Service to ServiceDTO @@ -54,122 +53,86 @@ func convertServiceToDTO(service *models.ServiceFullView) (ServiceDTO, error) { // getDashboardStats calculates dashboard statistics func (s *Server) getDashboardStats(ctx context.Context) (*DashboardStats, error) { // Get all services with their states - services, err := s.baseServices.Services().FindView(ctx, service.FindParams{}) + services, err := s.baseServices.Services().GetAllEnabled(ctx) if err != nil { return nil, err } - // Get recent incidents - activeIncidentsCount, err := s.storage.IncidentsCount(ctx, storage.FindIncidentsParams{ - Resolved: utils.Pointer(false), - }) + incidentsStatsData, err := s.baseServices.Incidents().Stats(ctx) if err != nil { return nil, err } - // Get all service states - serviceStates, err := s.baseServices.ServiceStates().GetAll(ctx) + servicesStatsData, err := s.baseServices.ServiceStates().Stats(ctx) if err != nil { return nil, err } - // Create a map for quick lookup of service states by service ID - stateMap := make(map[string]*models.ServiceState) - for _, state := range serviceStates { - stateMap[state.ServiceID] = state + incidentsStats := incidentsStatsData.ToDomain() + servicesStats := servicesStatsData.ToDomain() + + var totalUptimeAtomic atomic.Value + totalUptimeAtomic.Store(float64(0)) + + since := time.Now().AddDate(0, -1, 0) + + eg, gCtx := errgroup.WithContext(ctx) + for _, svc := range services { + eg.Go(func() error { + svcIncidentsStatsData, err := s.baseServices.Incidents().StatsByServiceID(gCtx, svc.ID, since) + if err != nil { + return err + } + + svcIncidentsStats := svcIncidentsStatsData.ToDomain() + + totalUptimeAtomic.Store(totalUptimeAtomic.Load().(float64) + svcIncidentsStats.UptimePercentage30d) + + return nil + }) + } + + if err := eg.Wait(); err != nil { + return nil, err + } + + var avgUptime float64 + totalUptime := totalUptimeAtomic.Load().(float64) + if totalUptime > 0 { + avgUptime = totalUptime / float64(len(services)) } // Initialize stats stats := DashboardStats{ - TotalServices: int(services.Count), - ServicesUp: 0, - ServicesDown: 0, - ServicesUnknown: 0, - UptimePercentage: 0.0, - AvgResponseTime: 0, - TotalChecks: 0, - ActiveIncidents: 0, - LastCheckTime: nil, + TotalServices: servicesStats.TotalServices, + ServicesUp: servicesStats.ServicesUp, + ServicesDown: servicesStats.ServicesDown, + ServicesUnknown: servicesStats.ServicesUnknown, + UptimePercentage: avgUptime, + AvgResponseTime: servicesStats.AvgResponseTime, + TotalChecks: servicesStats.TotalChecks, + ActiveIncidents: incidentsStats.UnresolvedIncidents, ChecksPerMinute: 0, Protocols: make(map[models.ServiceProtocolType]int), } // Calculate statistics - var totalChecks int64 - upServices := 0 - var lastCheckTime *time.Time - var totalResponseTimeMs int64 - var responseTimeCount int64 - - for _, service := range services.Items { - if !service.IsEnabled { - continue - } - - // Get service state - serviceState := stateMap[service.ID] - - // Count by status - if serviceState != nil { - switch serviceState.Status { - case models.StatusUp: - stats.ServicesUp++ - upServices++ - case models.StatusDown: - stats.ServicesDown++ - case models.StatusUnknown: - stats.ServicesUnknown++ - } - - // Add response time to total (only from services that have response time data) - if serviceState.ResponseTime != nil && *serviceState.ResponseTime > 0 { - totalResponseTimeMs += *serviceState.ResponseTime - responseTimeCount++ - } - totalChecks += serviceState.TotalChecks - - // Track last check time - if serviceState.LastCheck != nil { - if lastCheckTime == nil || serviceState.LastCheck.After(*lastCheckTime) { - lastCheckTime = serviceState.LastCheck - } - } - } + for _, service := range services { // Count by protocol protocol := service.Protocol if protocol == "" { protocol = "unknown" } - // TODO: fix this when remove storage.ServiceProtocolType - stats.Protocols[models.ServiceProtocolType(protocol)]++ + stats.Protocols[protocol]++ } - // Calculate averages - if upServices > 0 { - stats.UptimePercentage = float64(upServices) / float64(len(services.Items)) * 100 - } - if responseTimeCount > 0 { - stats.AvgResponseTime = totalResponseTimeMs / responseTimeCount - } - stats.TotalChecks = totalChecks - - // Count active incidents - stats.ActiveIncidents = int(activeIncidentsCount) - - // Set last check time - stats.LastCheckTime = lastCheckTime - // Calculate checks per minute (estimate based on intervals) - checksPerMinute := 0 - for _, service := range services.Items { - if !service.IsEnabled { - continue - } - + var checksPerMinute int64 + for _, service := range services { if service.Interval > 0 { - checksPerMinute += int(time.Minute / service.Interval) + checksPerMinute += int64(time.Minute / service.Interval.ToDuration()) } } stats.ChecksPerMinute = checksPerMinute diff --git a/sql/queries/incidents/incidents.sql b/sql/queries/incidents/incidents.sql index 811dc01..abc422c 100755 --- a/sql/queries/incidents/incidents.sql +++ b/sql/queries/incidents/incidents.sql @@ -1,13 +1,23 @@ -- name: DeleteByServiceID :exec DELETE FROM incidents WHERE service_id=?; --- name: StatsByServiceID :one +-- name: Stats :one SELECT COUNT(*) AS total_incidents, SUM(duration) AS total_downtime, AVG(duration) AS avg_downtime, SUM(CASE WHEN resolved THEN 1 ELSE 0 END) AS resolved_incidents, SUM(CASE WHEN NOT resolved THEN 1 ELSE 0 END) AS unresolved_incidents +FROM incidents; + +-- name: StatsByServiceID :one +SELECT + COUNT(*) AS total_incidents, + SUM(duration) AS total_downtime, + AVG(duration) AS avg_downtime, + SUM(CASE WHEN resolved THEN 1 ELSE 0 END) AS resolved_incidents, + SUM(CASE WHEN NOT resolved THEN 1 ELSE 0 END) AS unresolved_incidents, + ROUND(100.0 - (COALESCE(SUM(duration), 0) * 100.0 / (30 * 24 * 60 * 60 * 1000)), 3) AS uptime_percentage_30d FROM incidents WHERE service_id=? AND start_time >= ?; diff --git a/sql/queries/service_states/service_states.sql b/sql/queries/service_states/service_states.sql index de210ef..d497bdd 100755 --- a/sql/queries/service_states/service_states.sql +++ b/sql/queries/service_states/service_states.sql @@ -3,3 +3,13 @@ SELECT * FROM service_states WHERE service_id=? LIMIT 1; -- name: DeleteByServiceID :exec DELETE FROM service_states WHERE service_id=?; + +-- name: Stats :one +SELECT + COUNT(*) AS total_services, + SUM(CASE WHEN status='up' THEN 1 ELSE 0 END) AS services_up, + SUM(CASE WHEN status='down' THEN 1 ELSE 0 END) AS services_down, + SUM(CASE WHEN status='unknown' THEN 1 ELSE 0 END) AS services_unknown, + AVG(response_time) AS avg_response_time, + SUM(total_checks) AS total_checks +FROM service_states; diff --git a/sql/queries/services/services.sql b/sql/queries/services/services.sql index 90e9556..6e9902d 100644 --- a/sql/queries/services/services.sql +++ b/sql/queries/services/services.sql @@ -1,2 +1,5 @@ -- name: Exist :one SELECT EXISTS (SELECT 1 FROM services WHERE id = ? LIMIT 1); + +-- name: GetAllEnabled :many +SELECT * FROM services WHERE is_enabled=TRUE ORDER BY name; From f8b77edbe10582558f557797a916188506c80cbc Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Fri, 19 Sep 2025 21:52:49 +0300 Subject: [PATCH 12/71] Refactor storage layer and update service protocols - Removed the old storage package and replaced it with a new store package. - Updated service protocol types from storage.ServiceProtocolType to models.ServiceProtocolType. - Changed the retries field type from int to int64 in various structs and database models. - Refactored the TestSuite and MonitorService to use the new store package. - Implemented incident statistics retrieval by date range in the incidents service. - Updated web handlers and responses to reflect changes in the storage and service models. - Removed unused storage error handling and incident management code. --- cmd/sentinel/hub.go | 12 +- cmd/testapi/main.go | 51 ++- internal/models/service.go | 2 +- internal/monitor/monitor.go | 77 +--- internal/monitors/config.go | 7 +- internal/scheduler/job.go | 2 +- internal/scheduler/scheduler.go | 2 +- internal/services/incidents/stats.go | 13 + internal/storage/errors.go | 8 - internal/storage/incidents.go | 420 ------------------ internal/storage/models.go | 63 --- internal/storage/storage.go | 85 ---- internal/storage/types.go | 14 - internal/store/repos/repo_incidents/custom.go | 2 + internal/store/repos/repo_incidents/stats.go | 71 +++ internal/store/repos/repo_services/types.go | 2 +- internal/web/dto.go | 2 +- internal/web/handlers.go | 14 +- internal/web/responses.go | 6 +- internal/web/websockets.go | 11 +- 20 files changed, 140 insertions(+), 724 deletions(-) create mode 100644 internal/services/incidents/stats.go delete mode 100644 internal/storage/errors.go delete mode 100644 internal/storage/incidents.go delete mode 100644 internal/storage/models.go delete mode 100644 internal/storage/storage.go delete mode 100644 internal/storage/types.go create mode 100644 internal/store/repos/repo_incidents/stats.go diff --git a/cmd/sentinel/hub.go b/cmd/sentinel/hub.go index 1c03ac0..6b5a706 100644 --- a/cmd/sentinel/hub.go +++ b/cmd/sentinel/hub.go @@ -14,7 +14,6 @@ import ( "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/scheduler" "github.com/sxwebdev/sentinel/internal/services/baseservices" - "github.com/sxwebdev/sentinel/internal/storage" "github.com/sxwebdev/sentinel/internal/store" "github.com/sxwebdev/sentinel/internal/upgrader" "github.com/sxwebdev/sentinel/internal/web" @@ -82,12 +81,6 @@ func hubStartCMD() *cli.Command { return fmt.Errorf("failed to initialize store: %w", err) } - // Initialize storage - storage, err := storage.New(l, dbPath) - if err != nil { - return fmt.Errorf("failed to initialize storage: %w", err) - } - // Print SQLite version if using SQLite storage sqliteVersion, err := db.GetSQLiteVersion(ctx) if err != nil { @@ -116,7 +109,7 @@ func hubStartCMD() *cli.Command { baseServices := baseservices.New(st, rc) // Create monitor service - monitorService := monitor.NewMonitorService(l, storage, conf, notif, rc, baseServices) + monitorService := monitor.NewMonitorService(l, st, conf, notif, rc, baseServices) // Initialize scheduler sched := scheduler.New(l, monitorService, rc, baseServices) @@ -124,7 +117,7 @@ func hubStartCMD() *cli.Command { serverInfo := models.GetSystemInfo(version, commitHash, buildDate) serverInfo.SqliteVersion = sqliteVersion - webServer, err := web.NewServer(l, conf, serverInfo, baseServices, monitorService, storage, rc, upgr) + webServer, err := web.NewServer(l, conf, serverInfo, baseServices, monitorService, rc, upgr) if err != nil { return fmt.Errorf("failed to initialize web server: %w", err) } @@ -133,7 +126,6 @@ func hubStartCMD() *cli.Command { ln.ServicesRunner().Register( service.New(service.WithService(pingpong.New(l))), service.New(service.WithService(db)), - service.New(service.WithService(storage)), service.New(service.WithService(rc)), service.New(service.WithService(sched)), service.New(service.WithService(webServer)), diff --git a/cmd/testapi/main.go b/cmd/testapi/main.go index 514419d..29b0953 100644 --- a/cmd/testapi/main.go +++ b/cmd/testapi/main.go @@ -20,10 +20,12 @@ import ( "github.com/sxwebdev/sentinel/internal/monitors" "github.com/sxwebdev/sentinel/internal/notifier" "github.com/sxwebdev/sentinel/internal/receiver" - "github.com/sxwebdev/sentinel/internal/storage" + "github.com/sxwebdev/sentinel/internal/services/baseservices" + "github.com/sxwebdev/sentinel/internal/store" "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/upgrader" "github.com/sxwebdev/sentinel/internal/web" + "github.com/sxwebdev/sentinel/pkg/sqlite" "github.com/tkcrm/mx/logger" ) @@ -31,7 +33,7 @@ type TestSuite struct { server *web.Server baseURL string client *http.Client - stor *storage.Storage + stor *store.Store ctx context.Context services map[string]*web.ServiceDTO incidents map[string]*web.Incident @@ -40,7 +42,7 @@ type TestSuite struct { type TestService struct { Name string - Protocol storage.ServiceProtocolType + Protocol models.ServiceProtocolType Tags []string Config monitors.Config Enabled bool @@ -49,7 +51,7 @@ type TestService struct { var testServices = []TestService{ { Name: "HTTP Test Service 1", - Protocol: storage.ServiceProtocolTypeHTTP, + Protocol: models.ServiceProtocolTypeHTTP, Tags: []string{"http", "production", "api"}, Config: monitors.Config{ HTTP: &monitors.HTTPConfig{ @@ -68,7 +70,7 @@ var testServices = []TestService{ }, { Name: "HTTP Test Service 2", - Protocol: storage.ServiceProtocolTypeHTTP, + Protocol: models.ServiceProtocolTypeHTTP, Tags: []string{"http", "staging", "web"}, Config: monitors.Config{ HTTP: &monitors.HTTPConfig{ @@ -87,7 +89,7 @@ var testServices = []TestService{ }, { Name: "TCP Test Service", - Protocol: storage.ServiceProtocolTypeTCP, + Protocol: models.ServiceProtocolTypeTCP, Tags: []string{"tcp", "database", "production"}, Config: monitors.Config{ TCP: &monitors.TCPConfig{ @@ -98,7 +100,7 @@ var testServices = []TestService{ }, { Name: "gRPC Test Service", - Protocol: storage.ServiceProtocolTypeGRPC, + Protocol: models.ServiceProtocolTypeGRPC, Tags: []string{"grpc", "api", "microservice"}, Config: monitors.Config{ GRPC: &monitors.GRPCConfig{ @@ -111,7 +113,7 @@ var testServices = []TestService{ }, { Name: "Disabled Service", - Protocol: storage.ServiceProtocolTypeHTTP, + Protocol: models.ServiceProtocolTypeHTTP, Tags: []string{"disabled", "test"}, Config: monitors.Config{ HTTP: &monitors.HTTPConfig{ @@ -222,12 +224,6 @@ func setupTestSuite() (*TestSuite, error) { l := logger.Default() - // Initialize storage - stor, err := storage.New(l, dbPath) - if err != nil { - return nil, fmt.Errorf("failed to initialize storage: %w", err) - } - // Initialize notifier (disabled for tests) var notif *notifier.Notifier @@ -243,11 +239,24 @@ func setupTestSuite() (*TestSuite, error) { return nil, fmt.Errorf("failed to initialize upgrader: %w", err) } + // init sqlite + db, err := sqlite.New(ctx, dbPath) + if err != nil { + return nil, fmt.Errorf("failed to initialize sqlite: %w", err) + } + + st, err := store.New(db.DB) + if err != nil { + return nil, fmt.Errorf("failed to initialize store: %w", err) + } + + baseServices := baseservices.New(st, rc) + // Create monitor service - monitorService := monitor.NewMonitorService(stor, cfg, notif, rc) + monitorService := monitor.NewMonitorService(l, st, cfg, notif, rc, baseServices) // Create web server - webServer, err := web.NewServer(l, cfg, models.SystemInfo{}, monitorService, stor, rc, upgr) + webServer, err := web.NewServer(l, cfg, models.SystemInfo{}, baseServices, monitorService, rc, upgr) if err != nil { return nil, fmt.Errorf("failed to create web server: %w", err) } @@ -266,7 +275,7 @@ func setupTestSuite() (*TestSuite, error) { server: webServer, baseURL: fmt.Sprintf("http://%s:%d", cfg.Server.Host, cfg.Server.Port), client: &http.Client{Timeout: 10 * time.Second}, - stor: stor, + stor: st, ctx: ctx, services: make(map[string]*web.ServiceDTO), incidents: make(map[string]*web.Incident), @@ -278,7 +287,7 @@ func setupTestSuite() (*TestSuite, error) { func (s *TestSuite) cleanup() { if s.stor != nil { - s.stor.Stop(context.Background()) + s.stor.SQLite().Close() } } @@ -456,7 +465,7 @@ func testServiceFilters(s *TestSuite) error { expectedHTTPServices := 0 for _, svc := range s.testServices { - if svc.Protocol == storage.ServiceProtocolTypeHTTP { + if svc.Protocol == models.ServiceProtocolTypeHTTP { expectedHTTPServices++ } } @@ -539,7 +548,7 @@ func testServiceFilters(s *TestSuite) error { // Validate each service matches all filters for _, item := range result.Items { - if item.Protocol != storage.ServiceProtocolTypeHTTP { + if item.Protocol != models.ServiceProtocolTypeHTTP { return fmt.Errorf("multiple filters: service %s doesn't match protocol filter", item.Name) } if !item.IsEnabled { @@ -886,7 +895,7 @@ func testErrorHandling(s *TestSuite) error { // Test invalid service creation invalidService := web.CreateUpdateServiceRequest{ Name: "", // Empty name should fail - Protocol: storage.ServiceProtocolTypeHTTP, + Protocol: models.ServiceProtocolTypeHTTP, } resp, err := s.makeRequest("POST", "/api/v1/services", invalidService) diff --git a/internal/models/service.go b/internal/models/service.go index bceeccd..c26953a 100644 --- a/internal/models/service.go +++ b/internal/models/service.go @@ -33,7 +33,7 @@ type ServiceFullView struct { Protocol ServiceProtocolType `json:"protocol"` Interval time.Duration `json:"interval" swaggertype:"primitive,integer"` Timeout time.Duration `json:"timeout" swaggertype:"primitive,integer"` - Retries int `json:"retries"` + Retries int64 `json:"retries"` Tags []string `json:"tags"` Config map[string]any `json:"config"` IsEnabled bool `json:"is_enabled"` diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 04bca2c..abbd1e8 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -14,7 +14,7 @@ import ( "github.com/sxwebdev/sentinel/internal/services/baseservices" "github.com/sxwebdev/sentinel/internal/services/incidents" "github.com/sxwebdev/sentinel/internal/services/servicestate" - "github.com/sxwebdev/sentinel/internal/storage" + "github.com/sxwebdev/sentinel/internal/store" "github.com/sxwebdev/sentinel/internal/store/repos/repo_service_states" "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/utils" @@ -25,7 +25,7 @@ import ( // MonitorService handles service monitoring type MonitorService struct { logger logger.Logger - storage *storage.Storage + store *store.Store config *config.ConfigHub notifier *notifier.Notifier receiver *receiver.Receiver @@ -35,7 +35,7 @@ type MonitorService struct { // NewMonitorService creates a new monitor service func NewMonitorService( logger logger.Logger, - storage *storage.Storage, + store *store.Store, config *config.ConfigHub, notifier *notifier.Notifier, receiver *receiver.Receiver, @@ -43,7 +43,7 @@ func NewMonitorService( ) *MonitorService { return &MonitorService{ logger: logger, - storage: storage, + store: store, config: config, notifier: notifier, receiver: receiver, @@ -186,7 +186,7 @@ func (m *MonitorService) resolveActiveIncidents(ctx context.Context, serviceID s return fmt.Errorf("failed to resolve incidents: %w", err) } - err = storecmn.WrapTx(ctx, m.storage.SQLiteDB(), func(txCtx *sql.Tx) error { + err = storecmn.WrapTx(ctx, m.store.SQLite(), func(txCtx *sql.Tx) error { for _, incident := range incidents { resolverIncident, err := m.baseservices.Incidents().ResolveByID(ctx, incident.ID) if err != nil { @@ -209,70 +209,3 @@ func (m *MonitorService) resolveActiveIncidents(ctx context.Context, serviceID s return nil } - -// resolveAllActiveIncidents resolves all active incidents for a service -// func (m *MonitorService) resolveAllActiveIncidents(ctx context.Context, serviceID string) error { -// return m.resolveActiveIncidents(ctx, serviceID) -// } - -// // ForceResolveIncidents manually resolves all active incidents for a service -// func (m *MonitorService) ForceResolveIncidents(ctx context.Context, serviceID string) error { -// return m.resolveAllActiveIncidents(ctx, serviceID) -// } - -// CheckService performs a health check on a service -func (m *MonitorService) CheckService(ctx context.Context, service *storage.Service) error { - // Get current service state - serviceState, err := m.baseservices.ServiceStates().GetByServiceID(ctx, service.ID) - if err != nil { - return fmt.Errorf("failed to get service state: %w", err) - } - - // Perform the check (simplified - just record success/failure) - startTime := time.Now() - responseTime := time.Since(startTime) - now := time.Now() - - // For now, just record success (this should be replaced with actual check logic) - wasDown := serviceState.Status == models.StatusDown - - updateParams := servicestate.UpdateParams{ - ServiceState: models.ServiceState{ - Status: models.StatusUp, - LastCheck: &now, - ResponseTime: utils.Pointer(responseTime.Milliseconds()), - ConsecutiveFails: 0, - ConsecutiveSuccess: serviceState.ConsecutiveSuccess + 1, - TotalChecks: serviceState.TotalChecks + 1, - LastError: nil, - }, - FieldMask: dbutils.FieldMask[repo_service_states.ColumnName]{ - repo_service_states.ColumnNameServiceStatesStatus, - repo_service_states.ColumnNameServiceStatesLastCheck, - repo_service_states.ColumnNameServiceStatesResponseTime, - repo_service_states.ColumnNameServiceStatesConsecutiveFails, - repo_service_states.ColumnNameServiceStatesConsecutiveSuccess, - repo_service_states.ColumnNameServiceStatesTotalChecks, - repo_service_states.ColumnNameServiceStatesLastError, - }, - } - - // Save to database - if _, err := m.baseservices.ServiceStates().Update(ctx, serviceState.ID, updateParams); err != nil { - return fmt.Errorf("failed to update service state: %w", err) - } - - // Resolve incident if service was down before - if wasDown { - if err := m.resolveActiveIncidents(ctx, service.ID); err != nil { - return fmt.Errorf("failed to resolve incident: %w", err) - } - } - - // Update service state - if _, err := m.baseservices.ServiceStates().Update(ctx, serviceState.ID, updateParams); err != nil { - return fmt.Errorf("failed to update service state: %w", err) - } - - return nil -} diff --git a/internal/monitors/config.go b/internal/monitors/config.go index e1f9c86..2308015 100644 --- a/internal/monitors/config.go +++ b/internal/monitors/config.go @@ -6,7 +6,6 @@ import ( "github.com/go-playground/validator/v10" "github.com/sxwebdev/sentinel/internal/models" - "github.com/sxwebdev/sentinel/internal/storage" ) type Config struct { @@ -84,9 +83,9 @@ func GetConfig[T any](cfg map[string]any, protocol models.ServiceProtocolType) ( // ConvertToMap converts the config to a map[string]any func (c *Config) ConvertToMap() map[string]any { return map[string]any{ - string(storage.ServiceProtocolTypeHTTP): c.HTTP, - string(storage.ServiceProtocolTypeTCP): c.TCP, - string(storage.ServiceProtocolTypeGRPC): c.GRPC, + string(models.ServiceProtocolTypeHTTP): c.HTTP, + string(models.ServiceProtocolTypeTCP): c.TCP, + string(models.ServiceProtocolTypeGRPC): c.GRPC, } } diff --git a/internal/scheduler/job.go b/internal/scheduler/job.go index f9eb8fa..fe01c1d 100644 --- a/internal/scheduler/job.go +++ b/internal/scheduler/job.go @@ -12,7 +12,7 @@ type job struct { serviceName string interval time.Duration timeout time.Duration - retries int + retries int64 ticker *time.Ticker stopChan chan struct{} inProgress atomic.Bool diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index a597d62..9fc06bb 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -230,7 +230,7 @@ func (s *Scheduler) performCheck(job *job) error { var lastErr error var lastAttemptResponseTime time.Duration - for attempt := 1; attempt <= job.retries; attempt++ { + for attempt := int64(1); attempt <= job.retries; attempt++ { // Create context with timeout for this specific check attemptCtx, cancel := context.WithTimeout(job.checkCtx, job.timeout) diff --git a/internal/services/incidents/stats.go b/internal/services/incidents/stats.go new file mode 100644 index 0000000..fa57809 --- /dev/null +++ b/internal/services/incidents/stats.go @@ -0,0 +1,13 @@ +package incidents + +import ( + "context" + "time" + + "github.com/sxwebdev/sentinel/internal/store/repos/repo_incidents" +) + +// StatsByDateRange retrieves the stats of incidents within a specific date range +func (s *Service) StatsByDateRange(ctx context.Context, startTime, endTime time.Time) (repo_incidents.StatsByDateRangeData, error) { + return s.store.Incidents().StatsByDateRange(ctx, startTime, endTime) +} diff --git a/internal/storage/errors.go b/internal/storage/errors.go deleted file mode 100644 index 2b1feea..0000000 --- a/internal/storage/errors.go +++ /dev/null @@ -1,8 +0,0 @@ -package storage - -import "errors" - -var ( - ErrNotFound = errors.New("not found") - ErrAlreadyExists = errors.New("already exists") -) diff --git a/internal/storage/incidents.go b/internal/storage/incidents.go deleted file mode 100644 index c7291e7..0000000 --- a/internal/storage/incidents.go +++ /dev/null @@ -1,420 +0,0 @@ -package storage - -import ( - "context" - "database/sql" - "errors" - "fmt" - "time" - - "github.com/huandu/go-sqlbuilder" -) - -// IncidentRow represents a database row for incidents -type IncidentRow struct { - ID string `db:"id"` - ServiceID string `db:"service_id"` - StartTime time.Time `db:"start_time"` - EndTime *time.Time `db:"end_time"` - Error string `db:"error"` - DurationNS *int64 `db:"duration"` - Resolved bool `db:"resolved"` - CreatedAt time.Time `db:"created_at"` - UpdatedAt time.Time `db:"updated_at"` -} - -// GetIncidentByID retrieves an incident by ID -func (o *Storage) GetIncidentByID(ctx context.Context, id string) (*Incident, error) { - if id == "" { - return nil, fmt.Errorf("id is required") - } - - sb := sqlbuilder.NewSelectBuilder() - sb.Select( - "i.id", - "i.service_id", - "i.start_time", - "i.end_time", - "i.error", - "i.duration", - "i.resolved", - "i.created_at", - "i.updated_at", - ) - sb.From("incidents i") - sb.Where(sb.Equal("i.id", id)) - - query, args := sb.Build() - row := o.db.QueryRowContext(ctx, query, args...) - - var incidentRow IncidentRow - err := row.Scan( - &incidentRow.ID, - &incidentRow.ServiceID, - &incidentRow.StartTime, - &incidentRow.EndTime, - &incidentRow.Error, - &incidentRow.DurationNS, - &incidentRow.Resolved, - &incidentRow.CreatedAt, - &incidentRow.UpdatedAt, - ) - if err != nil { - if errors.Is(err, sql.ErrNoRows) { - return nil, ErrNotFound - } - return nil, fmt.Errorf("failed to scan incident: %w", err) - } - - return o.rowToIncident(&incidentRow), nil -} - -// type FindIncidentsParams struct { -// // Search by service id or incident id -// Search string -// ID string -// ServiceID string -// Resolved *bool -// StartTime *time.Time -// EndTime *time.Time -// Page *uint32 -// PageSize *uint32 -// } - -// func findIncidentsBuilder(params FindIncidentsParams, col ...string) *sqlbuilder.SelectBuilder { -// sb := sqlbuilder.NewSelectBuilder() -// sb.Select(col...) -// sb.From("incidents i") - -// if params.ID != "" { -// sb.Where(sb.Equal("i.id", params.ID)) -// } - -// if params.ServiceID != "" { -// sb.Where(sb.Equal("i.service_id", params.ServiceID)) -// } - -// if params.Search != "" { -// likeCondition := fmt.Sprintf("%%%s%%", params.Search) -// sb.Where(sb.Or( -// sb.Like("i.id", likeCondition), -// sb.Like("i.service_id", likeCondition), -// )) -// } - -// if params.Resolved != nil { -// sb.Where(sb.Equal("i.resolved", *params.Resolved)) -// } - -// if params.StartTime != nil { -// sb.Where(sb.GreaterEqualThan("i.start_time", *params.StartTime)) -// } - -// if params.EndTime != nil { -// sb.Where(sb.LessEqualThan("i.end_time", *params.EndTime)) -// } - -// return sb -// } - -// // FindIncidents finds incidents -// func (o *Storage) FindIncidents(ctx context.Context, params FindIncidentsParams) (storecmn.FindResponseWithCount[*Incident], error) { -// sb := findIncidentsBuilder(params, -// "i.id", -// "i.service_id", -// "i.start_time", -// "i.end_time", -// "i.error", -// "i.duration", -// "i.resolved", -// "i.created_at", -// "i.updated_at", -// ) -// sb.OrderBy("i.start_time").Desc() - -// res := storecmn.FindResponseWithCount[*Incident]{} - -// limit, offset, err := storecmn.Pagination(params.Page, params.PageSize) -// if err != nil { -// return res, fmt.Errorf("failed to apply pagination: %w", err) -// } - -// sb.Limit(int(limit)).Offset(int(offset)) - -// sql, args := sb.Build() -// rows, err := o.db.QueryContext(ctx, sql, args...) -// if err != nil { -// return res, fmt.Errorf("failed to query incidents: %w", err) -// } -// defer rows.Close() - -// incidents := []*Incident{} -// for rows.Next() { -// var incidentRow IncidentRow -// err := rows.Scan( -// &incidentRow.ID, -// &incidentRow.ServiceID, -// &incidentRow.StartTime, -// &incidentRow.EndTime, -// &incidentRow.Error, -// &incidentRow.DurationNS, -// &incidentRow.Resolved, -// &incidentRow.CreatedAt, -// &incidentRow.UpdatedAt, -// ) -// if err != nil { -// return res, fmt.Errorf("failed to scan incident: %w", err) -// } - -// incidents = append(incidents, o.rowToIncident(&incidentRow)) -// } - -// if err := rows.Err(); err != nil { -// return res, fmt.Errorf("error iterating rows: %w", err) -// } - -// // Get total count of incidents -// var totalCount uint32 -// countBuilder := findIncidentsBuilder(params, "COUNT(*)") - -// countQuery, countArgs := countBuilder.Build() -// err = o.db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&totalCount) -// if err != nil { -// return res, fmt.Errorf("failed to count incidents: %w", err) -// } - -// res.Count = totalCount -// res.Items = incidents - -// return res, nil -// } - -// FindIncidents finds incidents -// func (o *Storage) IncidentsCount(ctx context.Context, params FindIncidentsParams) (uint32, error) { -// // Get total count of incidents -// var totalCount uint32 -// countBuilder := findIncidentsBuilder(params, "COUNT(*)") - -// countQuery, countArgs := countBuilder.Build() -// err := o.db.QueryRowContext(ctx, countQuery, countArgs...).Scan(&totalCount) -// if err != nil { -// return 0, fmt.Errorf("failed to count incidents: %w", err) -// } - -// return totalCount, nil -// } - -// // ResolveAllIncidents resolves all incidents for a service -// func (o *Storage) ResolveAllIncidents(ctx context.Context, serviceID string) ([]*Incident, error) { -// if serviceID == "" { -// return nil, fmt.Errorf("serviceID is required") -// } - -// items, err := o.FindIncidents(ctx, FindIncidentsParams{ -// ServiceID: serviceID, -// Resolved: utils.Pointer(false), -// }) -// if err != nil { -// return nil, fmt.Errorf("failed to find incidents: %w", err) -// } - -// tx, err := o.db.BeginTx(ctx, nil) -// if err != nil { -// return nil, fmt.Errorf("failed to begin transaction: %w", err) -// } -// defer tx.Rollback() - -// for _, item := range items.Items { -// now := time.Now() - -// // Update all incidents for the service to resolved -// ub := sqlbuilder.NewUpdateBuilder() - -// ub.Update("incidents"). -// Set( -// ub.Assign("resolved", true), -// ub.Assign("end_time", now), -// ub.Assign("duration", now.Sub(item.StartTime)), -// ub.Assign("updated_at", now), -// ). -// Where( -// ub.Equal("id", item.ID), -// ) - -// sql, args := ub.Build() -// if _, err := tx.ExecContext(ctx, sql, args...); err != nil { -// return nil, fmt.Errorf("failed to resolve incidents: %w", err) -// } -// } - -// // Commit transaction -// if err := tx.Commit(); err != nil { -// return nil, fmt.Errorf("failed to commit transaction: %w", err) -// } - -// resolvedIncidents := []*Incident{} -// for _, item := range items.Items { -// incident, err := o.GetIncidentByID(ctx, item.ID) -// if err != nil { -// return nil, fmt.Errorf("failed to get incident by ID: %w", err) -// } - -// resolvedIncidents = append(resolvedIncidents, incident) -// } - -// return resolvedIncidents, nil -// } - -// SaveIncident creates a new incident using ORM with retry logic -// func (o *Storage) SaveIncident(ctx context.Context, incident *Incident) error { -// ib := sqlbuilder.NewInsertBuilder() -// ib.InsertInto("incidents") -// ib.Cols("id", "service_id", "start_time", "end_time", "error", "duration", "resolved") - -// ib.Values( -// utils.GenerateULID(), -// incident.ServiceID, -// incident.StartTime, -// incident.EndTime, -// incident.Error, -// durationToNS(incident.Duration), -// incident.Resolved, -// ) - -// sql, args := ib.Build() -// _, err := o.db.ExecContext(ctx, sql, args...) -// if err != nil { -// return fmt.Errorf("failed to create incident: %w", err) -// } - -// return nil -// } - -// UpdateIncident updates an existing incident using ORM with retry logic -func (o *Storage) UpdateIncident(ctx context.Context, incident *Incident) error { - ub := sqlbuilder.NewUpdateBuilder() - ub.Update("incidents") - ub.Set( - ub.Assign("service_id", incident.ServiceID), - ub.Assign("start_time", incident.StartTime), - ub.Assign("end_time", incident.EndTime), - ub.Assign("error", incident.Error), - ub.Assign("duration", durationToNS(incident.Duration)), - ub.Assign("resolved", incident.Resolved), - ub.Assign("updated_at", time.Now()), - ) - ub.Where(ub.Equal("id", incident.ID)) - - sql, args := ub.Build() - _, err := o.db.ExecContext(ctx, sql, args...) - if err != nil { - return fmt.Errorf("failed to update incident: %w", err) - } - - return nil -} - -// DeleteIncident deletes an incident by ID using ORM with retry logic -// func (o *Storage) DeleteIncident(ctx context.Context, incidentID string) error { -// db := sqlbuilder.NewDeleteBuilder() -// db.DeleteFrom("incidents") -// db.Where(db.Equal("id", incidentID)) - -// sql, args := db.Build() -// _, err := o.db.ExecContext(ctx, sql, args...) -// if err != nil { -// return fmt.Errorf("failed to delete incident: %w", err) -// } - -// return nil -// } - -type GetIncidentsStatsByDateRangeItem struct { - Date time.Time `json:"date"` - Count int64 `json:"count"` - AvgDuration time.Duration `json:"avg_duration"` - TotalDuration time.Duration `json:"total_duration"` -} - -type GetIncidentsStatsByDateRangeData []GetIncidentsStatsByDateRangeItem - -// GetIncidentsStatsByDateRange retrieves the stats of incidents within a specific date range -func (o *Storage) GetIncidentsStatsByDateRange(ctx context.Context, startTime, endTime time.Time) (GetIncidentsStatsByDateRangeData, error) { - // Generate date series for the range - var result GetIncidentsStatsByDateRangeData - - // Iterate through each day in the range - for d := startTime; !d.After(endTime); d = d.AddDate(0, 0, 1) { - // Get start and end of the day - dayStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location()) - dayEnd := time.Date(d.Year(), d.Month(), d.Day(), 23, 59, 59, 999999999, d.Location()) - - // Query incidents for this specific date range - sb := sqlbuilder.NewSelectBuilder() - sb.Select( - "COUNT(*) as count", - "AVG(CASE WHEN resolved = true THEN duration ELSE (strftime('%s', 'now') - strftime('%s', start_time)) * 1000 END) as avg_duration", - "SUM(CASE WHEN resolved = true THEN duration ELSE (strftime('%s', 'now') - strftime('%s', start_time)) * 1000 END) as total_duration", - ) - sb.From("incidents") - sb.Where(sb.GreaterEqualThan("start_time", dayStart)) - sb.Where(sb.LessEqualThan("start_time", dayEnd)) - - sql, args := sb.Build() - row := o.db.QueryRowContext(ctx, sql, args...) - - var count int64 - var avgDurationNS *float64 - var totalDurationNS *float64 - - if err := row.Scan(&count, &avgDurationNS, &totalDurationNS); err != nil { - return nil, fmt.Errorf("failed to scan incident stats for date %s: %w", d.Format("2006-01-02"), err) - } - - item := GetIncidentsStatsByDateRangeItem{ - Date: d, - Count: count, - } - - // Convert nanoseconds to duration - if avgDurationNS != nil { - item.AvgDuration = time.Duration(int64(*avgDurationNS)) - } - - if totalDurationNS != nil { - item.TotalDuration = time.Duration(int64(*totalDurationNS)) - } - - result = append(result, item) - } - - return result, nil -} - -// rowToIncident converts an IncidentRow to Incident -func (o *Storage) rowToIncident(row *IncidentRow) *Incident { - incident := &Incident{ - ID: row.ID, - ServiceID: row.ServiceID, - StartTime: row.StartTime, - EndTime: row.EndTime, - Error: row.Error, - Resolved: row.Resolved, - } - - if row.DurationNS != nil { - duration := time.Duration(*row.DurationNS) - incident.Duration = &duration - } - - return incident -} - -// durationToNS converts a duration pointer to nanoseconds -func durationToNS(d *time.Duration) *int64 { - if d == nil { - return nil - } - ns := d.Nanoseconds() - return &ns -} diff --git a/internal/storage/models.go b/internal/storage/models.go deleted file mode 100644 index 5c64e15..0000000 --- a/internal/storage/models.go +++ /dev/null @@ -1,63 +0,0 @@ -package storage - -import ( - "time" -) - -type ServiceProtocolType string - -const ( - ServiceProtocolTypeHTTP ServiceProtocolType = "http" - ServiceProtocolTypeTCP ServiceProtocolType = "tcp" - ServiceProtocolTypeGRPC ServiceProtocolType = "grpc" -) - -// Service represents a monitored service -type Service struct { - ID string `json:"id"` - Name string `json:"name"` - Protocol ServiceProtocolType `json:"protocol"` - Interval time.Duration `json:"interval" swaggertype:"primitive,integer"` - Timeout time.Duration `json:"timeout" swaggertype:"primitive,integer"` - Retries int `json:"retries"` - Tags []string `json:"tags"` - Config map[string]any `json:"config"` - IsEnabled bool `json:"is_enabled"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - ActiveIncidents int `json:"active_incidents,omitempty"` - TotalIncidents int `json:"total_incidents,omitempty"` - Status ServiceStatus `json:"status"` - LastCheck *time.Time `json:"last_check,omitempty"` - NextCheck *time.Time `json:"next_check,omitempty"` - LastError *string `json:"last_error,omitempty"` - ConsecutiveFails int `json:"consecutive_fails"` - ConsecutiveSuccess int `json:"consecutive_success"` - TotalChecks int `json:"total_checks"` - ResponseTime *time.Duration `json:"response_time" swaggertype:"primitive,integer"` -} - -// ServiceStatus represents the current status of a service -type ServiceStatus string - -const ( - StatusUnknown ServiceStatus = "unknown" - StatusUp ServiceStatus = "up" - StatusDown ServiceStatus = "down" - StatusMaintenance ServiceStatus = "maintenance" -) - -func (s ServiceStatus) String() string { - return string(s) -} - -// Incident represents a service incident -type Incident struct { - ID string `json:"id"` - ServiceID string `json:"service_id"` - StartTime time.Time `json:"start_time"` - EndTime *time.Time `json:"end_time,omitempty"` - Error string `json:"error"` - Duration *time.Duration `json:"duration,omitempty" swaggertype:"primitive,integer"` - Resolved bool `json:"resolved"` -} diff --git a/internal/storage/storage.go b/internal/storage/storage.go deleted file mode 100644 index 9b674d6..0000000 --- a/internal/storage/storage.go +++ /dev/null @@ -1,85 +0,0 @@ -package storage - -import ( - "context" - "database/sql" - "fmt" - "os" - "path/filepath" - "time" - - "github.com/sxwebdev/sentinel/pkg/migrations" - "github.com/tkcrm/mx/logger" - _ "modernc.org/sqlite" - - emsql "github.com/sxwebdev/sentinel/sql" -) - -// Storage implements Storage interface using SQLite -type Storage struct { - db *sql.DB -} - -// New creates a new SQLite storage instance -func New(l logger.Logger, dbPath string) (*Storage, error) { - // Ensure directory exists - dir := filepath.Dir(dbPath) - if err := os.MkdirAll(dir, 0o755); err != nil { - return nil, fmt.Errorf("failed to create database directory: %w", err) - } - - // Run migrations - m := migrations.New(l, emsql.MigrationsFS, emsql.MigrationsPath) - if err := m.MigrateUpAll(dbPath); err != nil { - return nil, fmt.Errorf("failed to migrate database: %w", err) - } - - // Open SQLite database with proper settings for concurrent access - db, err := sql.Open("sqlite", dbPath+"?_busy_timeout=30000&_journal_mode=WAL&_synchronous=NORMAL&_cache_size=10000&_foreign_keys=on") - if err != nil { - return nil, fmt.Errorf("failed to open database: %w", err) - } - - // Set connection pool settings - db.SetMaxOpenConns(1) - db.SetMaxIdleConns(1) - db.SetConnMaxLifetime(time.Hour) - - // Test connection - if err := db.PingContext(context.Background()); err != nil { - return nil, fmt.Errorf("failed to ping database: %w", err) - } - - return &Storage{ - db: db, - }, nil -} - -// Name returns the storage type -func (s *Storage) Name() string { - return "store" -} - -// Start initializes the storage -func (s *Storage) Start(_ context.Context) error { - if s.db == nil { - return fmt.Errorf("sqlite not initialized") - } - return nil -} - -// Stop closes the database connection -func (s *Storage) Stop(_ context.Context) error { - if s.db != nil { - if err := s.db.Close(); err != nil { - return fmt.Errorf("failed to close sqlite database: %w", err) - } - s.db = nil - } - return nil -} - -// SQLiteDB returns the underlying sql.DB instance -func (s *Storage) SQLiteDB() *sql.DB { - return s.db -} diff --git a/internal/storage/types.go b/internal/storage/types.go deleted file mode 100644 index d899327..0000000 --- a/internal/storage/types.go +++ /dev/null @@ -1,14 +0,0 @@ -package storage - -import "time" - -type CreateUpdateServiceRequest struct { - Name string `json:"name" yaml:"name"` - Protocol ServiceProtocolType `json:"protocol" yaml:"protocol"` - Interval time.Duration `json:"interval" yaml:"interval" swaggertype:"primitive,integer"` - Timeout time.Duration `json:"timeout" yaml:"timeout" swaggertype:"primitive,integer"` - Retries int `json:"retries" yaml:"retries"` - Tags []string `json:"tags" yaml:"tags"` - Config map[string]any `json:"config" yaml:"config"` - IsEnabled bool `json:"is_enabled" yaml:"is_enabled"` -} diff --git a/internal/store/repos/repo_incidents/custom.go b/internal/store/repos/repo_incidents/custom.go index 228e00a..3a44b3d 100644 --- a/internal/store/repos/repo_incidents/custom.go +++ b/internal/store/repos/repo_incidents/custom.go @@ -3,6 +3,7 @@ package repo_incidents import ( "context" "database/sql" + "time" "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/store/storecmn" @@ -11,6 +12,7 @@ import ( type ICustomQuerier interface { Querier Find(ctx context.Context, params FindParams) (*storecmn.FindResponseWithCount[*models.Incident], error) + StatsByDateRange(ctx context.Context, startTime, endTime time.Time) (StatsByDateRangeData, error) } type CustomQueries struct { diff --git a/internal/store/repos/repo_incidents/stats.go b/internal/store/repos/repo_incidents/stats.go new file mode 100644 index 0000000..0ffff76 --- /dev/null +++ b/internal/store/repos/repo_incidents/stats.go @@ -0,0 +1,71 @@ +package repo_incidents + +import ( + "context" + "fmt" + "time" + + "github.com/huandu/go-sqlbuilder" +) + +type StatsByDateRangeItem struct { + Date time.Time `json:"date"` + Count int64 `json:"count"` + AvgDuration int64 `json:"avg_duration"` + TotalDuration int64 `json:"total_duration"` +} + +type StatsByDateRangeData []StatsByDateRangeItem + +// StatsByDateRange retrieves the stats of incidents within a specific date range +func (o *CustomQueries) StatsByDateRange(ctx context.Context, startTime, endTime time.Time) (StatsByDateRangeData, error) { + // Generate date series for the range + var result StatsByDateRangeData + + // Iterate through each day in the range + for d := startTime; !d.After(endTime); d = d.AddDate(0, 0, 1) { + // Get start and end of the day + dayStart := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location()) + dayEnd := time.Date(d.Year(), d.Month(), d.Day(), 23, 59, 59, 999999999, d.Location()) + + // Query incidents for this specific date range + sb := sqlbuilder.NewSelectBuilder() + sb.Select( + "COUNT(*) as count", + "AVG(CASE WHEN resolved = true THEN duration ELSE (strftime('%s', 'now') - strftime('%s', start_time)) * 1000 END) as avg_duration", + "SUM(CASE WHEN resolved = true THEN duration ELSE (strftime('%s', 'now') - strftime('%s', start_time)) * 1000 END) as total_duration", + ) + sb.From("incidents") + sb.Where(sb.GreaterEqualThan("start_time", dayStart)) + sb.Where(sb.LessEqualThan("start_time", dayEnd)) + + sql, args := sb.Build() + row := o.db.QueryRowContext(ctx, sql, args...) + + var count int64 + var avgDuration *float64 + var totalDuration *float64 + + if err := row.Scan(&count, &avgDuration, &totalDuration); err != nil { + return nil, fmt.Errorf("failed to scan incident stats for date %s: %w", d.Format("2006-01-02"), err) + } + + item := StatsByDateRangeItem{ + Date: d, + Count: count, + } + + // Convert nanoseconds to duration + if avgDuration != nil { + item.AvgDuration = int64(*avgDuration) + } + + if totalDuration != nil { + item.TotalDuration = int64(*totalDuration) + } + + result = append(result, item) + } + + return result, nil +} diff --git a/internal/store/repos/repo_services/types.go b/internal/store/repos/repo_services/types.go index 3798b5e..70ee6c6 100644 --- a/internal/store/repos/repo_services/types.go +++ b/internal/store/repos/repo_services/types.go @@ -15,7 +15,7 @@ type itemViewRow struct { Protocol string Interval string Timeout string - Retries int + Retries int64 Tags string Config string IsEnabled bool diff --git a/internal/web/dto.go b/internal/web/dto.go index 502b2d9..164144b 100644 --- a/internal/web/dto.go +++ b/internal/web/dto.go @@ -69,7 +69,7 @@ type ServiceDTO struct { Protocol models.ServiceProtocolType `json:"protocol" example:"http"` Interval uint32 `json:"interval" swaggertype:"primitive,integer" example:"60000"` Timeout uint32 `json:"timeout" swaggertype:"primitive,integer" example:"10000"` - Retries int `json:"retries" example:"5"` + Retries int64 `json:"retries" example:"5"` Tags []string `json:"tags" example:"web,production"` Config monitors.Config `json:"config"` IsEnabled bool `json:"is_enabled" example:"true"` diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 59a0363..b038797 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -38,7 +38,6 @@ import ( "github.com/sxwebdev/sentinel/internal/services/baseservices" "github.com/sxwebdev/sentinel/internal/services/incidents" "github.com/sxwebdev/sentinel/internal/services/service" - "github.com/sxwebdev/sentinel/internal/storage" "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/upgrader" "github.com/sxwebdev/sentinel/internal/utils" @@ -57,7 +56,6 @@ type Server struct { wsMutex sync.Mutex validator *validator.Validate - storage *storage.Storage baseServices *baseservices.BaseServices monitorService *monitor.MonitorService receiver *receiver.Receiver @@ -71,7 +69,6 @@ func NewServer( serverInfo models.SystemInfo, baseServices *baseservices.BaseServices, monitorService *monitor.MonitorService, - storage *storage.Storage, receiver *receiver.Receiver, upgrader *upgrader.Upgrader, ) (*Server, error) { @@ -87,7 +84,6 @@ func NewServer( logger: logger, serverInfo: serverInfo, monitorService: monitorService, - storage: storage, receiver: receiver, config: cfg, app: app, @@ -672,7 +668,7 @@ func (s *Server) handleAPIGetIncidentsStats(c *fiber.Ctx) error { return newErrorResponse(c, fiber.StatusBadRequest, errors.New("end_time must be after start_time")) } - stats, err := s.storage.GetIncidentsStatsByDateRange(c.Context(), startTime, endTime) + stats, err := s.baseServices.Incidents().StatsByDateRange(c.Context(), startTime, endTime) if err != nil { return newErrorResponse(c, fiber.StatusInternalServerError, errors.New("failed to get incidents stats: "+err.Error())) } @@ -683,10 +679,10 @@ func (s *Server) handleAPIGetIncidentsStats(c *fiber.Ctx) error { response = append(response, getIncidentsStatsItem{ Date: item.Date, Count: item.Count, - AvgDuration: uint32(item.AvgDuration.Seconds()), - AvgDurationHuman: item.AvgDuration.Round(time.Second).String(), - TotalDuration: uint32(item.TotalDuration.Seconds()), - TotalDurationHuman: item.TotalDuration.Round(time.Second).String(), + AvgDuration: uint32(item.AvgDuration), + AvgDurationHuman: (time.Duration(item.AvgDuration) * time.Millisecond).Round(time.Second).String(), + TotalDuration: uint32(item.TotalDuration), + TotalDurationHuman: (time.Duration(item.TotalDuration) * time.Millisecond).Round(time.Second).String(), }) } diff --git a/internal/web/responses.go b/internal/web/responses.go index 3c8c74e..3f0c401 100644 --- a/internal/web/responses.go +++ b/internal/web/responses.go @@ -4,7 +4,7 @@ import ( "errors" "github.com/gofiber/fiber/v2" - "github.com/sxwebdev/sentinel/internal/storage" + "github.com/sxwebdev/sentinel/internal/store/storecmn" ) // ErrorResponse represents an error response @@ -16,13 +16,13 @@ type ErrorResponse struct { // newErrorResponse creates a new ErrorResponse and sends it as a JSON response func newErrorResponse(c *fiber.Ctx, status int, err error) error { - if errors.Is(err, storage.ErrNotFound) { + if errors.Is(err, storecmn.ErrNotFound) { return c.Status(fiber.StatusNotFound).JSON(ErrorResponse{ Error: err.Error(), }) } - if errors.Is(err, storage.ErrAlreadyExists) { + if errors.Is(err, storecmn.ErrAlreadyExists) { return c.Status(fiber.StatusConflict).JSON(ErrorResponse{ Error: err.Error(), }) diff --git a/internal/web/websockets.go b/internal/web/websockets.go index a4977ed..976b977 100644 --- a/internal/web/websockets.go +++ b/internal/web/websockets.go @@ -75,10 +75,6 @@ func (s *Server) handleWebSocket(c *websocket.Conn) { // BroadcastServiceUpdate sends service updates to all connected WebSocket clients func (s *Server) broadcastServiceTriggered(data receiver.TriggerServiceData) error { - if s.storage == nil { - return nil - } - svc := ServiceDTO{ ID: data.Svc.ID, } @@ -131,10 +127,6 @@ func (s *Server) broadcastServiceTriggered(data receiver.TriggerServiceData) err // broadcastStatsUpdate sends dashboard statistics updates to all connected WebSocket clients func (s *Server) broadcastStatsUpdate(ctx context.Context) error { - if s.storage == nil { - return nil - } - stats, err := s.getDashboardStats(ctx) if err != nil { return fmt.Errorf("failed to get dashboard stats: %w", err) @@ -205,8 +197,7 @@ func (s *Server) subscribeEvents(ctx context.Context) error { // reset ticker ticker.Reset(30 * time.Second) - if s.storage == nil || - data.EventType == receiver.TriggerServiceEventTypeCheck || + if data.EventType == receiver.TriggerServiceEventTypeCheck || data.EventType == receiver.TriggerServiceEventTypeUnknown { continue } From 44720da76b7a457151e24ff48fc12893abb2b6ef Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Fri, 19 Sep 2025 22:18:58 +0300 Subject: [PATCH 13/71] refactor: Simplify MonitorService initialization by removing unused parameters --- cmd/sentinel/hub.go | 2 +- cmd/testapi/main.go | 2 +- internal/monitor/monitor.go | 8 -------- 3 files changed, 2 insertions(+), 10 deletions(-) diff --git a/cmd/sentinel/hub.go b/cmd/sentinel/hub.go index 6b5a706..ed14a4c 100644 --- a/cmd/sentinel/hub.go +++ b/cmd/sentinel/hub.go @@ -109,7 +109,7 @@ func hubStartCMD() *cli.Command { baseServices := baseservices.New(st, rc) // Create monitor service - monitorService := monitor.NewMonitorService(l, st, conf, notif, rc, baseServices) + monitorService := monitor.NewMonitorService(l, st, notif, baseServices) // Initialize scheduler sched := scheduler.New(l, monitorService, rc, baseServices) diff --git a/cmd/testapi/main.go b/cmd/testapi/main.go index 29b0953..228505e 100644 --- a/cmd/testapi/main.go +++ b/cmd/testapi/main.go @@ -253,7 +253,7 @@ func setupTestSuite() (*TestSuite, error) { baseServices := baseservices.New(st, rc) // Create monitor service - monitorService := monitor.NewMonitorService(l, st, cfg, notif, rc, baseServices) + monitorService := monitor.NewMonitorService(l, st, notif, baseServices) // Create web server webServer, err := web.NewServer(l, cfg, models.SystemInfo{}, baseServices, monitorService, rc, upgr) diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index abbd1e8..5e5f23e 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -7,10 +7,8 @@ import ( "log" "time" - "github.com/sxwebdev/sentinel/internal/config" "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/notifier" - "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/services/baseservices" "github.com/sxwebdev/sentinel/internal/services/incidents" "github.com/sxwebdev/sentinel/internal/services/servicestate" @@ -26,9 +24,7 @@ import ( type MonitorService struct { logger logger.Logger store *store.Store - config *config.ConfigHub notifier *notifier.Notifier - receiver *receiver.Receiver baseservices *baseservices.BaseServices } @@ -36,17 +32,13 @@ type MonitorService struct { func NewMonitorService( logger logger.Logger, store *store.Store, - config *config.ConfigHub, notifier *notifier.Notifier, - receiver *receiver.Receiver, baseservices *baseservices.BaseServices, ) *MonitorService { return &MonitorService{ logger: logger, store: store, - config: config, notifier: notifier, - receiver: receiver, baseservices: baseservices, } } From 99670d4af79199e59d17aa54e1ac8e5ae195c50b Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Fri, 19 Sep 2025 22:27:41 +0300 Subject: [PATCH 14/71] refactor: Remove MonitorService and update related components to streamline service handling --- cmd/sentinel/hub.go | 8 +- cmd/testapi/main.go | 10 +- internal/monitor/monitor.go | 203 -------------------------------- internal/scheduler/scheduler.go | 196 ++++++++++++++++++++++++++++-- internal/web/handlers.go | 28 ++--- 5 files changed, 199 insertions(+), 246 deletions(-) delete mode 100644 internal/monitor/monitor.go diff --git a/cmd/sentinel/hub.go b/cmd/sentinel/hub.go index ed14a4c..84c23f8 100644 --- a/cmd/sentinel/hub.go +++ b/cmd/sentinel/hub.go @@ -9,7 +9,6 @@ import ( "github.com/sxwebdev/sentinel/internal/config" "github.com/sxwebdev/sentinel/internal/models" - "github.com/sxwebdev/sentinel/internal/monitor" "github.com/sxwebdev/sentinel/internal/notifier" "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/scheduler" @@ -108,16 +107,13 @@ func hubStartCMD() *cli.Command { baseServices := baseservices.New(st, rc) - // Create monitor service - monitorService := monitor.NewMonitorService(l, st, notif, baseServices) - // Initialize scheduler - sched := scheduler.New(l, monitorService, rc, baseServices) + sched := scheduler.New(l, st, notif, rc, baseServices) serverInfo := models.GetSystemInfo(version, commitHash, buildDate) serverInfo.SqliteVersion = sqliteVersion - webServer, err := web.NewServer(l, conf, serverInfo, baseServices, monitorService, rc, upgr) + webServer, err := web.NewServer(l, conf, serverInfo, baseServices, rc, upgr) if err != nil { return fmt.Errorf("failed to initialize web server: %w", err) } diff --git a/cmd/testapi/main.go b/cmd/testapi/main.go index 228505e..2b3700e 100644 --- a/cmd/testapi/main.go +++ b/cmd/testapi/main.go @@ -16,9 +16,7 @@ import ( "github.com/sxwebdev/sentinel/internal/config" "github.com/sxwebdev/sentinel/internal/models" - "github.com/sxwebdev/sentinel/internal/monitor" "github.com/sxwebdev/sentinel/internal/monitors" - "github.com/sxwebdev/sentinel/internal/notifier" "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/services/baseservices" "github.com/sxwebdev/sentinel/internal/store" @@ -224,9 +222,6 @@ func setupTestSuite() (*TestSuite, error) { l := logger.Default() - // Initialize notifier (disabled for tests) - var notif *notifier.Notifier - // Initialize receiver rc := receiver.New() if err := rc.Start(ctx); err != nil { @@ -252,11 +247,8 @@ func setupTestSuite() (*TestSuite, error) { baseServices := baseservices.New(st, rc) - // Create monitor service - monitorService := monitor.NewMonitorService(l, st, notif, baseServices) - // Create web server - webServer, err := web.NewServer(l, cfg, models.SystemInfo{}, baseServices, monitorService, rc, upgr) + webServer, err := web.NewServer(l, cfg, models.SystemInfo{}, baseServices, rc, upgr) if err != nil { return nil, fmt.Errorf("failed to create web server: %w", err) } diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go deleted file mode 100644 index 5e5f23e..0000000 --- a/internal/monitor/monitor.go +++ /dev/null @@ -1,203 +0,0 @@ -package monitor - -import ( - "context" - "database/sql" - "fmt" - "log" - "time" - - "github.com/sxwebdev/sentinel/internal/models" - "github.com/sxwebdev/sentinel/internal/notifier" - "github.com/sxwebdev/sentinel/internal/services/baseservices" - "github.com/sxwebdev/sentinel/internal/services/incidents" - "github.com/sxwebdev/sentinel/internal/services/servicestate" - "github.com/sxwebdev/sentinel/internal/store" - "github.com/sxwebdev/sentinel/internal/store/repos/repo_service_states" - "github.com/sxwebdev/sentinel/internal/store/storecmn" - "github.com/sxwebdev/sentinel/internal/utils" - "github.com/tkcrm/modules/pkg/db/dbutils" - "github.com/tkcrm/mx/logger" -) - -// MonitorService handles service monitoring -type MonitorService struct { - logger logger.Logger - store *store.Store - notifier *notifier.Notifier - baseservices *baseservices.BaseServices -} - -// NewMonitorService creates a new monitor service -func NewMonitorService( - logger logger.Logger, - store *store.Store, - notifier *notifier.Notifier, - baseservices *baseservices.BaseServices, -) *MonitorService { - return &MonitorService{ - logger: logger, - store: store, - notifier: notifier, - baseservices: baseservices, - } -} - -// RecordSuccess records a successful check for a service -func (m *MonitorService) RecordSuccess(ctx context.Context, serviceID string, responseTime time.Duration) error { - // Get current service state - serviceState, err := m.baseservices.ServiceStates().GetByServiceID(ctx, serviceID) - if err != nil { - return fmt.Errorf("failed to get service state: %w", err) - } - - // Update state - now := time.Now() - updateParams := servicestate.UpdateParams{ - ServiceState: models.ServiceState{ - Status: models.StatusUp, - LastCheck: &now, - ResponseTime: utils.Pointer(responseTime.Milliseconds()), - ConsecutiveFails: 0, - ConsecutiveSuccess: serviceState.ConsecutiveSuccess + 1, - TotalChecks: serviceState.TotalChecks + 1, - LastError: nil, - }, - FieldMask: dbutils.FieldMask[repo_service_states.ColumnName]{ - repo_service_states.ColumnNameServiceStatesStatus, - repo_service_states.ColumnNameServiceStatesLastCheck, - repo_service_states.ColumnNameServiceStatesResponseTime, - repo_service_states.ColumnNameServiceStatesConsecutiveFails, - repo_service_states.ColumnNameServiceStatesConsecutiveSuccess, - repo_service_states.ColumnNameServiceStatesTotalChecks, - repo_service_states.ColumnNameServiceStatesLastError, - }, - } - - // Save to database - if _, err := m.baseservices.ServiceStates().Update(ctx, serviceState.ID, updateParams); err != nil { - return fmt.Errorf("failed to update service state: %w", err) - } - - // Resolve any active incidents - if err := m.resolveActiveIncidents(ctx, serviceID); err != nil { - return err - } - - return nil -} - -// RecordFailure records a failed check for a service -func (m *MonitorService) RecordFailure(ctx context.Context, serviceID string, checkErr error, responseTime time.Duration) error { - // Get current service from database - service, err := m.baseservices.Services().GetViewByID(ctx, serviceID) - if err != nil { - return fmt.Errorf("service %s not found in database: %w", serviceID, err) - } - - // Get current service state - serviceState, err := m.baseservices.ServiceStates().GetByServiceID(ctx, serviceID) - if err != nil { - return fmt.Errorf("failed to get service state: %w", err) - } - - // Update state - now := time.Now() - wasUp := serviceState.Status == models.StatusUp || serviceState.Status == models.StatusUnknown - - updateParams := servicestate.UpdateParams{ - ServiceState: models.ServiceState{ - Status: models.StatusDown, - LastCheck: &now, - ResponseTime: utils.Pointer(responseTime.Milliseconds()), - ConsecutiveFails: serviceState.ConsecutiveFails + 1, - ConsecutiveSuccess: 0, - TotalChecks: serviceState.TotalChecks + 1, - LastError: utils.Pointer(checkErr.Error()), - }, - FieldMask: dbutils.FieldMask[repo_service_states.ColumnName]{ - repo_service_states.ColumnNameServiceStatesStatus, - repo_service_states.ColumnNameServiceStatesLastCheck, - repo_service_states.ColumnNameServiceStatesResponseTime, - repo_service_states.ColumnNameServiceStatesConsecutiveFails, - repo_service_states.ColumnNameServiceStatesConsecutiveSuccess, - repo_service_states.ColumnNameServiceStatesTotalChecks, - repo_service_states.ColumnNameServiceStatesLastError, - }, - } - - // Save to database - if _, err := m.baseservices.ServiceStates().Update(ctx, serviceState.ID, updateParams); err != nil { - return fmt.Errorf("failed to update service state for %s: %w", service.Name, err) - } - - // Create incident if service was up before - if wasUp { - if err := m.createIncident(ctx, service, checkErr); err != nil { - return fmt.Errorf("failed to create incident: %w", err) - } - } - - return nil -} - -// createIncident creates a new incident when a service goes down -func (m *MonitorService) createIncident(ctx context.Context, svc *models.ServiceFullView, err error) error { - // Save incident to storage - incident, err := m.baseservices.Incidents().Create(ctx, incidents.CreateParams{ - ServiceID: svc.ID, - Error: err.Error(), - }) - if err != nil { - return fmt.Errorf("failed to save incident for %s: %w", svc.Name, err) - } - - // Send alert notification - if m.notifier != nil { - if err := m.notifier.SendAlert(svc, incident); err != nil { - err := fmt.Errorf("failed to send alert notification for %s: %w", svc.Name, err) - log.Println(err) - return nil - } - } - - return nil -} - -// resolveActiveIncidents resolves the active incident when a service recovers -func (m *MonitorService) resolveActiveIncidents(ctx context.Context, serviceID string) error { - // Get service - svc, err := m.baseservices.Services().GetViewByID(ctx, serviceID) - if err != nil { - return fmt.Errorf("failed to get service: %w", err) - } - - // Resolve all active incidents for this service - incidents, err := m.baseservices.Incidents().GetAllUnresolvedByServiceID(ctx, serviceID) - if err != nil { - return fmt.Errorf("failed to resolve incidents: %w", err) - } - - err = storecmn.WrapTx(ctx, m.store.SQLite(), func(txCtx *sql.Tx) error { - for _, incident := range incidents { - resolverIncident, err := m.baseservices.Incidents().ResolveByID(ctx, incident.ID) - if err != nil { - return fmt.Errorf("failed to resolve incident %s: %w", incident.ID, err) - } - - if m.notifier != nil { - if err := m.notifier.SendRecovery(svc, resolverIncident); err != nil { - m.logger.Errorf("failed to send recovery notification for %s: %v", svc.Name, err) - return nil - } - } - } - - return nil - }) - if err != nil { - return fmt.Errorf("failed to resolve incidents in transaction: %w", err) - } - - return nil -} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 9fc06bb..b446b38 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -2,18 +2,27 @@ package scheduler import ( "context" + "database/sql" "errors" "fmt" + "log" "sync" "time" "github.com/puzpuzpuz/xsync/v3" "github.com/sxwebdev/sentinel/internal/models" - "github.com/sxwebdev/sentinel/internal/monitor" "github.com/sxwebdev/sentinel/internal/monitors" + "github.com/sxwebdev/sentinel/internal/notifier" "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/services/baseservices" + "github.com/sxwebdev/sentinel/internal/services/incidents" "github.com/sxwebdev/sentinel/internal/services/service" + "github.com/sxwebdev/sentinel/internal/services/servicestate" + "github.com/sxwebdev/sentinel/internal/store" + "github.com/sxwebdev/sentinel/internal/store/repos/repo_service_states" + "github.com/sxwebdev/sentinel/internal/store/storecmn" + "github.com/sxwebdev/sentinel/internal/utils" + "github.com/tkcrm/modules/pkg/db/dbutils" "github.com/tkcrm/mx/logger" ) @@ -24,9 +33,11 @@ var ErrServiceNotFound = fmt.Errorf("service not found") type Scheduler struct { logger logger.Logger + store *store.Store + notifier *notifier.Notifier + receiver *receiver.Receiver - monitorSvc *monitor.MonitorService - baseServices *baseservices.BaseServices + baseservices *baseservices.BaseServices jobs *xsync.MapOf[string, *job] wg sync.WaitGroup @@ -35,15 +46,17 @@ type Scheduler struct { // New creates a new scheduler func New( l logger.Logger, - monitorService *monitor.MonitorService, + store *store.Store, + notifier *notifier.Notifier, receiver *receiver.Receiver, baseServices *baseservices.BaseServices, ) *Scheduler { return &Scheduler{ logger: l, - monitorSvc: monitorService, + store: store, + notifier: notifier, receiver: receiver, - baseServices: baseServices, + baseservices: baseServices, jobs: xsync.NewMapOf[string, *job](), } } @@ -55,7 +68,7 @@ func (s *Scheduler) Name() string { return "scheduler" } func (s *Scheduler) Start(ctx context.Context) error { // Load enabled services from storage isEnabled := true - services, err := s.baseServices.Services().FindView(ctx, service.FindParams{ + services, err := s.baseservices.Services().FindView(ctx, service.FindParams{ IsEnabled: &isEnabled, }) if err != nil { @@ -206,7 +219,7 @@ func (s *Scheduler) performCheck(job *job) error { serviceName := job.serviceName // Get current service configuration from database - service, err := s.baseServices.Services().GetByID(job.checkCtx, job.serviceID) + service, err := s.baseservices.Services().GetByID(job.checkCtx, job.serviceID) if err != nil { return fmt.Errorf("failed to get service %s: %w", serviceName, err) } @@ -245,7 +258,7 @@ func (s *Scheduler) performCheck(job *job) error { if err == nil { // Success - record the time of this successful attempt - if err := s.monitorSvc.RecordSuccess(job.checkCtx, job.serviceID, attemptResponseTime); err != nil { + if err := s.recordSuccess(job.checkCtx, job.serviceID, attemptResponseTime); err != nil { return fmt.Errorf("failed to record success for %s: %w", serviceName, err) } @@ -255,7 +268,7 @@ func (s *Scheduler) performCheck(job *job) error { s.logger.Debugf("service %s check successful (attempt %d/%d) in %v", serviceName, attempt, job.retries, attemptResponseTime) } - svc, err := s.baseServices.Services().GetViewByID(job.checkCtx, job.serviceID) + svc, err := s.baseservices.Services().GetViewByID(job.checkCtx, job.serviceID) if err != nil { return fmt.Errorf("failed to get service view for %s: %w", serviceName, err) } @@ -287,11 +300,11 @@ func (s *Scheduler) performCheck(job *job) error { } // All attempts failed - record the time of the last attempt - if err := s.monitorSvc.RecordFailure(job.checkCtx, job.serviceID, lastErr, lastAttemptResponseTime); err != nil { + if err := s.recordFailure(job.checkCtx, job.serviceID, lastErr, lastAttemptResponseTime); err != nil { return fmt.Errorf("failed to record failure for %s: %w", serviceName, err) } - svc, err := s.baseServices.Services().GetViewByID(job.checkCtx, job.serviceID) + svc, err := s.baseservices.Services().GetViewByID(job.checkCtx, job.serviceID) if err != nil { return fmt.Errorf("failed to get service view for %s: %w", serviceName, err) } @@ -392,3 +405,162 @@ func (s *Scheduler) subscribeEvents(ctx context.Context) error { return nil } + +// RecordSuccess records a successful check for a service +func (m *Scheduler) recordSuccess(ctx context.Context, serviceID string, responseTime time.Duration) error { + // Get current service state + serviceState, err := m.baseservices.ServiceStates().GetByServiceID(ctx, serviceID) + if err != nil { + return fmt.Errorf("failed to get service state: %w", err) + } + + // Update state + now := time.Now() + updateParams := servicestate.UpdateParams{ + ServiceState: models.ServiceState{ + Status: models.StatusUp, + LastCheck: &now, + ResponseTime: utils.Pointer(responseTime.Milliseconds()), + ConsecutiveFails: 0, + ConsecutiveSuccess: serviceState.ConsecutiveSuccess + 1, + TotalChecks: serviceState.TotalChecks + 1, + LastError: nil, + }, + FieldMask: dbutils.FieldMask[repo_service_states.ColumnName]{ + repo_service_states.ColumnNameServiceStatesStatus, + repo_service_states.ColumnNameServiceStatesLastCheck, + repo_service_states.ColumnNameServiceStatesResponseTime, + repo_service_states.ColumnNameServiceStatesConsecutiveFails, + repo_service_states.ColumnNameServiceStatesConsecutiveSuccess, + repo_service_states.ColumnNameServiceStatesTotalChecks, + repo_service_states.ColumnNameServiceStatesLastError, + }, + } + + // Save to database + if _, err := m.baseservices.ServiceStates().Update(ctx, serviceState.ID, updateParams); err != nil { + return fmt.Errorf("failed to update service state: %w", err) + } + + // Resolve any active incidents + if err := m.resolveActiveIncidents(ctx, serviceID); err != nil { + return err + } + + return nil +} + +// RecordFailure records a failed check for a service +func (m *Scheduler) recordFailure(ctx context.Context, serviceID string, checkErr error, responseTime time.Duration) error { + // Get current service from database + service, err := m.baseservices.Services().GetViewByID(ctx, serviceID) + if err != nil { + return fmt.Errorf("service %s not found in database: %w", serviceID, err) + } + + // Get current service state + serviceState, err := m.baseservices.ServiceStates().GetByServiceID(ctx, serviceID) + if err != nil { + return fmt.Errorf("failed to get service state: %w", err) + } + + // Update state + now := time.Now() + wasUp := serviceState.Status == models.StatusUp || serviceState.Status == models.StatusUnknown + + updateParams := servicestate.UpdateParams{ + ServiceState: models.ServiceState{ + Status: models.StatusDown, + LastCheck: &now, + ResponseTime: utils.Pointer(responseTime.Milliseconds()), + ConsecutiveFails: serviceState.ConsecutiveFails + 1, + ConsecutiveSuccess: 0, + TotalChecks: serviceState.TotalChecks + 1, + LastError: utils.Pointer(checkErr.Error()), + }, + FieldMask: dbutils.FieldMask[repo_service_states.ColumnName]{ + repo_service_states.ColumnNameServiceStatesStatus, + repo_service_states.ColumnNameServiceStatesLastCheck, + repo_service_states.ColumnNameServiceStatesResponseTime, + repo_service_states.ColumnNameServiceStatesConsecutiveFails, + repo_service_states.ColumnNameServiceStatesConsecutiveSuccess, + repo_service_states.ColumnNameServiceStatesTotalChecks, + repo_service_states.ColumnNameServiceStatesLastError, + }, + } + + // Save to database + if _, err := m.baseservices.ServiceStates().Update(ctx, serviceState.ID, updateParams); err != nil { + return fmt.Errorf("failed to update service state for %s: %w", service.Name, err) + } + + // Create incident if service was up before + if wasUp { + if err := m.createIncident(ctx, service, checkErr); err != nil { + return fmt.Errorf("failed to create incident: %w", err) + } + } + + return nil +} + +// createIncident creates a new incident when a service goes down +func (m *Scheduler) createIncident(ctx context.Context, svc *models.ServiceFullView, err error) error { + // Save incident to storage + incident, err := m.baseservices.Incidents().Create(ctx, incidents.CreateParams{ + ServiceID: svc.ID, + Error: err.Error(), + }) + if err != nil { + return fmt.Errorf("failed to save incident for %s: %w", svc.Name, err) + } + + // Send alert notification + if m.notifier != nil { + if err := m.notifier.SendAlert(svc, incident); err != nil { + err := fmt.Errorf("failed to send alert notification for %s: %w", svc.Name, err) + log.Println(err) + return nil + } + } + + return nil +} + +// resolveActiveIncidents resolves the active incident when a service recovers +func (m *Scheduler) resolveActiveIncidents(ctx context.Context, serviceID string) error { + // Get service + svc, err := m.baseservices.Services().GetViewByID(ctx, serviceID) + if err != nil { + return fmt.Errorf("failed to get service: %w", err) + } + + // Resolve all active incidents for this service + incidents, err := m.baseservices.Incidents().GetAllUnresolvedByServiceID(ctx, serviceID) + if err != nil { + return fmt.Errorf("failed to resolve incidents: %w", err) + } + + err = storecmn.WrapTx(ctx, m.store.SQLite(), func(txCtx *sql.Tx) error { + for _, incident := range incidents { + resolverIncident, err := m.baseservices.Incidents().ResolveByID(ctx, incident.ID) + if err != nil { + return fmt.Errorf("failed to resolve incident %s: %w", incident.ID, err) + } + + if m.notifier != nil { + if err := m.notifier.SendRecovery(svc, resolverIncident); err != nil { + m.logger.Errorf("failed to send recovery notification for %s: %v", svc.Name, err) + return nil + } + } + } + + return nil + }) + if err != nil { + return fmt.Errorf("failed to resolve incidents in transaction: %w", err) + } + + return nil +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index b038797..64ef6e9 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -33,7 +33,6 @@ import ( "github.com/sxwebdev/sentinel/frontend" "github.com/sxwebdev/sentinel/internal/config" "github.com/sxwebdev/sentinel/internal/models" - "github.com/sxwebdev/sentinel/internal/monitor" "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/services/baseservices" "github.com/sxwebdev/sentinel/internal/services/incidents" @@ -56,10 +55,9 @@ type Server struct { wsMutex sync.Mutex validator *validator.Validate - baseServices *baseservices.BaseServices - monitorService *monitor.MonitorService - receiver *receiver.Receiver - upgrader *upgrader.Upgrader + baseServices *baseservices.BaseServices + receiver *receiver.Receiver + upgrader *upgrader.Upgrader } // NewServer creates a new web server @@ -68,7 +66,6 @@ func NewServer( cfg *config.ConfigHub, serverInfo models.SystemInfo, baseServices *baseservices.BaseServices, - monitorService *monitor.MonitorService, receiver *receiver.Receiver, upgrader *upgrader.Upgrader, ) (*Server, error) { @@ -81,16 +78,15 @@ func NewServer( app.Use(cors.New()) server := &Server{ - logger: logger, - serverInfo: serverInfo, - monitorService: monitorService, - receiver: receiver, - config: cfg, - app: app, - baseServices: baseServices, - wsConnections: make(map[*websocket.Conn]bool), - validator: validator.New(), - upgrader: upgrader, + logger: logger, + serverInfo: serverInfo, + receiver: receiver, + config: cfg, + app: app, + baseServices: baseServices, + wsConnections: make(map[*websocket.Conn]bool), + validator: validator.New(), + upgrader: upgrader, } // Setup basic auth if enabled From a91bfad88928196b51c3f4e7f82c31bfb31e62ca Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Sun, 21 Sep 2025 15:46:54 +0300 Subject: [PATCH 15/71] refactor: Enhance migration handling and streamline database initialization --- cmd/sentinel/hub.go | 18 ++++++++++---- internal/scheduler/scheduler.go | 34 ++++++++++++-------------- internal/services/incidents/methods.go | 12 ++++++--- pkg/migrations/up.go | 2 +- pkg/sqlite/sqlite.go | 6 ++--- 5 files changed, 42 insertions(+), 30 deletions(-) diff --git a/cmd/sentinel/hub.go b/cmd/sentinel/hub.go index 84c23f8..8e2aaeb 100644 --- a/cmd/sentinel/hub.go +++ b/cmd/sentinel/hub.go @@ -16,7 +16,9 @@ import ( "github.com/sxwebdev/sentinel/internal/store" "github.com/sxwebdev/sentinel/internal/upgrader" "github.com/sxwebdev/sentinel/internal/web" + "github.com/sxwebdev/sentinel/pkg/migrations" "github.com/sxwebdev/sentinel/pkg/sqlite" + "github.com/sxwebdev/sentinel/sql" "github.com/tkcrm/mx/launcher" "github.com/tkcrm/mx/logger" "github.com/tkcrm/mx/service" @@ -75,11 +77,6 @@ func hubStartCMD() *cli.Command { return fmt.Errorf("failed to initialize sqlite: %w", err) } - st, err := store.New(db.DB) - if err != nil { - return fmt.Errorf("failed to initialize store: %w", err) - } - // Print SQLite version if using SQLite storage sqliteVersion, err := db.GetSQLiteVersion(ctx) if err != nil { @@ -87,6 +84,17 @@ func hubStartCMD() *cli.Command { } l.Infof("SQLite version: %s", sqliteVersion) + // check and run migrations + m := migrations.New(l, sql.MigrationsFS, sql.MigrationsPath) + if err := m.MigrateUpAll(dbPath); err != nil { + return fmt.Errorf("failed to run migrations: %w", err) + } + + st, err := store.New(db.DB) + if err != nil { + return fmt.Errorf("failed to initialize store: %w", err) + } + // Initialize notifier var notif *notifier.Notifier if conf.Notifications.Enabled { diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index b446b38..ae5583b 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -2,7 +2,6 @@ package scheduler import ( "context" - "database/sql" "errors" "fmt" "log" @@ -20,7 +19,6 @@ import ( "github.com/sxwebdev/sentinel/internal/services/servicestate" "github.com/sxwebdev/sentinel/internal/store" "github.com/sxwebdev/sentinel/internal/store/repos/repo_service_states" - "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/utils" "github.com/tkcrm/modules/pkg/db/dbutils" "github.com/tkcrm/mx/logger" @@ -75,6 +73,8 @@ func (s *Scheduler) Start(ctx context.Context) error { return fmt.Errorf("failed to load services: %w", err) } + s.logger.Infof("starting scheduler with %d enabled services", len(services.Items)) + // Get all services under read lock for _, svc := range services.Items { s.addService(ctx, svc) @@ -541,25 +541,23 @@ func (m *Scheduler) resolveActiveIncidents(ctx context.Context, serviceID string return fmt.Errorf("failed to resolve incidents: %w", err) } - err = storecmn.WrapTx(ctx, m.store.SQLite(), func(txCtx *sql.Tx) error { - for _, incident := range incidents { - resolverIncident, err := m.baseservices.Incidents().ResolveByID(ctx, incident.ID) - if err != nil { - return fmt.Errorf("failed to resolve incident %s: %w", incident.ID, err) - } + if len(incidents) == 0 { + // No active incidents to resolve + return nil + } - if m.notifier != nil { - if err := m.notifier.SendRecovery(svc, resolverIncident); err != nil { - m.logger.Errorf("failed to send recovery notification for %s: %v", svc.Name, err) - return nil - } - } + for _, incident := range incidents { + resolverIncident, err := m.baseservices.Incidents().ResolveByID(ctx, incident.ID) + if err != nil { + return fmt.Errorf("failed to resolve incident %s: %w", incident.ID, err) } - return nil - }) - if err != nil { - return fmt.Errorf("failed to resolve incidents in transaction: %w", err) + if m.notifier != nil { + if err := m.notifier.SendRecovery(svc, resolverIncident); err != nil { + m.logger.Errorf("failed to send recovery notification for %s: %v", svc.Name, err) + return nil + } + } } return nil diff --git a/internal/services/incidents/methods.go b/internal/services/incidents/methods.go index 03b7799..fd8a175 100644 --- a/internal/services/incidents/methods.go +++ b/internal/services/incidents/methods.go @@ -7,6 +7,7 @@ import ( "github.com/go-playground/validator/v10" "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/repos" "github.com/sxwebdev/sentinel/internal/store/repos/repo_incidents" "github.com/sxwebdev/sentinel/internal/store/storecmn" "github.com/sxwebdev/sentinel/internal/utils" @@ -40,16 +41,21 @@ func (s *Service) GetAllUnresolvedByServiceID(ctx context.Context, serviceID str } // ResolveByID resolves a specific incident by its ID. -func (s *Service) ResolveByID(ctx context.Context, id string) (*models.Incident, error) { +func (s *Service) ResolveByID(ctx context.Context, id string, opts ...repos.Option) (*models.Incident, error) { if id == "" { return nil, storecmn.ErrEmptyID } - if err := s.store.Incidents().ResolveByID(ctx, id); err != nil { + if err := s.store.Incidents(opts...).ResolveByID(ctx, id); err != nil { return nil, err } - return s.GetByID(ctx, id) + item, err := s.store.Incidents().GetByID(ctx, id) + if err != nil { + return nil, err + } + + return item, nil } type CreateParams struct { diff --git a/pkg/migrations/up.go b/pkg/migrations/up.go index 88a4dc6..5dfc05d 100644 --- a/pkg/migrations/up.go +++ b/pkg/migrations/up.go @@ -6,7 +6,7 @@ import ( // MigrateUpAll runs all pending database migrations func (m *Migrations) MigrateUpAll(dbPath string) error { - m.info("run all migrations") + m.info("applying all migrations") migrations, err := m.loadFromFS() if err != nil { diff --git a/pkg/sqlite/sqlite.go b/pkg/sqlite/sqlite.go index 008098c..fc68ba0 100644 --- a/pkg/sqlite/sqlite.go +++ b/pkg/sqlite/sqlite.go @@ -20,8 +20,6 @@ func New(ctx context.Context, dbPath string) (*SQLite, error) { return nil, fmt.Errorf("database path is empty") } - instance := &SQLite{} - dir := filepath.Dir(dbPath) if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("failed to create database directory: %w", err) @@ -43,7 +41,9 @@ func New(ctx context.Context, dbPath string) (*SQLite, error) { return nil, fmt.Errorf("failed to ping database: %w", err) } - instance.DB = db + instance := &SQLite{ + DB: db, + } return instance, nil } From 20d9fc18d27e3a8e14080e08fd1949293f50efac Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Mon, 22 Sep 2025 15:55:31 +0300 Subject: [PATCH 16/71] feat: Add incident states, notification providers, and notification history models and repositories --- internal/models/models_gen.go | 28 +++++ internal/notifier/shoutrrr.go | 7 +- .../repo_incident_states/constants_gen.go | 59 ++++++++++ .../store/repos/repo_incident_states/db.go | 31 ++++++ .../incident_states_gen.sql.go | 105 ++++++++++++++++++ .../repos/repo_incident_states/querier.go | 20 ++++ .../constants_gen.go | 61 ++++++++++ .../repos/repo_notification_history/db.go | 31 ++++++ .../notification_history_gen.sql.go | 77 +++++++++++++ .../repo_notification_history/querier.go | 19 ++++ .../constants_gen.go | 59 ++++++++++ .../repos/repo_notification_providers/db.go | 31 ++++++ .../notification_providers_gen.sql.go | 72 ++++++++++++ .../repo_notification_providers/querier.go | 19 ++++ internal/store/repos/repos.go | 58 ++++++++-- pgxgen.yaml | 63 +++++++++-- sql/migrations/2_agents.up.sql | 35 ++++++ .../incident_states/incident_states_gen.sql | 14 +++ .../notification_history_gen.sql | 11 ++ .../notification_providers_gen.sql | 11 ++ sqlc.yaml | 65 +++++++++++ 21 files changed, 856 insertions(+), 20 deletions(-) create mode 100755 internal/store/repos/repo_incident_states/constants_gen.go create mode 100644 internal/store/repos/repo_incident_states/db.go create mode 100644 internal/store/repos/repo_incident_states/incident_states_gen.sql.go create mode 100644 internal/store/repos/repo_incident_states/querier.go create mode 100755 internal/store/repos/repo_notification_history/constants_gen.go create mode 100644 internal/store/repos/repo_notification_history/db.go create mode 100644 internal/store/repos/repo_notification_history/notification_history_gen.sql.go create mode 100644 internal/store/repos/repo_notification_history/querier.go create mode 100755 internal/store/repos/repo_notification_providers/constants_gen.go create mode 100644 internal/store/repos/repo_notification_providers/db.go create mode 100644 internal/store/repos/repo_notification_providers/notification_providers_gen.sql.go create mode 100644 internal/store/repos/repo_notification_providers/querier.go create mode 100755 sql/queries/incident_states/incident_states_gen.sql create mode 100755 sql/queries/notification_history/notification_history_gen.sql create mode 100755 sql/queries/notification_providers/notification_providers_gen.sql diff --git a/internal/models/models_gen.go b/internal/models/models_gen.go index 7d32330..30560d9 100644 --- a/internal/models/models_gen.go +++ b/internal/models/models_gen.go @@ -42,6 +42,34 @@ type Incident struct { UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` } +type IncidentState struct { + ID string `db:"id" json:"id"` + IncidentID string `db:"incident_id" json:"incident_id"` + Status string `db:"status" json:"status"` + Level int64 `db:"level" json:"level"` + CreatedAt *time.Time `db:"created_at" json:"created_at"` + UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` +} + +type NotificationHistory struct { + ID string `db:"id" json:"id"` + ProviderID string `db:"provider_id" json:"provider_id"` + IncidentID *string `db:"incident_id" json:"incident_id"` + Message string `db:"message" json:"message"` + Status string `db:"status" json:"status"` + ErrorMessage *string `db:"error_message" json:"error_message"` + CreatedAt *time.Time `db:"created_at" json:"created_at"` +} + +type NotificationProvider struct { + ID string `db:"id" json:"id"` + ProviderType string `db:"provider_type" json:"provider_type"` + Config storecmn.JSONField `db:"config" json:"config"` + IsEnabled bool `db:"is_enabled" json:"is_enabled"` + CreatedAt *time.Time `db:"created_at" json:"created_at"` + UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` +} + type Service struct { ID string `db:"id" json:"id"` Name string `db:"name" json:"name"` diff --git a/internal/notifier/shoutrrr.go b/internal/notifier/shoutrrr.go index b965e7f..68614e1 100644 --- a/internal/notifier/shoutrrr.go +++ b/internal/notifier/shoutrrr.go @@ -59,12 +59,9 @@ func (s *Notifier) Start(ctx context.Context) error { } s.isStarted = true - s.wg.Add(1) - go func() { - defer s.wg.Done() - s.processQueue() - }() + // Start processing queue in a separate goroutine + s.wg.Go(s.processQueue) return nil } diff --git a/internal/store/repos/repo_incident_states/constants_gen.go b/internal/store/repos/repo_incident_states/constants_gen.go new file mode 100755 index 0000000..20ff0d0 --- /dev/null +++ b/internal/store/repos/repo_incident_states/constants_gen.go @@ -0,0 +1,59 @@ +// Code generated by pgxgen. DO NOT EDIT. +// versions: +// +// pgxgen v0.3.11 +package repo_incident_states + +import ( + "strings" + + "github.com/gobeam/stringy" +) + +type TableName string + +func (s TableName) String() string { return string(s) } + +const ( + TableNameIncidentStates TableName = "incident_states" +) + +type ColumnName string + +func (s ColumnName) String() string { return string(s) } + +func (s ColumnName) StructName() string { + v := stringy.New(string(s)).CamelCase().Get() + v = stringy.New(v).UcFirst() + return strings.ReplaceAll(v, "Id", "ID") +} + +type ColumnNames []ColumnName + +func (s ColumnNames) Strings() []string { + res := make([]string, len(s)) + for idx, colName := range s { + res[idx] = colName.String() + } + return res +} + +const ( + ColumnNameIncidentStatesId ColumnName = "id" + ColumnNameIncidentStatesIncidentId ColumnName = "incident_id" + ColumnNameIncidentStatesStatus ColumnName = "status" + ColumnNameIncidentStatesLevel ColumnName = "level" + ColumnNameIncidentStatesCreatedAt ColumnName = "created_at" + ColumnNameIncidentStatesUpdatedAt ColumnName = "updated_at" +) + +func IncidentStatesColumnNames() ColumnNames { + return ColumnNames{ + ColumnNameIncidentStatesId, + ColumnNameIncidentStatesIncidentId, + ColumnNameIncidentStatesStatus, + ColumnNameIncidentStatesLevel, + ColumnNameIncidentStatesCreatedAt, + ColumnNameIncidentStatesUpdatedAt, + } +} diff --git a/internal/store/repos/repo_incident_states/db.go b/internal/store/repos/repo_incident_states/db.go new file mode 100644 index 0000000..7bf673d --- /dev/null +++ b/internal/store/repos/repo_incident_states/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repo_incident_states + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/store/repos/repo_incident_states/incident_states_gen.sql.go b/internal/store/repos/repo_incident_states/incident_states_gen.sql.go new file mode 100644 index 0000000..c369431 --- /dev/null +++ b/internal/store/repos/repo_incident_states/incident_states_gen.sql.go @@ -0,0 +1,105 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: incident_states_gen.sql + +package repo_incident_states + +import ( + "context" + + "github.com/sxwebdev/sentinel/internal/models" +) + +const create = `-- name: Create :one +INSERT INTO incident_states (id, incident_id, status, level) + VALUES (?, ?, ?, ?) + RETURNING id, incident_id, status, level, created_at, updated_at +` + +type CreateParams struct { + ID string `db:"id" json:"id"` + IncidentID string `db:"incident_id" json:"incident_id"` + Status string `db:"status" json:"status"` + Level int64 `db:"level" json:"level"` +} + +func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.IncidentState, error) { + row := q.db.QueryRowContext(ctx, create, + arg.ID, + arg.IncidentID, + arg.Status, + arg.Level, + ) + var i models.IncidentState + err := row.Scan( + &i.ID, + &i.IncidentID, + &i.Status, + &i.Level, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const delete = `-- name: Delete :exec +DELETE FROM incident_states WHERE id=? +` + +func (q *Queries) Delete(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, delete, id) + return err +} + +const getAll = `-- name: GetAll :many +SELECT id, incident_id, status, level, created_at, updated_at FROM incident_states +` + +func (q *Queries) GetAll(ctx context.Context) ([]*models.IncidentState, error) { + rows, err := q.db.QueryContext(ctx, getAll) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*models.IncidentState{} + for rows.Next() { + var i models.IncidentState + if err := rows.Scan( + &i.ID, + &i.IncidentID, + &i.Status, + &i.Level, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const getByID = `-- name: GetByID :one +SELECT id, incident_id, status, level, created_at, updated_at FROM incident_states WHERE id=? LIMIT 1 +` + +func (q *Queries) GetByID(ctx context.Context, id string) (*models.IncidentState, error) { + row := q.db.QueryRowContext(ctx, getByID, id) + var i models.IncidentState + err := row.Scan( + &i.ID, + &i.IncidentID, + &i.Status, + &i.Level, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} diff --git a/internal/store/repos/repo_incident_states/querier.go b/internal/store/repos/repo_incident_states/querier.go new file mode 100644 index 0000000..1d0c3fe --- /dev/null +++ b/internal/store/repos/repo_incident_states/querier.go @@ -0,0 +1,20 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repo_incident_states + +import ( + "context" + + "github.com/sxwebdev/sentinel/internal/models" +) + +type Querier interface { + Create(ctx context.Context, arg CreateParams) (*models.IncidentState, error) + Delete(ctx context.Context, id string) error + GetAll(ctx context.Context) ([]*models.IncidentState, error) + GetByID(ctx context.Context, id string) (*models.IncidentState, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/store/repos/repo_notification_history/constants_gen.go b/internal/store/repos/repo_notification_history/constants_gen.go new file mode 100755 index 0000000..0694771 --- /dev/null +++ b/internal/store/repos/repo_notification_history/constants_gen.go @@ -0,0 +1,61 @@ +// Code generated by pgxgen. DO NOT EDIT. +// versions: +// +// pgxgen v0.3.11 +package repo_notification_history + +import ( + "strings" + + "github.com/gobeam/stringy" +) + +type TableName string + +func (s TableName) String() string { return string(s) } + +const ( + TableNameNotificationHistory TableName = "notification_history" +) + +type ColumnName string + +func (s ColumnName) String() string { return string(s) } + +func (s ColumnName) StructName() string { + v := stringy.New(string(s)).CamelCase().Get() + v = stringy.New(v).UcFirst() + return strings.ReplaceAll(v, "Id", "ID") +} + +type ColumnNames []ColumnName + +func (s ColumnNames) Strings() []string { + res := make([]string, len(s)) + for idx, colName := range s { + res[idx] = colName.String() + } + return res +} + +const ( + ColumnNameNotificationHistoryId ColumnName = "id" + ColumnNameNotificationHistoryProviderId ColumnName = "provider_id" + ColumnNameNotificationHistoryIncidentId ColumnName = "incident_id" + ColumnNameNotificationHistoryMessage ColumnName = "message" + ColumnNameNotificationHistoryStatus ColumnName = "status" + ColumnNameNotificationHistoryErrorMessage ColumnName = "error_message" + ColumnNameNotificationHistoryCreatedAt ColumnName = "created_at" +) + +func NotificationHistoryColumnNames() ColumnNames { + return ColumnNames{ + ColumnNameNotificationHistoryId, + ColumnNameNotificationHistoryProviderId, + ColumnNameNotificationHistoryIncidentId, + ColumnNameNotificationHistoryMessage, + ColumnNameNotificationHistoryStatus, + ColumnNameNotificationHistoryErrorMessage, + ColumnNameNotificationHistoryCreatedAt, + } +} diff --git a/internal/store/repos/repo_notification_history/db.go b/internal/store/repos/repo_notification_history/db.go new file mode 100644 index 0000000..e31203b --- /dev/null +++ b/internal/store/repos/repo_notification_history/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repo_notification_history + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/store/repos/repo_notification_history/notification_history_gen.sql.go b/internal/store/repos/repo_notification_history/notification_history_gen.sql.go new file mode 100644 index 0000000..d63dd5a --- /dev/null +++ b/internal/store/repos/repo_notification_history/notification_history_gen.sql.go @@ -0,0 +1,77 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: notification_history_gen.sql + +package repo_notification_history + +import ( + "context" + + "github.com/sxwebdev/sentinel/internal/models" +) + +const create = `-- name: Create :one +INSERT INTO notification_history (id, provider_id, incident_id, message, status, error_message) + VALUES (?, ?, ?, ?, ?, ?) + RETURNING id, provider_id, incident_id, message, status, error_message, created_at +` + +type CreateParams struct { + ID string `db:"id" json:"id"` + ProviderID string `db:"provider_id" json:"provider_id"` + IncidentID *string `db:"incident_id" json:"incident_id"` + Message string `db:"message" json:"message"` + Status string `db:"status" json:"status"` + ErrorMessage *string `db:"error_message" json:"error_message"` +} + +func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.NotificationHistory, error) { + row := q.db.QueryRowContext(ctx, create, + arg.ID, + arg.ProviderID, + arg.IncidentID, + arg.Message, + arg.Status, + arg.ErrorMessage, + ) + var i models.NotificationHistory + err := row.Scan( + &i.ID, + &i.ProviderID, + &i.IncidentID, + &i.Message, + &i.Status, + &i.ErrorMessage, + &i.CreatedAt, + ) + return &i, err +} + +const delete = `-- name: Delete :exec +DELETE FROM notification_history WHERE id=? +` + +func (q *Queries) Delete(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, delete, id) + return err +} + +const getByID = `-- name: GetByID :one +SELECT id, provider_id, incident_id, message, status, error_message, created_at FROM notification_history WHERE id=? LIMIT 1 +` + +func (q *Queries) GetByID(ctx context.Context, id string) (*models.NotificationHistory, error) { + row := q.db.QueryRowContext(ctx, getByID, id) + var i models.NotificationHistory + err := row.Scan( + &i.ID, + &i.ProviderID, + &i.IncidentID, + &i.Message, + &i.Status, + &i.ErrorMessage, + &i.CreatedAt, + ) + return &i, err +} diff --git a/internal/store/repos/repo_notification_history/querier.go b/internal/store/repos/repo_notification_history/querier.go new file mode 100644 index 0000000..61d7843 --- /dev/null +++ b/internal/store/repos/repo_notification_history/querier.go @@ -0,0 +1,19 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repo_notification_history + +import ( + "context" + + "github.com/sxwebdev/sentinel/internal/models" +) + +type Querier interface { + Create(ctx context.Context, arg CreateParams) (*models.NotificationHistory, error) + Delete(ctx context.Context, id string) error + GetByID(ctx context.Context, id string) (*models.NotificationHistory, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/store/repos/repo_notification_providers/constants_gen.go b/internal/store/repos/repo_notification_providers/constants_gen.go new file mode 100755 index 0000000..d087d97 --- /dev/null +++ b/internal/store/repos/repo_notification_providers/constants_gen.go @@ -0,0 +1,59 @@ +// Code generated by pgxgen. DO NOT EDIT. +// versions: +// +// pgxgen v0.3.11 +package repo_notification_providers + +import ( + "strings" + + "github.com/gobeam/stringy" +) + +type TableName string + +func (s TableName) String() string { return string(s) } + +const ( + TableNameNotificationProviders TableName = "notification_providers" +) + +type ColumnName string + +func (s ColumnName) String() string { return string(s) } + +func (s ColumnName) StructName() string { + v := stringy.New(string(s)).CamelCase().Get() + v = stringy.New(v).UcFirst() + return strings.ReplaceAll(v, "Id", "ID") +} + +type ColumnNames []ColumnName + +func (s ColumnNames) Strings() []string { + res := make([]string, len(s)) + for idx, colName := range s { + res[idx] = colName.String() + } + return res +} + +const ( + ColumnNameNotificationProvidersId ColumnName = "id" + ColumnNameNotificationProvidersProviderType ColumnName = "provider_type" + ColumnNameNotificationProvidersConfig ColumnName = "config" + ColumnNameNotificationProvidersIsEnabled ColumnName = "is_enabled" + ColumnNameNotificationProvidersCreatedAt ColumnName = "created_at" + ColumnNameNotificationProvidersUpdatedAt ColumnName = "updated_at" +) + +func NotificationProvidersColumnNames() ColumnNames { + return ColumnNames{ + ColumnNameNotificationProvidersId, + ColumnNameNotificationProvidersProviderType, + ColumnNameNotificationProvidersConfig, + ColumnNameNotificationProvidersIsEnabled, + ColumnNameNotificationProvidersCreatedAt, + ColumnNameNotificationProvidersUpdatedAt, + } +} diff --git a/internal/store/repos/repo_notification_providers/db.go b/internal/store/repos/repo_notification_providers/db.go new file mode 100644 index 0000000..7acdc90 --- /dev/null +++ b/internal/store/repos/repo_notification_providers/db.go @@ -0,0 +1,31 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repo_notification_providers + +import ( + "context" + "database/sql" +) + +type DBTX interface { + ExecContext(context.Context, string, ...interface{}) (sql.Result, error) + PrepareContext(context.Context, string) (*sql.Stmt, error) + QueryContext(context.Context, string, ...interface{}) (*sql.Rows, error) + QueryRowContext(context.Context, string, ...interface{}) *sql.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx *sql.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/store/repos/repo_notification_providers/notification_providers_gen.sql.go b/internal/store/repos/repo_notification_providers/notification_providers_gen.sql.go new file mode 100644 index 0000000..3dceb9c --- /dev/null +++ b/internal/store/repos/repo_notification_providers/notification_providers_gen.sql.go @@ -0,0 +1,72 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: notification_providers_gen.sql + +package repo_notification_providers + +import ( + "context" + + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/storecmn" +) + +const create = `-- name: Create :one +INSERT INTO notification_providers (id, provider_type, config, is_enabled) + VALUES (?, ?, ?, ?) + RETURNING id, provider_type, json(config), is_enabled, created_at, updated_at +` + +type CreateParams struct { + ID string `db:"id" json:"id"` + ProviderType string `db:"provider_type" json:"provider_type"` + Config storecmn.JSONField `db:"config" json:"config"` + IsEnabled bool `db:"is_enabled" json:"is_enabled"` +} + +func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.NotificationProvider, error) { + row := q.db.QueryRowContext(ctx, create, + arg.ID, + arg.ProviderType, + arg.Config, + arg.IsEnabled, + ) + var i models.NotificationProvider + err := row.Scan( + &i.ID, + &i.ProviderType, + &i.Config, + &i.IsEnabled, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} + +const delete = `-- name: Delete :exec +DELETE FROM notification_providers WHERE id=? +` + +func (q *Queries) Delete(ctx context.Context, id string) error { + _, err := q.db.ExecContext(ctx, delete, id) + return err +} + +const getByID = `-- name: GetByID :one +SELECT id, provider_type, json(config), is_enabled, created_at, updated_at FROM notification_providers WHERE id=? LIMIT 1 +` + +func (q *Queries) GetByID(ctx context.Context, id string) (*models.NotificationProvider, error) { + row := q.db.QueryRowContext(ctx, getByID, id) + var i models.NotificationProvider + err := row.Scan( + &i.ID, + &i.ProviderType, + &i.Config, + &i.IsEnabled, + &i.CreatedAt, + &i.UpdatedAt, + ) + return &i, err +} diff --git a/internal/store/repos/repo_notification_providers/querier.go b/internal/store/repos/repo_notification_providers/querier.go new file mode 100644 index 0000000..23af2f0 --- /dev/null +++ b/internal/store/repos/repo_notification_providers/querier.go @@ -0,0 +1,19 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 + +package repo_notification_providers + +import ( + "context" + + "github.com/sxwebdev/sentinel/internal/models" +) + +type Querier interface { + Create(ctx context.Context, arg CreateParams) (*models.NotificationProvider, error) + Delete(ctx context.Context, id string) error + GetByID(ctx context.Context, id string) (*models.NotificationProvider, error) +} + +var _ Querier = (*Queries)(nil) diff --git a/internal/store/repos/repos.go b/internal/store/repos/repos.go index f9dfc93..23cb05c 100644 --- a/internal/store/repos/repos.go +++ b/internal/store/repos/repos.go @@ -4,24 +4,33 @@ import ( "database/sql" "github.com/sxwebdev/sentinel/internal/store/repos/repo_agents" + "github.com/sxwebdev/sentinel/internal/store/repos/repo_incident_states" "github.com/sxwebdev/sentinel/internal/store/repos/repo_incidents" + "github.com/sxwebdev/sentinel/internal/store/repos/repo_notification_history" + "github.com/sxwebdev/sentinel/internal/store/repos/repo_notification_providers" "github.com/sxwebdev/sentinel/internal/store/repos/repo_service_states" "github.com/sxwebdev/sentinel/internal/store/repos/repo_services" ) type Repos struct { - agents *repo_agents.CustomQueries - services *repo_services.CustomQueries - serviceStates *repo_service_states.CustomQueries - incidents *repo_incidents.CustomQueries + agents *repo_agents.CustomQueries + services *repo_services.CustomQueries + serviceStates *repo_service_states.CustomQueries + incidents *repo_incidents.CustomQueries + incidentsStates *repo_incident_states.Queries + notificationProviders *repo_notification_providers.Queries + notificationHistory *repo_notification_history.Queries } func New(sqlite *sql.DB) *Repos { return &Repos{ - agents: repo_agents.NewCustom(sqlite), - services: repo_services.NewCustom(sqlite), - serviceStates: repo_service_states.NewCustom(sqlite), - incidents: repo_incidents.NewCustom(sqlite), + agents: repo_agents.NewCustom(sqlite), + services: repo_services.NewCustom(sqlite), + serviceStates: repo_service_states.NewCustom(sqlite), + incidents: repo_incidents.NewCustom(sqlite), + incidentsStates: repo_incident_states.New(sqlite), + notificationProviders: repo_notification_providers.New(sqlite), + notificationHistory: repo_notification_history.New(sqlite), } } @@ -68,3 +77,36 @@ func (s *Repos) Incidents(opts ...Option) repo_incidents.ICustomQuerier { return s.incidents } + +// IncidentStates returns repo for incident states +func (s *Repos) IncidentStates(opts ...Option) repo_incident_states.Querier { + options := parseOptions(opts...) + + if options.Tx != nil { + return s.incidentsStates.WithTx(options.Tx) + } + + return s.incidentsStates +} + +// NotificationProviders returns repo for notification providers +func (s *Repos) NotificationProviders(opts ...Option) repo_notification_providers.Querier { + options := parseOptions(opts...) + + if options.Tx != nil { + return s.notificationProviders.WithTx(options.Tx) + } + + return s.notificationProviders +} + +// NotificationHistory returns repo for notification history +func (s *Repos) NotificationHistory(opts ...Option) repo_notification_history.Querier { + options := parseOptions(opts...) + + if options.Tx != nil { + return s.notificationHistory.WithTx(options.Tx) + } + + return s.notificationHistory +} diff --git a/pgxgen.yaml b/pgxgen.yaml index 8467be5..c781d51 100644 --- a/pgxgen.yaml +++ b/pgxgen.yaml @@ -33,7 +33,7 @@ sqlc: - updated_at returning: "*" column_values: - created_at: now() + created_at: CURRENT_TIMESTAMP delete: get: name: GetByID @@ -48,8 +48,6 @@ sqlc: - created_at - updated_at returning: "*" - column_values: - created_at: now() delete: get: name: GetByID @@ -64,8 +62,6 @@ sqlc: - created_at - updated_at returning: "*" - column_values: - created_at: now() delete: get: name: GetByID @@ -92,8 +88,55 @@ sqlc: get: name: GetByID + # incident_states + incident_states: + output_dir: sql/queries/incident_states + primary_column: id + methods: + create: + skip_columns: + - created_at + - updated_at + returning: "*" + delete: + get: + name: GetByID + find: + name: GetAll + + # notification_providers + notification_providers: + output_dir: sql/queries/notification_providers + primary_column: id + methods: + create: + skip_columns: + - created_at + - updated_at + returning: "*" + delete: + get: + name: GetByID + + # notification_history + notification_history: + output_dir: sql/queries/notification_history + primary_column: id + methods: + create: + skip_columns: + - created_at + - updated_at + returning: "*" + delete: + get: + name: GetByID + constants: tables: + agents: + output_dir: internal/store/repos/repo_agents + include_column_names: true services: output_dir: internal/store/repos/repo_services include_column_names: true @@ -103,6 +146,12 @@ sqlc: incidents: output_dir: internal/store/repos/repo_incidents include_column_names: true - agents: - output_dir: internal/store/repos/repo_agents + incident_states: + output_dir: internal/store/repos/repo_incident_states + include_column_names: true + notification_providers: + output_dir: internal/store/repos/repo_notification_providers + include_column_names: true + notification_history: + output_dir: internal/store/repos/repo_notification_history include_column_names: true diff --git a/sql/migrations/2_agents.up.sql b/sql/migrations/2_agents.up.sql index 6abf817..8aecf10 100644 --- a/sql/migrations/2_agents.up.sql +++ b/sql/migrations/2_agents.up.sql @@ -32,3 +32,38 @@ ALTER TABLE incidents RENAME COLUMN duration_ns TO duration; -- Update service_states response_time_ns to response_time (from nanoseconds to milliseconds) UPDATE service_states SET response_time_ns = response_time_ns / 1000000 WHERE response_time_ns IS NOT NULL AND response_time_ns > 0; ALTER TABLE service_states RENAME COLUMN response_time_ns TO response_time; + +-- Create incidents states table +CREATE TABLE IF NOT EXISTS incident_states ( + id TEXT PRIMARY KEY, + incident_id TEXT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE, + "status" TEXT NOT NULL CHECK (status != ''), + level INTEGER NOT NULL DEFAULT 0, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_incident_states_incident_id ON incident_states(incident_id); +CREATE INDEX IF NOT EXISTS idx_incident_states_status ON incident_states(status); + +-- Create notifications providers table +CREATE TABLE IF NOT EXISTS notification_providers ( + id TEXT PRIMARY KEY, + provider_type TEXT NOT NULL, + config jsonb NOT NULL DEFAULT '{}', + is_enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_notification_providers_enabled ON notification_providers(is_enabled); + +-- Create notifications history table +CREATE TABLE IF NOT EXISTS notification_history ( + id TEXT PRIMARY KEY, + provider_id TEXT NOT NULL REFERENCES notification_providers(id) ON DELETE CASCADE, + incident_id TEXT REFERENCES incidents(id) ON DELETE DELETE SET NULL, + message TEXT NOT NULL CHECK (message != ''), + "status" TEXT NOT NULL DEFAULT 'pending' CHECK (status != ''), + error_message TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_notification_history_provider ON notification_history(provider_id); diff --git a/sql/queries/incident_states/incident_states_gen.sql b/sql/queries/incident_states/incident_states_gen.sql new file mode 100755 index 0000000..19aa561 --- /dev/null +++ b/sql/queries/incident_states/incident_states_gen.sql @@ -0,0 +1,14 @@ +-- name: Create :one +INSERT INTO incident_states (id, incident_id, status, level) + VALUES (?, ?, ?, ?) + RETURNING *; + +-- name: Delete :exec +DELETE FROM incident_states WHERE id=?; + +-- name: GetAll :many +SELECT * FROM incident_states; + +-- name: GetByID :one +SELECT * FROM incident_states WHERE id=? LIMIT 1; + diff --git a/sql/queries/notification_history/notification_history_gen.sql b/sql/queries/notification_history/notification_history_gen.sql new file mode 100755 index 0000000..881810b --- /dev/null +++ b/sql/queries/notification_history/notification_history_gen.sql @@ -0,0 +1,11 @@ +-- name: Create :one +INSERT INTO notification_history (id, provider_id, incident_id, message, status, error_message) + VALUES (?, ?, ?, ?, ?, ?) + RETURNING *; + +-- name: Delete :exec +DELETE FROM notification_history WHERE id=?; + +-- name: GetByID :one +SELECT * FROM notification_history WHERE id=? LIMIT 1; + diff --git a/sql/queries/notification_providers/notification_providers_gen.sql b/sql/queries/notification_providers/notification_providers_gen.sql new file mode 100755 index 0000000..ab5662c --- /dev/null +++ b/sql/queries/notification_providers/notification_providers_gen.sql @@ -0,0 +1,11 @@ +-- name: Create :one +INSERT INTO notification_providers (id, provider_type, config, is_enabled) + VALUES (?, ?, ?, ?) + RETURNING *; + +-- name: Delete :exec +DELETE FROM notification_providers WHERE id=?; + +-- name: GetByID :one +SELECT * FROM notification_providers WHERE id=? LIMIT 1; + diff --git a/sqlc.yaml b/sqlc.yaml index f49f8aa..630cb88 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -23,6 +23,11 @@ overrides: - column: service_states.status go_type: type: ServiceStatus + + # notification_history + - column: notification_history.incident_id + go_type: + type: "*string" sql: # Services - schema: sql/migrations @@ -84,6 +89,26 @@ sql: emit_all_enum_values: true query_parameter_limit: 3 + # incident_states + - schema: sql/migrations + queries: sql/queries/incident_states + engine: sqlite + gen: + go: + out: internal/store/repos/repo_incident_states + emit_prepared_queries: false + emit_json_tags: true + emit_exported_queries: false + emit_db_tags: true + emit_interface: true + emit_exact_table_names: false + emit_empty_slices: true + emit_result_struct_pointers: true + emit_params_struct_pointers: false + emit_enum_valid_method: true + emit_all_enum_values: true + query_parameter_limit: 3 + # agents - schema: sql/migrations queries: sql/queries/agents @@ -103,3 +128,43 @@ sql: emit_enum_valid_method: true emit_all_enum_values: true query_parameter_limit: 3 + + # notification_providers + - schema: sql/migrations + queries: sql/queries/notification_providers + engine: sqlite + gen: + go: + out: internal/store/repos/repo_notification_providers + emit_prepared_queries: false + emit_json_tags: true + emit_exported_queries: false + emit_db_tags: true + emit_interface: true + emit_exact_table_names: false + emit_empty_slices: true + emit_result_struct_pointers: true + emit_params_struct_pointers: false + emit_enum_valid_method: true + emit_all_enum_values: true + query_parameter_limit: 3 + + # notification_history + - schema: sql/migrations + queries: sql/queries/notification_history + engine: sqlite + gen: + go: + out: internal/store/repos/repo_notification_history + emit_prepared_queries: false + emit_json_tags: true + emit_exported_queries: false + emit_db_tags: true + emit_interface: true + emit_exact_table_names: false + emit_empty_slices: true + emit_result_struct_pointers: true + emit_params_struct_pointers: false + emit_enum_valid_method: true + emit_all_enum_values: true + query_parameter_limit: 3 From a7b5379bad89279bc17c574c490c703b1250cc2b Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Tue, 23 Sep 2025 00:29:36 +0300 Subject: [PATCH 17/71] feat: Implement notification providers management - Added SQL queries for creating, updating, deleting, and retrieving notification providers. - Introduced new API endpoints for managing notification providers, including creation, update, deletion, and testing. - Implemented notification provider types and validation logic for configurations. - Enhanced notification history management with unsent notifications retrieval and status updates. - Integrated Shoutrrr as a notification provider type with appropriate configuration handling. - Added utility functions for duration formatting and loop management for background tasks. - Updated frontend types and API generation for notifications. --- Makefile | 6 + cmd/sentinel/hub.go | 21 +- cmd/testapi/main.go | 2 +- docs/docsv1/docs.go | 330 +++++++++++++++-- docs/docsv1/swagger.json | 330 +++++++++++++++-- docs/docsv1/swagger.yaml | 220 +++++++++-- .../src/shared/api/gen/incidents/incidents.ts | 6 +- .../api/gen/notifications/notifications.ts | 122 +++++++ frontend/src/shared/types/model/index.ts | 9 +- .../{storageIncident.ts => modelsIncident.ts} | 4 +- .../types/model/modelsNotificationProvider.ts | 18 + .../model/modelsNotificationProviderType.ts | 15 + .../notificationsCreateProviderParams.ts | 11 + .../notificationsUpdateProviderParams.ts | 15 + ...cmnFindResponseWithCountModelsIncident.ts} | 6 +- .../shared/types/model/storecmnJSONField.ts | 11 + .../shared/types/model/webDashboardStats.ts | 1 - internal/models/models_gen.go | 47 +-- internal/models/notification.go | 38 ++ internal/notifier/shoutrrr.go | 344 ------------------ internal/scheduler/scheduler.go | 85 ++++- internal/services/agents/methods.go | 10 +- internal/services/baseservices/base.go | 27 +- internal/services/notifications/helpers.go | 22 ++ internal/services/notifications/history.go | 105 ++++++ internal/services/notifications/providers.go | 154 ++++++++ internal/services/notifications/sender.go | 135 +++++++ internal/services/notifications/service.go | 38 ++ internal/services/service/methods.go | 8 +- .../constants_gen.go | 24 +- .../notification_history.sql.go | 123 +++++++ .../notification_history_gen.sql.go | 32 +- .../repo_notification_history/querier.go | 4 +- .../notification_providers.sql.go | 74 ++++ .../notification_providers_gen.sql.go | 52 ++- .../repo_notification_providers/querier.go | 3 + internal/store/storecmn/json.go | 12 +- internal/utils/duration.go | 24 ++ internal/web/handlers.go | 48 ++- internal/web/notifications.go | 136 +++++++ pgxgen.yaml | 13 +- pkg/loop/loop.go | 120 ++++++ pkg/loop/options.go | 46 +++ pkg/loop/type.go | 13 + pkg/migrations/cli.go | 16 + sql/migrations/2_agents.down.sql | 20 +- sql/migrations/2_agents.up.sql | 24 +- .../notification_history.sql | 34 ++ .../notification_history_gen.sql | 7 +- .../notification_providers.sql | 11 + .../notification_providers_gen.sql | 7 +- sqlc.yaml | 17 +- 52 files changed, 2394 insertions(+), 606 deletions(-) create mode 100644 frontend/src/shared/api/gen/notifications/notifications.ts rename frontend/src/shared/types/model/{storageIncident.ts => modelsIncident.ts} (80%) create mode 100644 frontend/src/shared/types/model/modelsNotificationProvider.ts create mode 100644 frontend/src/shared/types/model/modelsNotificationProviderType.ts create mode 100644 frontend/src/shared/types/model/notificationsCreateProviderParams.ts create mode 100644 frontend/src/shared/types/model/notificationsUpdateProviderParams.ts rename frontend/src/shared/types/model/{storecmnFindResponseWithCountStorageIncident.ts => storecmnFindResponseWithCountModelsIncident.ts} (56%) create mode 100644 frontend/src/shared/types/model/storecmnJSONField.ts create mode 100644 internal/models/notification.go delete mode 100644 internal/notifier/shoutrrr.go create mode 100644 internal/services/notifications/helpers.go create mode 100644 internal/services/notifications/history.go create mode 100644 internal/services/notifications/providers.go create mode 100644 internal/services/notifications/sender.go create mode 100644 internal/services/notifications/service.go create mode 100644 internal/store/repos/repo_notification_history/notification_history.sql.go create mode 100644 internal/store/repos/repo_notification_providers/notification_providers.sql.go create mode 100644 internal/utils/duration.go create mode 100644 internal/web/notifications.go create mode 100644 pkg/loop/loop.go create mode 100644 pkg/loop/options.go create mode 100644 pkg/loop/type.go create mode 100755 sql/queries/notification_history/notification_history.sql create mode 100755 sql/queries/notification_providers/notification_providers.sql diff --git a/Makefile b/Makefile index 26d29fa..2d48e66 100644 --- a/Makefile +++ b/Makefile @@ -21,6 +21,12 @@ help: ## Show this help message dev: ## Run in development mode with auto-reload go run $(SENTINEL_PATH) start -c ./config.yaml +migrateup: + go run $(SENTINEL_PATH) migrations up -db-path ./data/db.sqlite + +migratedown: + go run $(SENTINEL_PATH) migrations down -db-path ./data/db.sqlite + agent: ## Run in development mode with auto-reload go run $(SENTINEL_PATH) agent start -c ./config-agent.yaml diff --git a/cmd/sentinel/hub.go b/cmd/sentinel/hub.go index 8e2aaeb..272bd5e 100644 --- a/cmd/sentinel/hub.go +++ b/cmd/sentinel/hub.go @@ -9,7 +9,6 @@ import ( "github.com/sxwebdev/sentinel/internal/config" "github.com/sxwebdev/sentinel/internal/models" - "github.com/sxwebdev/sentinel/internal/notifier" "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/scheduler" "github.com/sxwebdev/sentinel/internal/services/baseservices" @@ -95,15 +94,6 @@ func hubStartCMD() *cli.Command { return fmt.Errorf("failed to initialize store: %w", err) } - // Initialize notifier - var notif *notifier.Notifier - if conf.Notifications.Enabled { - notif, err = notifier.New(l, conf.Notifications.URLs) - if err != nil { - return fmt.Errorf("failed to initialize notifier: %w", err) - } - } - // Init receiver rc := receiver.New() @@ -113,10 +103,10 @@ func hubStartCMD() *cli.Command { return fmt.Errorf("failed to initialize upgrader: %w", err) } - baseServices := baseservices.New(st, rc) + baseServices := baseservices.New(l, st, rc) // Initialize scheduler - sched := scheduler.New(l, st, notif, rc, baseServices) + sched := scheduler.New(l, st, rc, baseServices) serverInfo := models.GetSystemInfo(version, commitHash, buildDate) serverInfo.SqliteVersion = sqliteVersion @@ -133,14 +123,9 @@ func hubStartCMD() *cli.Command { service.New(service.WithService(rc)), service.New(service.WithService(sched)), service.New(service.WithService(webServer)), + service.New(service.WithService(baseServices.Notifications().Sender())), ) - if notif != nil { - ln.ServicesRunner().Register( - service.New(service.WithService(notif)), - ) - } - return ln.Run() }, } diff --git a/cmd/testapi/main.go b/cmd/testapi/main.go index 2b3700e..9824e4d 100644 --- a/cmd/testapi/main.go +++ b/cmd/testapi/main.go @@ -245,7 +245,7 @@ func setupTestSuite() (*TestSuite, error) { return nil, fmt.Errorf("failed to initialize store: %w", err) } - baseServices := baseservices.New(st, rc) + baseServices := baseservices.New(l, st, rc) // Create web server webServer, err := web.NewServer(l, cfg, models.SystemInfo{}, baseServices, rc, upgr) diff --git a/docs/docsv1/docs.go b/docs/docsv1/docs.go index f9d99f1..270b8ec 100644 --- a/docs/docsv1/docs.go +++ b/docs/docsv1/docs.go @@ -96,7 +96,7 @@ const docTemplate = `{ "200": { "description": "List of incidents", "schema": { - "$ref": "#/definitions/storecmn.FindResponseWithCount-storage_Incident" + "$ref": "#/definitions/storecmn.FindResponseWithCount-models_Incident" } }, "500": { @@ -616,7 +616,7 @@ const docTemplate = `{ "200": { "description": "List of incidents", "schema": { - "$ref": "#/definitions/storecmn.FindResponseWithCount-storage_Incident" + "$ref": "#/definitions/storecmn.FindResponseWithCount-models_Incident" } }, "400": { @@ -738,6 +738,215 @@ const docTemplate = `{ } } }, + "/settings/notifications/providers": { + "get": { + "description": "Retrieves a list of all configured notification providers.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "List all notification providers", + "responses": { + "200": { + "description": "List of notification providers", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.NotificationProvider" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + }, + "post": { + "description": "Creates a new notification provider with the given configuration.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Create new notification provider", + "parameters": [ + { + "description": "Body params", + "name": "service", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/notifications.CreateProviderParams" + } + } + ], + "responses": { + "201": { + "description": "Notification provider created", + "schema": { + "$ref": "#/definitions/models.NotificationProvider" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/settings/notifications/providers/{id}": { + "put": { + "description": "Updates a notification provider with the given ID and configuration.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Update a notification provider", + "parameters": [ + { + "type": "string", + "description": "Notification Provider ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Body params", + "name": "service", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/notifications.UpdateProviderParams" + } + } + ], + "responses": { + "200": { + "description": "Notification provider updated", + "schema": { + "$ref": "#/definitions/models.NotificationProvider" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Deletes a notification provider by its ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Delete a notification provider", + "parameters": [ + { + "type": "string", + "description": "Notification Provider ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/settings/notifications/providers/{id}/test": { + "post": { + "description": "Sends a test notification using the specified provider ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Test a notification provider", + "parameters": [ + { + "type": "string", + "description": "Notification Provider ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, "/tags": { "get": { "description": "Retrieves all unique tags used across services", @@ -821,6 +1030,70 @@ const docTemplate = `{ } } }, + "models.Incident": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "end_time": { + "type": "string" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "resolved": { + "type": "boolean" + }, + "service_id": { + "type": "string" + }, + "start_time": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "models.NotificationProvider": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/storecmn.JSONField" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_enabled": { + "type": "boolean" + }, + "provider_type": { + "$ref": "#/definitions/models.NotificationProviderType" + }, + "updated_at": { + "type": "string" + } + } + }, + "models.NotificationProviderType": { + "type": "string", + "enum": [ + "shoutrrr" + ], + "x-enum-varnames": [ + "NotificationProviderTypeShoutrrr" + ] + }, "models.ServiceProtocolType": { "type": "string", "enum": [ @@ -985,6 +1258,23 @@ const docTemplate = `{ } } }, + "notifications.CreateProviderParams": { + "type": "object" + }, + "notifications.UpdateProviderParams": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/storecmn.JSONField" + }, + "is_enabled": { + "type": "boolean" + }, + "provider_type": { + "$ref": "#/definitions/models.NotificationProviderType" + } + } + }, "service.Stats": { "type": "object", "properties": { @@ -1014,33 +1304,7 @@ const docTemplate = `{ } } }, - "storage.Incident": { - "type": "object", - "properties": { - "duration": { - "type": "integer" - }, - "end_time": { - "type": "string" - }, - "error": { - "type": "string" - }, - "id": { - "type": "string" - }, - "resolved": { - "type": "boolean" - }, - "service_id": { - "type": "string" - }, - "start_time": { - "type": "string" - } - } - }, - "storecmn.FindResponseWithCount-storage_Incident": { + "storecmn.FindResponseWithCount-models_Incident": { "type": "object", "properties": { "count": { @@ -1049,7 +1313,7 @@ const docTemplate = `{ "items": { "type": "array", "items": { - "$ref": "#/definitions/storage.Incident" + "$ref": "#/definitions/models.Incident" } } } @@ -1068,6 +1332,9 @@ const docTemplate = `{ } } }, + "storecmn.JSONField": { + "type": "object" + }, "web.CreateUpdateServiceRequest": { "type": "object", "properties": { @@ -1130,9 +1397,6 @@ const docTemplate = `{ "type": "integer", "example": 60 }, - "last_check_time": { - "type": "string" - }, "protocols": { "type": "object", "additionalProperties": { diff --git a/docs/docsv1/swagger.json b/docs/docsv1/swagger.json index 57fd85b..504a323 100644 --- a/docs/docsv1/swagger.json +++ b/docs/docsv1/swagger.json @@ -89,7 +89,7 @@ "200": { "description": "List of incidents", "schema": { - "$ref": "#/definitions/storecmn.FindResponseWithCount-storage_Incident" + "$ref": "#/definitions/storecmn.FindResponseWithCount-models_Incident" } }, "500": { @@ -609,7 +609,7 @@ "200": { "description": "List of incidents", "schema": { - "$ref": "#/definitions/storecmn.FindResponseWithCount-storage_Incident" + "$ref": "#/definitions/storecmn.FindResponseWithCount-models_Incident" } }, "400": { @@ -731,6 +731,215 @@ } } }, + "/settings/notifications/providers": { + "get": { + "description": "Retrieves a list of all configured notification providers.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "List all notification providers", + "responses": { + "200": { + "description": "List of notification providers", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/models.NotificationProvider" + } + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + }, + "post": { + "description": "Creates a new notification provider with the given configuration.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Create new notification provider", + "parameters": [ + { + "description": "Body params", + "name": "service", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/notifications.CreateProviderParams" + } + } + ], + "responses": { + "201": { + "description": "Notification provider created", + "schema": { + "$ref": "#/definitions/models.NotificationProvider" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/settings/notifications/providers/{id}": { + "put": { + "description": "Updates a notification provider with the given ID and configuration.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Update a notification provider", + "parameters": [ + { + "type": "string", + "description": "Notification Provider ID", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Body params", + "name": "service", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/notifications.UpdateProviderParams" + } + } + ], + "responses": { + "200": { + "description": "Notification provider updated", + "schema": { + "$ref": "#/definitions/models.NotificationProvider" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + }, + "delete": { + "description": "Deletes a notification provider by its ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Delete a notification provider", + "parameters": [ + { + "type": "string", + "description": "Notification Provider ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, + "/settings/notifications/providers/{id}/test": { + "post": { + "description": "Sends a test notification using the specified provider ID.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "Test a notification provider", + "parameters": [ + { + "type": "string", + "description": "Notification Provider ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK" + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, "/tags": { "get": { "description": "Retrieves all unique tags used across services", @@ -814,6 +1023,70 @@ } } }, + "models.Incident": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "duration": { + "type": "integer" + }, + "end_time": { + "type": "string" + }, + "error": { + "type": "string" + }, + "id": { + "type": "string" + }, + "resolved": { + "type": "boolean" + }, + "service_id": { + "type": "string" + }, + "start_time": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, + "models.NotificationProvider": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/storecmn.JSONField" + }, + "created_at": { + "type": "string" + }, + "id": { + "type": "string" + }, + "is_enabled": { + "type": "boolean" + }, + "provider_type": { + "$ref": "#/definitions/models.NotificationProviderType" + }, + "updated_at": { + "type": "string" + } + } + }, + "models.NotificationProviderType": { + "type": "string", + "enum": [ + "shoutrrr" + ], + "x-enum-varnames": [ + "NotificationProviderTypeShoutrrr" + ] + }, "models.ServiceProtocolType": { "type": "string", "enum": [ @@ -978,6 +1251,23 @@ } } }, + "notifications.CreateProviderParams": { + "type": "object" + }, + "notifications.UpdateProviderParams": { + "type": "object", + "properties": { + "config": { + "$ref": "#/definitions/storecmn.JSONField" + }, + "is_enabled": { + "type": "boolean" + }, + "provider_type": { + "$ref": "#/definitions/models.NotificationProviderType" + } + } + }, "service.Stats": { "type": "object", "properties": { @@ -1007,33 +1297,7 @@ } } }, - "storage.Incident": { - "type": "object", - "properties": { - "duration": { - "type": "integer" - }, - "end_time": { - "type": "string" - }, - "error": { - "type": "string" - }, - "id": { - "type": "string" - }, - "resolved": { - "type": "boolean" - }, - "service_id": { - "type": "string" - }, - "start_time": { - "type": "string" - } - } - }, - "storecmn.FindResponseWithCount-storage_Incident": { + "storecmn.FindResponseWithCount-models_Incident": { "type": "object", "properties": { "count": { @@ -1042,7 +1306,7 @@ "items": { "type": "array", "items": { - "$ref": "#/definitions/storage.Incident" + "$ref": "#/definitions/models.Incident" } } } @@ -1061,6 +1325,9 @@ } } }, + "storecmn.JSONField": { + "type": "object" + }, "web.CreateUpdateServiceRequest": { "type": "object", "properties": { @@ -1123,9 +1390,6 @@ "type": "integer", "example": 60 }, - "last_check_time": { - "type": "string" - }, "protocols": { "type": "object", "additionalProperties": { diff --git a/docs/docsv1/swagger.yaml b/docs/docsv1/swagger.yaml index 990d761..7d39358 100644 --- a/docs/docsv1/swagger.yaml +++ b/docs/docsv1/swagger.yaml @@ -11,6 +11,48 @@ definitions: url: type: string type: object + models.Incident: + properties: + created_at: + type: string + duration: + type: integer + end_time: + type: string + error: + type: string + id: + type: string + resolved: + type: boolean + service_id: + type: string + start_time: + type: string + updated_at: + type: string + type: object + models.NotificationProvider: + properties: + config: + $ref: '#/definitions/storecmn.JSONField' + created_at: + type: string + id: + type: string + is_enabled: + type: boolean + provider_type: + $ref: '#/definitions/models.NotificationProviderType' + updated_at: + type: string + type: object + models.NotificationProviderType: + enum: + - shoutrrr + type: string + x-enum-varnames: + - NotificationProviderTypeShoutrrr models.ServiceProtocolType: enum: - http @@ -128,6 +170,17 @@ definitions: required: - endpoint type: object + notifications.CreateProviderParams: + type: object + notifications.UpdateProviderParams: + properties: + config: + $ref: '#/definitions/storecmn.JSONField' + is_enabled: + type: boolean + provider_type: + $ref: '#/definitions/models.NotificationProviderType' + type: object service.Stats: properties: avg_response_time: @@ -147,30 +200,13 @@ definitions: uptime_percentage: type: number type: object - storage.Incident: - properties: - duration: - type: integer - end_time: - type: string - error: - type: string - id: - type: string - resolved: - type: boolean - service_id: - type: string - start_time: - type: string - type: object - storecmn.FindResponseWithCount-storage_Incident: + storecmn.FindResponseWithCount-models_Incident: properties: count: type: integer items: items: - $ref: '#/definitions/storage.Incident' + $ref: '#/definitions/models.Incident' type: array type: object storecmn.FindResponseWithCount-web_ServiceDTO: @@ -182,6 +218,8 @@ definitions: $ref: '#/definitions/web.ServiceDTO' type: array type: object + storecmn.JSONField: + type: object web.CreateUpdateServiceRequest: properties: config: @@ -225,8 +263,6 @@ definitions: checks_per_minute: example: 60 type: integer - last_check_time: - type: string protocols: additionalProperties: type: integer @@ -436,7 +472,7 @@ paths: "200": description: List of incidents schema: - $ref: '#/definitions/storecmn.FindResponseWithCount-storage_Incident' + $ref: '#/definitions/storecmn.FindResponseWithCount-models_Incident' "500": description: Internal server error schema: @@ -780,7 +816,7 @@ paths: "200": description: List of incidents schema: - $ref: '#/definitions/storecmn.FindResponseWithCount-storage_Incident' + $ref: '#/definitions/storecmn.FindResponseWithCount-models_Incident' "400": description: Bad request schema: @@ -861,6 +897,144 @@ paths: summary: Get service statistics tags: - statistics + /settings/notifications/providers: + get: + consumes: + - application/json + description: Retrieves a list of all configured notification providers. + produces: + - application/json + responses: + "200": + description: List of notification providers + schema: + items: + $ref: '#/definitions/models.NotificationProvider' + type: array + "500": + description: Internal server error + schema: + $ref: '#/definitions/web.ErrorResponse' + summary: List all notification providers + tags: + - notifications + post: + consumes: + - application/json + description: Creates a new notification provider with the given configuration. + parameters: + - description: Body params + in: body + name: service + required: true + schema: + $ref: '#/definitions/notifications.CreateProviderParams' + produces: + - application/json + responses: + "201": + description: Notification provider created + schema: + $ref: '#/definitions/models.NotificationProvider' + "400": + description: Bad request + schema: + $ref: '#/definitions/web.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/web.ErrorResponse' + summary: Create new notification provider + tags: + - notifications + /settings/notifications/providers/{id}: + delete: + consumes: + - application/json + description: Deletes a notification provider by its ID. + parameters: + - description: Notification Provider ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "204": + description: No Content + "400": + description: Bad request + schema: + $ref: '#/definitions/web.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/web.ErrorResponse' + summary: Delete a notification provider + tags: + - notifications + put: + consumes: + - application/json + description: Updates a notification provider with the given ID and configuration. + parameters: + - description: Notification Provider ID + in: path + name: id + required: true + type: string + - description: Body params + in: body + name: service + required: true + schema: + $ref: '#/definitions/notifications.UpdateProviderParams' + produces: + - application/json + responses: + "200": + description: Notification provider updated + schema: + $ref: '#/definitions/models.NotificationProvider' + "400": + description: Bad request + schema: + $ref: '#/definitions/web.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/web.ErrorResponse' + summary: Update a notification provider + tags: + - notifications + /settings/notifications/providers/{id}/test: + post: + consumes: + - application/json + description: Sends a test notification using the specified provider ID. + parameters: + - description: Notification Provider ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + "400": + description: Bad request + schema: + $ref: '#/definitions/web.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/web.ErrorResponse' + summary: Test a notification provider + tags: + - notifications /tags: get: consumes: diff --git a/frontend/src/shared/api/gen/incidents/incidents.ts b/frontend/src/shared/api/gen/incidents/incidents.ts index 8bd3e94..8b7d99b 100644 --- a/frontend/src/shared/api/gen/incidents/incidents.ts +++ b/frontend/src/shared/api/gen/incidents/incidents.ts @@ -9,7 +9,7 @@ import type { GetIncidentsParams, GetIncidentsStatsParams, GetServicesIdIncidentsParams, - StorecmnFindResponseWithCountStorageIncident, + StorecmnFindResponseWithCountModelsIncident, WebGetIncidentsStatsItem, } from "../../../types/model"; @@ -21,7 +21,7 @@ export const getIncidents = () => { * @summary Get recent incidents */ const getIncidents = (params?: GetIncidentsParams) => { - return customFetcher({ + return customFetcher({ url: `/incidents`, method: "GET", params, @@ -46,7 +46,7 @@ export const getIncidents = () => { id: string, params?: GetServicesIdIncidentsParams, ) => { - return customFetcher({ + return customFetcher({ url: `/services/${id}/incidents`, method: "GET", params, diff --git a/frontend/src/shared/api/gen/notifications/notifications.ts b/frontend/src/shared/api/gen/notifications/notifications.ts new file mode 100644 index 0000000..73b297c --- /dev/null +++ b/frontend/src/shared/api/gen/notifications/notifications.ts @@ -0,0 +1,122 @@ +/** + * Generated by orval v7.11.2 🍺 + * Do not edit manually. + * Sentinel Monitoring API + * API for service monitoring and incident management + * OpenAPI spec version: 1.0 + */ +import type { + ModelsNotificationProvider, + NotificationsCreateProviderParams, + NotificationsUpdateProviderParams, +} from "../../../types/model"; + +import { customFetcher } from "../../baseApi"; + +export const getNotifications = () => { + /** + * Retrieves a list of all configured notification providers. + * @summary List all notification providers + */ + const getSettingsNotificationsProviders = () => { + return customFetcher({ + url: `/settings/notifications/providers`, + method: "GET", + }); + }; + /** + * Creates a new notification provider with the given configuration. + * @summary Create new notification provider + */ + const postSettingsNotificationsProviders = ( + notificationsCreateProviderParams: NotificationsCreateProviderParams, + ) => { + return customFetcher({ + url: `/settings/notifications/providers`, + method: "POST", + headers: { "Content-Type": "application/json" }, + data: notificationsCreateProviderParams, + }); + }; + /** + * Updates a notification provider with the given ID and configuration. + * @summary Update a notification provider + */ + const putSettingsNotificationsProvidersId = ( + id: string, + notificationsUpdateProviderParams: NotificationsUpdateProviderParams, + ) => { + return customFetcher({ + url: `/settings/notifications/providers/${id}`, + method: "PUT", + headers: { "Content-Type": "application/json" }, + data: notificationsUpdateProviderParams, + }); + }; + /** + * Deletes a notification provider by its ID. + * @summary Delete a notification provider + */ + const deleteSettingsNotificationsProvidersId = (id: string) => { + return customFetcher({ + url: `/settings/notifications/providers/${id}`, + method: "DELETE", + }); + }; + /** + * Sends a test notification using the specified provider ID. + * @summary Test a notification provider + */ + const postSettingsNotificationsProvidersIdTest = (id: string) => { + return customFetcher({ + url: `/settings/notifications/providers/${id}/test`, + method: "POST", + }); + }; + return { + getSettingsNotificationsProviders, + postSettingsNotificationsProviders, + putSettingsNotificationsProvidersId, + deleteSettingsNotificationsProvidersId, + postSettingsNotificationsProvidersIdTest, + }; +}; +export type GetSettingsNotificationsProvidersResult = NonNullable< + Awaited< + ReturnType< + ReturnType["getSettingsNotificationsProviders"] + > + > +>; +export type PostSettingsNotificationsProvidersResult = NonNullable< + Awaited< + ReturnType< + ReturnType["postSettingsNotificationsProviders"] + > + > +>; +export type PutSettingsNotificationsProvidersIdResult = NonNullable< + Awaited< + ReturnType< + ReturnType["putSettingsNotificationsProvidersId"] + > + > +>; +export type DeleteSettingsNotificationsProvidersIdResult = NonNullable< + Awaited< + ReturnType< + ReturnType< + typeof getNotifications + >["deleteSettingsNotificationsProvidersId"] + > + > +>; +export type PostSettingsNotificationsProvidersIdTestResult = NonNullable< + Awaited< + ReturnType< + ReturnType< + typeof getNotifications + >["postSettingsNotificationsProvidersIdTest"] + > + > +>; diff --git a/frontend/src/shared/types/model/index.ts b/frontend/src/shared/types/model/index.ts index e099e75..1ee887c 100644 --- a/frontend/src/shared/types/model/index.ts +++ b/frontend/src/shared/types/model/index.ts @@ -13,6 +13,9 @@ export * from "./getServicesIdStatsParams"; export * from "./getServicesParams"; export * from "./getTagsCount200"; export * from "./modelsAvailableUpdate"; +export * from "./modelsIncident"; +export * from "./modelsNotificationProvider"; +export * from "./modelsNotificationProviderType"; export * from "./modelsServiceProtocolType"; export * from "./modelsServiceStatus"; export * from "./monitorsConfig"; @@ -23,10 +26,12 @@ export * from "./monitorsGRPCConfig"; export * from "./monitorsGRPCConfigCheckType"; export * from "./monitorsHTTPConfig"; export * from "./monitorsTCPConfig"; +export * from "./notificationsCreateProviderParams"; +export * from "./notificationsUpdateProviderParams"; export * from "./serviceStats"; -export * from "./storageIncident"; -export * from "./storecmnFindResponseWithCountStorageIncident"; +export * from "./storecmnFindResponseWithCountModelsIncident"; export * from "./storecmnFindResponseWithCountWebServiceDTO"; +export * from "./storecmnJSONField"; export * from "./webCreateUpdateServiceRequest"; export * from "./webDashboardStats"; export * from "./webDashboardStatsProtocols"; diff --git a/frontend/src/shared/types/model/storageIncident.ts b/frontend/src/shared/types/model/modelsIncident.ts similarity index 80% rename from frontend/src/shared/types/model/storageIncident.ts rename to frontend/src/shared/types/model/modelsIncident.ts index 20ad4d8..44f2f76 100644 --- a/frontend/src/shared/types/model/storageIncident.ts +++ b/frontend/src/shared/types/model/modelsIncident.ts @@ -6,7 +6,8 @@ * OpenAPI spec version: 1.0 */ -export interface StorageIncident { +export interface ModelsIncident { + created_at?: string; duration?: number; end_time?: string; error?: string; @@ -14,4 +15,5 @@ export interface StorageIncident { resolved?: boolean; service_id?: string; start_time?: string; + updated_at?: string; } diff --git a/frontend/src/shared/types/model/modelsNotificationProvider.ts b/frontend/src/shared/types/model/modelsNotificationProvider.ts new file mode 100644 index 0000000..4f6681d --- /dev/null +++ b/frontend/src/shared/types/model/modelsNotificationProvider.ts @@ -0,0 +1,18 @@ +/** + * Generated by orval v7.11.2 🍺 + * Do not edit manually. + * Sentinel Monitoring API + * API for service monitoring and incident management + * OpenAPI spec version: 1.0 + */ +import type { StorecmnJSONField } from "./storecmnJSONField"; +import type { ModelsNotificationProviderType } from "./modelsNotificationProviderType"; + +export interface ModelsNotificationProvider { + config?: StorecmnJSONField; + created_at?: string; + id?: string; + is_enabled?: boolean; + provider_type?: ModelsNotificationProviderType; + updated_at?: string; +} diff --git a/frontend/src/shared/types/model/modelsNotificationProviderType.ts b/frontend/src/shared/types/model/modelsNotificationProviderType.ts new file mode 100644 index 0000000..c3cf565 --- /dev/null +++ b/frontend/src/shared/types/model/modelsNotificationProviderType.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.11.2 🍺 + * Do not edit manually. + * Sentinel Monitoring API + * API for service monitoring and incident management + * OpenAPI spec version: 1.0 + */ + +export type ModelsNotificationProviderType = + (typeof ModelsNotificationProviderType)[keyof typeof ModelsNotificationProviderType]; + +// eslint-disable-next-line @typescript-eslint/no-redeclare +export const ModelsNotificationProviderType = { + NotificationProviderTypeShoutrrr: "shoutrrr", +} as const; diff --git a/frontend/src/shared/types/model/notificationsCreateProviderParams.ts b/frontend/src/shared/types/model/notificationsCreateProviderParams.ts new file mode 100644 index 0000000..e6a5873 --- /dev/null +++ b/frontend/src/shared/types/model/notificationsCreateProviderParams.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.11.2 🍺 + * Do not edit manually. + * Sentinel Monitoring API + * API for service monitoring and incident management + * OpenAPI spec version: 1.0 + */ + +export interface NotificationsCreateProviderParams { + [key: string]: unknown; +} diff --git a/frontend/src/shared/types/model/notificationsUpdateProviderParams.ts b/frontend/src/shared/types/model/notificationsUpdateProviderParams.ts new file mode 100644 index 0000000..ac766fa --- /dev/null +++ b/frontend/src/shared/types/model/notificationsUpdateProviderParams.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.11.2 🍺 + * Do not edit manually. + * Sentinel Monitoring API + * API for service monitoring and incident management + * OpenAPI spec version: 1.0 + */ +import type { StorecmnJSONField } from "./storecmnJSONField"; +import type { ModelsNotificationProviderType } from "./modelsNotificationProviderType"; + +export interface NotificationsUpdateProviderParams { + config?: StorecmnJSONField; + is_enabled?: boolean; + provider_type?: ModelsNotificationProviderType; +} diff --git a/frontend/src/shared/types/model/storecmnFindResponseWithCountStorageIncident.ts b/frontend/src/shared/types/model/storecmnFindResponseWithCountModelsIncident.ts similarity index 56% rename from frontend/src/shared/types/model/storecmnFindResponseWithCountStorageIncident.ts rename to frontend/src/shared/types/model/storecmnFindResponseWithCountModelsIncident.ts index 05bcc6c..7c7d36d 100644 --- a/frontend/src/shared/types/model/storecmnFindResponseWithCountStorageIncident.ts +++ b/frontend/src/shared/types/model/storecmnFindResponseWithCountModelsIncident.ts @@ -5,9 +5,9 @@ * API for service monitoring and incident management * OpenAPI spec version: 1.0 */ -import type { StorageIncident } from "./storageIncident"; +import type { ModelsIncident } from "./modelsIncident"; -export interface StorecmnFindResponseWithCountStorageIncident { +export interface StorecmnFindResponseWithCountModelsIncident { count?: number; - items?: StorageIncident[]; + items?: ModelsIncident[]; } diff --git a/frontend/src/shared/types/model/storecmnJSONField.ts b/frontend/src/shared/types/model/storecmnJSONField.ts new file mode 100644 index 0000000..69cc82a --- /dev/null +++ b/frontend/src/shared/types/model/storecmnJSONField.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.11.2 🍺 + * Do not edit manually. + * Sentinel Monitoring API + * API for service monitoring and incident management + * OpenAPI spec version: 1.0 + */ + +export interface StorecmnJSONField { + [key: string]: unknown; +} diff --git a/frontend/src/shared/types/model/webDashboardStats.ts b/frontend/src/shared/types/model/webDashboardStats.ts index b8648a5..f6470f9 100644 --- a/frontend/src/shared/types/model/webDashboardStats.ts +++ b/frontend/src/shared/types/model/webDashboardStats.ts @@ -14,7 +14,6 @@ export interface WebDashboardStats { active_incidents?: number; avg_response_time?: number; checks_per_minute?: number; - last_check_time?: string; protocols?: WebDashboardStatsProtocols; services_down?: number; services_unknown?: number; diff --git a/internal/models/models_gen.go b/internal/models/models_gen.go index 30560d9..ecea0bc 100644 --- a/internal/models/models_gen.go +++ b/internal/models/models_gen.go @@ -26,8 +26,8 @@ type Agent struct { Config storecmn.JSONField `db:"config" json:"config"` SystemInfo storecmn.JSONField `db:"system_info" json:"system_info"` LastSeenAt *time.Time `db:"last_seen_at" json:"last_seen_at"` - CreatedAt *time.Time `db:"created_at" json:"created_at"` - UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } type Incident struct { @@ -43,31 +43,36 @@ type Incident struct { } type IncidentState struct { - ID string `db:"id" json:"id"` - IncidentID string `db:"incident_id" json:"incident_id"` - Status string `db:"status" json:"status"` - Level int64 `db:"level" json:"level"` - CreatedAt *time.Time `db:"created_at" json:"created_at"` - UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` + ID string `db:"id" json:"id"` + IncidentID string `db:"incident_id" json:"incident_id"` + Status string `db:"status" json:"status"` + Level int64 `db:"level" json:"level"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } type NotificationHistory struct { - ID string `db:"id" json:"id"` - ProviderID string `db:"provider_id" json:"provider_id"` - IncidentID *string `db:"incident_id" json:"incident_id"` - Message string `db:"message" json:"message"` - Status string `db:"status" json:"status"` - ErrorMessage *string `db:"error_message" json:"error_message"` - CreatedAt *time.Time `db:"created_at" json:"created_at"` + ID string `db:"id" json:"id"` + ProviderID string `db:"provider_id" json:"provider_id"` + IncidentID *string `db:"incident_id" json:"incident_id"` + Message string `db:"message" json:"message"` + Status string `db:"status" json:"status"` + Response *string `db:"response" json:"response"` + Attempts int64 `db:"attempts" json:"attempts"` + ErrorMessage *string `db:"error_message" json:"error_message"` + LastAttemptAt *time.Time `db:"last_attempt_at" json:"last_attempt_at"` + SentAt *time.Time `db:"sent_at" json:"sent_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } type NotificationProvider struct { - ID string `db:"id" json:"id"` - ProviderType string `db:"provider_type" json:"provider_type"` - Config storecmn.JSONField `db:"config" json:"config"` - IsEnabled bool `db:"is_enabled" json:"is_enabled"` - CreatedAt *time.Time `db:"created_at" json:"created_at"` - UpdatedAt *time.Time `db:"updated_at" json:"updated_at"` + ID string `db:"id" json:"id"` + ProviderType NotificationProviderType `db:"provider_type" json:"provider_type"` + Config storecmn.JSONField `db:"config" json:"config"` + IsEnabled bool `db:"is_enabled" json:"is_enabled"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } type Service struct { diff --git a/internal/models/notification.go b/internal/models/notification.go new file mode 100644 index 0000000..d49435e --- /dev/null +++ b/internal/models/notification.go @@ -0,0 +1,38 @@ +package models + +import ( + "fmt" +) + +type NotificationProviderType string + +const ( + NotificationProviderTypeShoutrrr NotificationProviderType = "shoutrrr" +) + +// Validate checks the validity of the NotificationProvider fields. +func (n NotificationProviderType) Validate() error { + if n == "" { + return fmt.Errorf("empty notification provider type") + } + + switch n { + case NotificationProviderTypeShoutrrr: + return nil + default: + return fmt.Errorf("invalid notification provider type: %s", n) + } +} + +type NotificationProviderShoutrrrConfig struct { + URL string `json:"url"` +} + +// Validate checks the validity of the NotificationProviderShoutrrrConfig fields. +func (n NotificationProviderShoutrrrConfig) Validate() error { + if n.URL == "" { + return fmt.Errorf("empty shoutrrr url") + } + + return nil +} diff --git a/internal/notifier/shoutrrr.go b/internal/notifier/shoutrrr.go deleted file mode 100644 index 68614e1..0000000 --- a/internal/notifier/shoutrrr.go +++ /dev/null @@ -1,344 +0,0 @@ -package notifier - -import ( - "context" - "fmt" - "strings" - "sync" - "time" - - "github.com/containrrr/shoutrrr" - "github.com/sxwebdev/sentinel/internal/models" - "github.com/tkcrm/mx/logger" -) - -// notificationRequest represents a single notification request -type notificationRequest struct { - Message string - CreatedAt time.Time -} - -type Notifier struct { - logger logger.Logger - urls []string - queue chan *notificationRequest - done chan struct{} - wg sync.WaitGroup - mu sync.RWMutex - isStarted bool - name string -} - -// New creates a new Notifier instance as a service -func New(l logger.Logger, urls []string) (*Notifier, error) { - if len(urls) == 0 { - return nil, fmt.Errorf("no notification URLs provided") - } - - notifier := &Notifier{ - logger: l, - urls: urls, - queue: make(chan *notificationRequest, 500), // buffer for 500 notifications - done: make(chan struct{}), - name: "NotificationService", - } - - return notifier, nil -} - -// Name returns the service name -func (s *Notifier) Name() string { return s.name } - -// Start starts the notification service -func (s *Notifier) Start(ctx context.Context) error { - s.mu.Lock() - defer s.mu.Unlock() - - if s.isStarted { - return fmt.Errorf("notification service already started") - } - - s.isStarted = true - - // Start processing queue in a separate goroutine - s.wg.Go(s.processQueue) - - return nil -} - -// Stop stops the notification service -func (s *Notifier) Stop(ctx context.Context) error { - s.mu.Lock() - if !s.isStarted { - s.mu.Unlock() - return nil - } - s.mu.Unlock() - - // Close the done channel to signal stop - close(s.done) - - // Wait for completion with timeout from context - done := make(chan struct{}) - go func() { - s.wg.Wait() - close(done) - }() - - select { - case <-done: - // All goroutines completed - case <-ctx.Done(): - // Timeout - force termination - return fmt.Errorf("notification service stop timeout: %w", ctx.Err()) - } - - s.mu.Lock() - s.isStarted = false - s.mu.Unlock() - - return nil -} - -// processQueue processes the notification queue with 500ms delay -func (s *Notifier) processQueue() { - ticker := time.NewTicker(500 * time.Millisecond) - defer ticker.Stop() - - for { - select { - case <-s.done: - // Process remaining notifications in queue before exit - for { - select { - case req := <-s.queue: - s.processNotification(req) - default: - return - } - } - - case <-ticker.C: - // Check queue every 500ms - select { - case req := <-s.queue: - s.processNotification(req) - default: - // Queue is empty, continue - } - } - } -} - -// processNotification processes a single notification with simple retry -func (s *Notifier) processNotification(req *notificationRequest) { - err := s.sendMessageSync(req.Message) - - if err != nil { - s.logger.Errorf("failed to send notification: %v", err) - - // Put back in queue for retry - s.queue <- req - } else { - s.logger.Info("notification sent successfully") - } -} - -// SendAlert sends an alert notification when a service goes down -func (s *Notifier) SendAlert(service *models.ServiceFullView, incident *models.Incident) error { - s.mu.RLock() - if !s.isStarted { - s.mu.RUnlock() - return fmt.Errorf("notification service is not started") - } - s.mu.RUnlock() - - message := s.formatAlertMessage(service, incident) - return s.enqueueMessage(message) -} - -// SendRecovery sends a recovery notification when a service comes back up -func (s *Notifier) SendRecovery(service *models.ServiceFullView, incident *models.Incident) error { - s.mu.RLock() - if !s.isStarted { - s.mu.RUnlock() - return fmt.Errorf("notification service is not started") - } - s.mu.RUnlock() - - message := s.formatRecoveryMessage(service, incident) - return s.enqueueMessage(message) -} - -// enqueueMessage adds a message to the queue for sending -func (s *Notifier) enqueueMessage(message string) error { - req := ¬ificationRequest{ - Message: message, - CreatedAt: time.Now(), - } - - select { - case s.queue <- req: - return nil - default: - return fmt.Errorf("notification queue is full") - } -} - -// sendMessageSync sends a message to all configured providers synchronously -// If one provider fails, it continues with others and returns partial errors -func (s *Notifier) sendMessageSync(message string) error { - // Send to all providers concurrently - var wg sync.WaitGroup - errors := make(chan error, len(s.urls)) - - for i, url := range s.urls { - wg.Add(1) - go func(index int, providerURL string) { - defer wg.Done() - - // Create individual sender for this provider - sender, err := shoutrrr.CreateSender(providerURL) - if err != nil { - errors <- fmt.Errorf("provider %d: failed to create sender: %w", index, err) - return - } - - providerName, _, err := sender.ExtractServiceName(providerURL) - if err != nil { - errors <- fmt.Errorf("provider %d: failed to extract service name: %w", index, err) - return - } - - // Send message with timeout - timeoutCtx, timeoutCancel := context.WithTimeout(context.Background(), 30*time.Second) - defer timeoutCancel() - - done := make(chan []error, 1) - go func() { - defer func() { - if r := recover(); r != nil { - errors <- fmt.Errorf("provider %s: panic during send: %v", providerName, r) - } - }() - done <- sender.Send(message, nil) - }() - - select { - case errs := <-done: - // Check if there are any actual errors (not nil) - hasErrors := false - for _, err := range errs { - if err != nil { - hasErrors = true - break - } - } - - if hasErrors { - errors <- fmt.Errorf("provider %s: failed to send: %v", providerName, errs) - } - case <-timeoutCtx.Done(): - errors <- fmt.Errorf("provider %s: timeout", providerName) - // Note: The goroutine with sender.Send() might still be running, - // but we can't force-kill it. This is a limitation of the shoutrrr library. - } - }(i, url) - } - - wg.Wait() - close(errors) - - // Collect all errors - var allErrors []error - for err := range errors { - allErrors = append(allErrors, err) - } - - // If all providers failed, return an error - if len(allErrors) == len(s.urls) { - return fmt.Errorf("all notification providers failed: %v", allErrors) - } - - // If some providers failed, return partial error - if len(allErrors) > 0 { - return fmt.Errorf("some notification providers failed (%d/%d): %v", len(allErrors), len(s.urls), allErrors) - } - - return nil -} - -// formatAlertMessage formats an alert message -func (s *Notifier) formatAlertMessage(service *models.ServiceFullView, incident *models.Incident) string { - tags := "-" - if len(service.Tags) > 0 { - tags = strings.Join(service.Tags, ", ") - } - - return fmt.Sprintf( - "🔴 [ALERT] %s is DOWN\n\n"+ - "• Service: %s\n"+ - "• Tags: %s\n"+ - "• Error: %s\n"+ - "• Started: %s\n"+ - "• Incident ID: %s", - service.Name, - service.Name, - tags, - incident.Error, - incident.StartTime.Format("2006-01-02 15:04:05"), - incident.ID, - ) -} - -// formatRecoveryMessage formats a recovery message -func (s *Notifier) formatRecoveryMessage(service *models.ServiceFullView, incident *models.Incident) string { - var duration string - if incident.Duration != nil { - duration = formatDuration(time.Duration(*incident.Duration) * time.Millisecond) - } else { - duration = formatDuration(time.Since(incident.StartTime)) - } - - var endTime string - if incident.EndTime != nil { - endTime = incident.EndTime.Format("2006-01-02 15:04:05") - } else { - endTime = time.Now().Format("2006-01-02 15:04:05") - } - - tags := "-" - if len(service.Tags) > 0 { - tags = strings.Join(service.Tags, ", ") - } - - return fmt.Sprintf( - "🟢 [RECOVERY] %s is UP\n\n"+ - "• Service: %s\n"+ - "• Tags: %s\n"+ - "• Downtime: %s\n"+ - "• Recovered: %s\n"+ - "• Incident ID: %s", - service.Name, - service.Name, - tags, - duration, - endTime, - incident.ID, - ) -} - -// formatDuration formats a duration in a human-readable way -func formatDuration(d time.Duration) string { - if d < time.Minute { - return fmt.Sprintf("%ds", int(d.Seconds())) - } - if d < time.Hour { - minutes := int(d.Minutes()) - seconds := int(d.Seconds()) % 60 - return fmt.Sprintf("%dm %ds", minutes, seconds) - } - hours := int(d.Hours()) - minutes := int(d.Minutes()) % 60 - return fmt.Sprintf("%dh %dm", hours, minutes) -} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index ae5583b..3e79085 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -4,14 +4,13 @@ import ( "context" "errors" "fmt" - "log" + "strings" "sync" "time" "github.com/puzpuzpuz/xsync/v3" "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/monitors" - "github.com/sxwebdev/sentinel/internal/notifier" "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/services/baseservices" "github.com/sxwebdev/sentinel/internal/services/incidents" @@ -31,8 +30,7 @@ var ErrServiceNotFound = fmt.Errorf("service not found") type Scheduler struct { logger logger.Logger - store *store.Store - notifier *notifier.Notifier + store *store.Store receiver *receiver.Receiver baseservices *baseservices.BaseServices @@ -45,14 +43,12 @@ type Scheduler struct { func New( l logger.Logger, store *store.Store, - notifier *notifier.Notifier, receiver *receiver.Receiver, baseServices *baseservices.BaseServices, ) *Scheduler { return &Scheduler{ logger: l, store: store, - notifier: notifier, receiver: receiver, baseservices: baseServices, jobs: xsync.NewMapOf[string, *job](), @@ -516,12 +512,9 @@ func (m *Scheduler) createIncident(ctx context.Context, svc *models.ServiceFullV } // Send alert notification - if m.notifier != nil { - if err := m.notifier.SendAlert(svc, incident); err != nil { - err := fmt.Errorf("failed to send alert notification for %s: %w", svc.Name, err) - log.Println(err) - return nil - } + message := m.formatAlertMessage(svc, incident) + if err := m.baseservices.Notifications().History().SendAlert(ctx, incident.ID, message); err != nil { + m.logger.Errorf("failed to send alert notification for %s: %v", svc.Name, err) } return nil @@ -552,13 +545,71 @@ func (m *Scheduler) resolveActiveIncidents(ctx context.Context, serviceID string return fmt.Errorf("failed to resolve incident %s: %w", incident.ID, err) } - if m.notifier != nil { - if err := m.notifier.SendRecovery(svc, resolverIncident); err != nil { - m.logger.Errorf("failed to send recovery notification for %s: %v", svc.Name, err) - return nil - } + message := m.formatRecoveryMessage(svc, resolverIncident) + if err := m.baseservices.Notifications().History().SendAlert(ctx, resolverIncident.ID, message); err != nil { + m.logger.Errorf("failed to send recovery notification for %s: %v", svc.Name, err) } } return nil } + +// formatAlertMessage formats an alert message +func (s *Scheduler) formatAlertMessage(service *models.ServiceFullView, incident *models.Incident) string { + tags := "-" + if len(service.Tags) > 0 { + tags = strings.Join(service.Tags, ", ") + } + + return fmt.Sprintf( + "🔴 [ALERT] %s is DOWN\n\n"+ + "• Service: %s\n"+ + "• Tags: %s\n"+ + "• Error: %s\n"+ + "• Started: %s\n"+ + "• Incident ID: %s", + service.Name, + service.Name, + tags, + incident.Error, + incident.StartTime.Format("2006-01-02 15:04:05"), + incident.ID, + ) +} + +// formatRecoveryMessage formats a recovery message +func (s *Scheduler) formatRecoveryMessage(service *models.ServiceFullView, incident *models.Incident) string { + var duration string + if incident.Duration != nil { + duration = utils.FormatDuration(time.Duration(*incident.Duration) * time.Millisecond) + } else { + duration = utils.FormatDuration(time.Since(incident.StartTime)) + } + + var endTime string + if incident.EndTime != nil { + endTime = incident.EndTime.Format("2006-01-02 15:04:05") + } else { + endTime = time.Now().Format("2006-01-02 15:04:05") + } + + tags := "-" + if len(service.Tags) > 0 { + tags = strings.Join(service.Tags, ", ") + } + + return fmt.Sprintf( + "🟢 [RECOVERY] %s is UP\n\n"+ + "• Service: %s\n"+ + "• Tags: %s\n"+ + "• Downtime: %s\n"+ + "• Recovered: %s\n"+ + "• Incident ID: %s", + service.Name, + service.Name, + tags, + duration, + endTime, + incident.ID, + ) +} diff --git a/internal/services/agents/methods.go b/internal/services/agents/methods.go index c21a27f..aa114d1 100644 --- a/internal/services/agents/methods.go +++ b/internal/services/agents/methods.go @@ -38,13 +38,13 @@ func (s *Service) Create(ctx context.Context, params CreateParams) (*models.Agen // Convert tags to JSONField tags := storecmn.JSONField("[]") - if err := tags.UnmarshalAny(params.Tags); err != nil { + if err := tags.UnmarshalFromAny(params.Tags); err != nil { return nil, fmt.Errorf("failed to convert tags to json raw message: %w", err) } // Convert config to JSONField config := storecmn.JSONField("{}") - if err := config.UnmarshalAny(params.Config); err != nil { + if err := config.UnmarshalFromAny(params.Config); err != nil { return nil, fmt.Errorf("failed to convert config to json raw message: %w", err) } @@ -148,19 +148,19 @@ func (s *Service) Update(ctx context.Context, id string, params UpdateParams) (* // Convert tags to JSONField tags := storecmn.JSONField("[]") - if err := tags.UnmarshalAny(params.Tags); err != nil { + if err := tags.UnmarshalFromAny(params.Tags); err != nil { return nil, fmt.Errorf("failed to convert tags to json raw message: %w", err) } // Convert config to JSONField config := storecmn.JSONField("{}") - if err := config.UnmarshalAny(params.Config); err != nil { + if err := config.UnmarshalFromAny(params.Config); err != nil { return nil, fmt.Errorf("failed to convert config to json raw message: %w", err) } // Convert system info to JSONField systemInfo := storecmn.JSONField("{}") - if err := systemInfo.UnmarshalAny(params.SystemInfo); err != nil { + if err := systemInfo.UnmarshalFromAny(params.SystemInfo); err != nil { return nil, fmt.Errorf("failed to convert system info to json raw message: %w", err) } diff --git a/internal/services/baseservices/base.go b/internal/services/baseservices/base.go index 1254f38..1865330 100644 --- a/internal/services/baseservices/base.go +++ b/internal/services/baseservices/base.go @@ -4,19 +4,23 @@ import ( "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/services/agents" "github.com/sxwebdev/sentinel/internal/services/incidents" + "github.com/sxwebdev/sentinel/internal/services/notifications" "github.com/sxwebdev/sentinel/internal/services/service" "github.com/sxwebdev/sentinel/internal/services/servicestate" "github.com/sxwebdev/sentinel/internal/store" + "github.com/tkcrm/mx/logger" ) type BaseServices struct { - agentsService *agents.Service - servicesService *service.Service - serviceStateService *servicestate.Service - incidentsService *incidents.Service + agentsService *agents.Service + servicesService *service.Service + serviceStateService *servicestate.Service + incidentsService *incidents.Service + notificationsService *notifications.Service } func New( + l logger.Logger, st *store.Store, receiver *receiver.Receiver, ) *BaseServices { @@ -24,12 +28,14 @@ func New( servicesService := service.New(st, receiver) serviceStateService := servicestate.New(st) incidentsService := incidents.New(st) + notificationsService := notifications.New(l, st) return &BaseServices{ - agentsService: agentsService, - servicesService: servicesService, - serviceStateService: serviceStateService, - incidentsService: incidentsService, + agentsService: agentsService, + servicesService: servicesService, + serviceStateService: serviceStateService, + incidentsService: incidentsService, + notificationsService: notificationsService, } } @@ -52,3 +58,8 @@ func (b *BaseServices) ServiceStates() *servicestate.Service { func (b *BaseServices) Incidents() *incidents.Service { return b.incidentsService } + +// Notifications returns notifications service +func (b *BaseServices) Notifications() *notifications.Service { + return b.notificationsService +} diff --git a/internal/services/notifications/helpers.go b/internal/services/notifications/helpers.go new file mode 100644 index 0000000..419eba8 --- /dev/null +++ b/internal/services/notifications/helpers.go @@ -0,0 +1,22 @@ +package notifications + +import ( + "fmt" + + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/storecmn" +) + +// validateProviderConfig validates the configuration of a notification provider based on its type. +func validateProviderConfig(providerType models.NotificationProviderType, config storecmn.JSONField) error { + switch providerType { + case models.NotificationProviderTypeShoutrrr: + var c models.NotificationProviderShoutrrrConfig + if err := config.ConvertToAny(&c); err != nil { + return err + } + return c.Validate() + default: + return fmt.Errorf("unsupported notification provider type: %s", providerType) + } +} diff --git a/internal/services/notifications/history.go b/internal/services/notifications/history.go new file mode 100644 index 0000000..1a7c510 --- /dev/null +++ b/internal/services/notifications/history.go @@ -0,0 +1,105 @@ +package notifications + +import ( + "context" + "fmt" + + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store" + "github.com/sxwebdev/sentinel/internal/store/repos/repo_notification_history" + "github.com/sxwebdev/sentinel/internal/store/storecmn" + "github.com/sxwebdev/sentinel/internal/utils" + "github.com/tkcrm/mx/logger" +) + +type History struct { + logger logger.Logger + store *store.Store + sender *Sender +} + +func newHistory(l logger.Logger, store *store.Store, sender *Sender) *History { + return &History{ + logger: l, + store: store, + sender: sender, + } +} + +// SendAlert sends an alert notification to all enabled providers +func (s *History) SendAlert(ctx context.Context, incidentID, message string) error { + // Get all enabled providers + providers, err := s.store.NotificationProviders().GetAllEnabled(ctx) + if err != nil { + return fmt.Errorf("failed to get enabled providers: %w", err) + } + + // Send alert to each provider + for _, provider := range providers { + params := CreateHistoryParams{ + ProviderID: provider.ID, + Message: message, + } + + if incidentID != "" { + params.IncidentID = &incidentID + } + + if _, err := s.Create(ctx, params); err != nil { + return fmt.Errorf("failed to send alert to provider %s: %v", provider.ID, err) + } + } + + return nil +} + +type CreateHistoryParams struct { + ProviderID string `db:"provider_id" json:"provider_id"` + IncidentID *string `db:"incident_id" json:"incident_id"` + Message string `db:"message" json:"message"` +} + +// Validate validates the CreateHistoryParams fields +func (s CreateHistoryParams) Validate() error { + if s.ProviderID == "" { + return storecmn.ErrEmptyID + } + + if s.Message == "" { + return fmt.Errorf("empty message") + } + + return nil +} + +// Create creates a new notification history record +func (s *History) Create(ctx context.Context, params CreateHistoryParams) (*models.NotificationHistory, error) { + if err := params.Validate(); err != nil { + return nil, err + } + + createParams := repo_notification_history.CreateParams{ + ID: utils.GenerateULID(), + ProviderID: params.ProviderID, + IncidentID: params.IncidentID, + Message: params.Message, + } + + item, err := s.store.NotificationHistory().Create(ctx, createParams) + if err != nil { + return nil, err + } + + s.sender.looper.Trigger(ctx) + + return item, nil +} + +// Delete deletes a notification history record by ID +func (s *History) Delete(ctx context.Context, id string) error { + if id == "" { + return storecmn.ErrEmptyID + } + + return s.store.NotificationHistory().Delete(ctx, id) +} diff --git a/internal/services/notifications/providers.go b/internal/services/notifications/providers.go new file mode 100644 index 0000000..20fb7ca --- /dev/null +++ b/internal/services/notifications/providers.go @@ -0,0 +1,154 @@ +package notifications + +import ( + "context" + "database/sql" + "errors" + + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store" + "github.com/sxwebdev/sentinel/internal/store/repos/repo_notification_providers" + "github.com/sxwebdev/sentinel/internal/store/storecmn" + "github.com/sxwebdev/sentinel/internal/utils" +) + +type Providers struct { + store *store.Store + + history *History +} + +func newProviders(store *store.Store, history *History) *Providers { + return &Providers{ + store: store, + history: history, + } +} + +// GetByID retrieves a notification provider by its ID +func (s *Providers) GetByID(ctx context.Context, id string) (*models.NotificationProvider, error) { + if id == "" { + return nil, storecmn.ErrEmptyID + } + + item, err := s.store.NotificationProviders().GetByID(ctx, id) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, storecmn.ErrNotFound + } + return nil, err + } + + return item, nil +} + +// GetAll retrieves all service states +func (s *Providers) GetAll(ctx context.Context) ([]*models.NotificationProvider, error) { + return s.store.NotificationProviders().GetAll(ctx) +} + +type CreateProviderParams struct { + ProviderType models.NotificationProviderType `db:"provider_type" json:"provider_type"` + Config storecmn.JSONField `db:"config" json:"config"` +} + +// Validate +func (s CreateProviderParams) Validate() error { + if err := s.ProviderType.Validate(); err != nil { + return err + } + + if err := validateProviderConfig(s.ProviderType, s.Config); err != nil { + return err + } + + return nil +} + +// Create creates a new notification provider +func (s *Providers) Create(ctx context.Context, params CreateProviderParams) (*models.NotificationProvider, error) { + if err := params.Validate(); err != nil { + return nil, err + } + + createParams := repo_notification_providers.CreateParams{ + ID: utils.GenerateULID(), + ProviderType: params.ProviderType, + Config: params.Config, + } + + return s.store.NotificationProviders().Create(ctx, createParams) +} + +type UpdateProviderParams struct { + ProviderType models.NotificationProviderType `db:"provider_type" json:"provider_type"` + Config storecmn.JSONField `db:"config" json:"config"` + IsEnabled bool `db:"is_enabled" json:"is_enabled"` +} + +// Validate +func (s UpdateProviderParams) Validate() error { + if err := s.ProviderType.Validate(); err != nil { + return err + } + + if err := validateProviderConfig(s.ProviderType, s.Config); err != nil { + return err + } + + return nil +} + +// Update updates a notification provider by ID +func (s *Providers) Update(ctx context.Context, id string, params UpdateProviderParams) (*models.NotificationProvider, error) { + if id == "" { + return nil, storecmn.ErrEmptyID + } + + if err := params.Validate(); err != nil { + return nil, err + } + + updateParams := repo_notification_providers.UpdateParams{ + ID: id, + ProviderType: params.ProviderType, + Config: params.Config, + IsEnabled: params.IsEnabled, + } + + if err := s.store.NotificationProviders().Update(ctx, updateParams); err != nil { + return nil, err + } + + return s.GetByID(ctx, id) +} + +// Delete deletes a notification provider by ID +func (s *Providers) Delete(ctx context.Context, id string) error { + if id == "" { + return storecmn.ErrEmptyID + } + + return s.store.NotificationProviders().Delete(ctx, id) +} + +// Test tests a notification provider by ID +func (s *Providers) Test(ctx context.Context, id string) error { + if id == "" { + return storecmn.ErrEmptyID + } + + // Get provider + provider, err := s.store.NotificationProviders().GetByID(ctx, id) + if err != nil { + return err + } + + // Create test notification history record + _, err = s.history.Create(ctx, CreateHistoryParams{ + ProviderID: provider.ID, + Message: "Test notification sent", + }) + + return err +} diff --git a/internal/services/notifications/sender.go b/internal/services/notifications/sender.go new file mode 100644 index 0000000..2257c0b --- /dev/null +++ b/internal/services/notifications/sender.go @@ -0,0 +1,135 @@ +package notifications + +import ( + "context" + "fmt" + "time" + + "github.com/containrrr/shoutrrr" + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store" + "github.com/sxwebdev/sentinel/internal/store/repos/repo_notification_history" + "github.com/sxwebdev/sentinel/internal/utils" + "github.com/sxwebdev/sentinel/pkg/loop" + "github.com/tkcrm/mx/logger" +) + +type Sender struct { + logger logger.Logger + store *store.Store + looper *loop.Loop +} + +func newSender(l logger.Logger, store *store.Store) *Sender { + s := &Sender{ + logger: l, + store: store, + } + + s.looper = loop.New( + s.initSender, + loop.WithLeading(), + loop.WithPeriod(time.Second*5), + loop.WithContextTimeout(time.Second*30), + ) + + return s +} + +// Name +func (s *Sender) Name() string { + return "notifications_sender" +} + +// Start +func (s *Sender) Start(ctx context.Context) error { + s.looper.Start(ctx) + return nil +} + +// Stop +func (s *Sender) Stop(_ context.Context) error { + s.looper.Stop() + s.looper.Wait() + return nil +} + +// initSender +func (s *Sender) initSender(ctx context.Context) { + if err := s.do(ctx); err != nil { + s.logger.Errorf("failed to process notification history: %v", err) + } +} + +func (s *Sender) do(ctx context.Context) error { + items, err := s.store.NotificationHistory().GetAllUnsent(ctx) + if err != nil { + return err + } + + if len(items) == 0 { + return nil + } + + s.logger.Infof("found %d unsent notification history items", len(items)) + + for _, item := range items { + var err error + switch item.ProviderType { + case models.NotificationProviderTypeShoutrrr: + err = s.processShoutrrr(ctx, item) + default: + s.logger.Warnf("unsupported notification provider type: %s", item.ProviderType) + continue + } + + if err != nil { + createErr := s.store.NotificationHistory().IncrementAttempt(ctx, repo_notification_history.IncrementAttemptParams{ + ID: item.ID, + ErrorMessage: utils.Pointer(err.Error()), + }) + if createErr != nil { + return createErr + } + + s.logger.Errorf("failed to send notification %s: %v", item.ID, err) + } else { + err := s.store.NotificationHistory().MarkAsSent(ctx, nil, item.ID) + if err != nil { + return err + } + + s.logger.Infof("notification %s sent successfully", item.ID) + } + + time.Sleep(500 * time.Millisecond) + } + + return nil +} + +// processShoutrrr +func (s *Sender) processShoutrrr(ctx context.Context, item *repo_notification_history.GetAllUnsentRow) error { + var config models.NotificationProviderShoutrrrConfig + if err := item.Config.ConvertToAny(&config); err != nil { + return err + } + + if config.URL == "" { + return fmt.Errorf("empty shoutrrr URL in config for provider ID %s", item.ProviderID) + } + + errCh := make(chan error, 1) + go func() { + errCh <- shoutrrr.Send(config.URL, item.Message) + }() + + select { + case <-ctx.Done(): + errCh <- ctx.Err() + case err := <-errCh: + return err + } + + return nil +} diff --git a/internal/services/notifications/service.go b/internal/services/notifications/service.go new file mode 100644 index 0000000..1c58f26 --- /dev/null +++ b/internal/services/notifications/service.go @@ -0,0 +1,38 @@ +package notifications + +import ( + "github.com/sxwebdev/sentinel/internal/store" + "github.com/tkcrm/mx/logger" +) + +type Service struct { + providers *Providers + history *History + sender *Sender +} + +func New(l logger.Logger, store *store.Store) *Service { + senderSvc := newSender(l, store) + historySvc := newHistory(l, store, senderSvc) + + return &Service{ + providers: newProviders(store, historySvc), + history: historySvc, + sender: senderSvc, + } +} + +// Providers returns notification providers service +func (s *Service) Providers() *Providers { + return s.providers +} + +// History returns notifications history service +func (s *Service) History() *History { + return s.history +} + +// Sender returns notifications sender service +func (s *Service) Sender() *Sender { + return s.sender +} diff --git a/internal/services/service/methods.go b/internal/services/service/methods.go index f469c32..39cec94 100644 --- a/internal/services/service/methods.go +++ b/internal/services/service/methods.go @@ -41,13 +41,13 @@ func (s *Service) Create(ctx context.Context, params CreateUpdateParams) (*model // Convert tags to JSONField tags := storecmn.JSONField("[]") - if err := tags.UnmarshalAny(params.Tags); err != nil { + if err := tags.UnmarshalFromAny(params.Tags); err != nil { return nil, fmt.Errorf("failed to convert tags to json raw message: %w", err) } // Convert config to JSONField config := storecmn.JSONField("{}") - if err := config.UnmarshalAny(params.Config); err != nil { + if err := config.UnmarshalFromAny(params.Config); err != nil { return nil, fmt.Errorf("failed to convert config to json raw message: %w", err) } @@ -111,13 +111,13 @@ func (s *Service) Update(ctx context.Context, id string, params CreateUpdatePara // Convert tags to JSONField tags := storecmn.JSONField("[]") - if err := tags.UnmarshalAny(params.Tags); err != nil { + if err := tags.UnmarshalFromAny(params.Tags); err != nil { return nil, fmt.Errorf("failed to convert tags to json raw message: %w", err) } // Convert config to JSONField config := storecmn.JSONField("{}") - if err := config.UnmarshalAny(params.Config); err != nil { + if err := config.UnmarshalFromAny(params.Config); err != nil { return nil, fmt.Errorf("failed to convert config to json raw message: %w", err) } diff --git a/internal/store/repos/repo_notification_history/constants_gen.go b/internal/store/repos/repo_notification_history/constants_gen.go index 0694771..67d77b8 100755 --- a/internal/store/repos/repo_notification_history/constants_gen.go +++ b/internal/store/repos/repo_notification_history/constants_gen.go @@ -39,13 +39,18 @@ func (s ColumnNames) Strings() []string { } const ( - ColumnNameNotificationHistoryId ColumnName = "id" - ColumnNameNotificationHistoryProviderId ColumnName = "provider_id" - ColumnNameNotificationHistoryIncidentId ColumnName = "incident_id" - ColumnNameNotificationHistoryMessage ColumnName = "message" - ColumnNameNotificationHistoryStatus ColumnName = "status" - ColumnNameNotificationHistoryErrorMessage ColumnName = "error_message" - ColumnNameNotificationHistoryCreatedAt ColumnName = "created_at" + ColumnNameNotificationHistoryId ColumnName = "id" + ColumnNameNotificationHistoryProviderId ColumnName = "provider_id" + ColumnNameNotificationHistoryIncidentId ColumnName = "incident_id" + ColumnNameNotificationHistoryMessage ColumnName = "message" + ColumnNameNotificationHistoryStatus ColumnName = "status" + ColumnNameNotificationHistoryResponse ColumnName = "response" + ColumnNameNotificationHistoryAttempts ColumnName = "attempts" + ColumnNameNotificationHistoryErrorMessage ColumnName = "error_message" + ColumnNameNotificationHistoryLastAttemptAt ColumnName = "last_attempt_at" + ColumnNameNotificationHistorySentAt ColumnName = "sent_at" + ColumnNameNotificationHistoryCreatedAt ColumnName = "created_at" + ColumnNameNotificationHistoryUpdatedAt ColumnName = "updated_at" ) func NotificationHistoryColumnNames() ColumnNames { @@ -55,7 +60,12 @@ func NotificationHistoryColumnNames() ColumnNames { ColumnNameNotificationHistoryIncidentId, ColumnNameNotificationHistoryMessage, ColumnNameNotificationHistoryStatus, + ColumnNameNotificationHistoryResponse, + ColumnNameNotificationHistoryAttempts, ColumnNameNotificationHistoryErrorMessage, + ColumnNameNotificationHistoryLastAttemptAt, + ColumnNameNotificationHistorySentAt, ColumnNameNotificationHistoryCreatedAt, + ColumnNameNotificationHistoryUpdatedAt, } } diff --git a/internal/store/repos/repo_notification_history/notification_history.sql.go b/internal/store/repos/repo_notification_history/notification_history.sql.go new file mode 100644 index 0000000..eb46dc0 --- /dev/null +++ b/internal/store/repos/repo_notification_history/notification_history.sql.go @@ -0,0 +1,123 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: notification_history.sql + +package repo_notification_history + +import ( + "context" + "time" + + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/storecmn" +) + +const getAllUnsent = `-- name: GetAllUnsent :many +SELECT + h.id, h.provider_id, h.incident_id, h.message, h.status, h.response, h.attempts, h.error_message, h.last_attempt_at, h.sent_at, h.created_at, h.updated_at, + p.provider_type, + p.config + FROM notification_history h + LEFT JOIN notification_providers p ON p.id = h.provider_id + WHERE + h.status != 'sent' AND + p.is_enabled = true + ORDER BY h.created_at ASC + LIMIT 100 +` + +type GetAllUnsentRow struct { + ID string `db:"id" json:"id"` + ProviderID string `db:"provider_id" json:"provider_id"` + IncidentID *string `db:"incident_id" json:"incident_id"` + Message string `db:"message" json:"message"` + Status string `db:"status" json:"status"` + Response *string `db:"response" json:"response"` + Attempts int64 `db:"attempts" json:"attempts"` + ErrorMessage *string `db:"error_message" json:"error_message"` + LastAttemptAt *time.Time `db:"last_attempt_at" json:"last_attempt_at"` + SentAt *time.Time `db:"sent_at" json:"sent_at"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` + ProviderType models.NotificationProviderType `db:"provider_type" json:"provider_type"` + Config storecmn.JSONField `db:"config" json:"config"` +} + +func (q *Queries) GetAllUnsent(ctx context.Context) ([]*GetAllUnsentRow, error) { + rows, err := q.db.QueryContext(ctx, getAllUnsent) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*GetAllUnsentRow{} + for rows.Next() { + var i GetAllUnsentRow + if err := rows.Scan( + &i.ID, + &i.ProviderID, + &i.IncidentID, + &i.Message, + &i.Status, + &i.Response, + &i.Attempts, + &i.ErrorMessage, + &i.LastAttemptAt, + &i.SentAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.ProviderType, + &i.Config, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const incrementAttempt = `-- name: IncrementAttempt :exec +UPDATE notification_history + SET + response = ?, + attempts = attempts + 1, + error_message = ?, + last_attempt_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? +` + +type IncrementAttemptParams struct { + Response *string `db:"response" json:"response"` + ErrorMessage *string `db:"error_message" json:"error_message"` + ID string `db:"id" json:"id"` +} + +func (q *Queries) IncrementAttempt(ctx context.Context, arg IncrementAttemptParams) error { + _, err := q.db.ExecContext(ctx, incrementAttempt, arg.Response, arg.ErrorMessage, arg.ID) + return err +} + +const markAsSent = `-- name: MarkAsSent :exec +UPDATE notification_history + SET + status = 'sent', + response = ?, + attempts = attempts + 1, + error_message = NULL, + last_attempt_at = CURRENT_TIMESTAMP, + sent_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? +` + +func (q *Queries) MarkAsSent(ctx context.Context, response *string, iD string) error { + _, err := q.db.ExecContext(ctx, markAsSent, response, iD) + return err +} diff --git a/internal/store/repos/repo_notification_history/notification_history_gen.sql.go b/internal/store/repos/repo_notification_history/notification_history_gen.sql.go index d63dd5a..5a39894 100644 --- a/internal/store/repos/repo_notification_history/notification_history_gen.sql.go +++ b/internal/store/repos/repo_notification_history/notification_history_gen.sql.go @@ -12,9 +12,9 @@ import ( ) const create = `-- name: Create :one -INSERT INTO notification_history (id, provider_id, incident_id, message, status, error_message) - VALUES (?, ?, ?, ?, ?, ?) - RETURNING id, provider_id, incident_id, message, status, error_message, created_at +INSERT INTO notification_history (id, provider_id, incident_id, message, error_message) + VALUES (?, ?, ?, ?, ?) + RETURNING id, provider_id, incident_id, message, status, response, attempts, error_message, last_attempt_at, sent_at, created_at, updated_at ` type CreateParams struct { @@ -22,7 +22,6 @@ type CreateParams struct { ProviderID string `db:"provider_id" json:"provider_id"` IncidentID *string `db:"incident_id" json:"incident_id"` Message string `db:"message" json:"message"` - Status string `db:"status" json:"status"` ErrorMessage *string `db:"error_message" json:"error_message"` } @@ -32,7 +31,6 @@ func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.Notific arg.ProviderID, arg.IncidentID, arg.Message, - arg.Status, arg.ErrorMessage, ) var i models.NotificationHistory @@ -42,8 +40,13 @@ func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.Notific &i.IncidentID, &i.Message, &i.Status, + &i.Response, + &i.Attempts, &i.ErrorMessage, + &i.LastAttemptAt, + &i.SentAt, &i.CreatedAt, + &i.UpdatedAt, ) return &i, err } @@ -56,22 +59,3 @@ func (q *Queries) Delete(ctx context.Context, id string) error { _, err := q.db.ExecContext(ctx, delete, id) return err } - -const getByID = `-- name: GetByID :one -SELECT id, provider_id, incident_id, message, status, error_message, created_at FROM notification_history WHERE id=? LIMIT 1 -` - -func (q *Queries) GetByID(ctx context.Context, id string) (*models.NotificationHistory, error) { - row := q.db.QueryRowContext(ctx, getByID, id) - var i models.NotificationHistory - err := row.Scan( - &i.ID, - &i.ProviderID, - &i.IncidentID, - &i.Message, - &i.Status, - &i.ErrorMessage, - &i.CreatedAt, - ) - return &i, err -} diff --git a/internal/store/repos/repo_notification_history/querier.go b/internal/store/repos/repo_notification_history/querier.go index 61d7843..dc0dca7 100644 --- a/internal/store/repos/repo_notification_history/querier.go +++ b/internal/store/repos/repo_notification_history/querier.go @@ -13,7 +13,9 @@ import ( type Querier interface { Create(ctx context.Context, arg CreateParams) (*models.NotificationHistory, error) Delete(ctx context.Context, id string) error - GetByID(ctx context.Context, id string) (*models.NotificationHistory, error) + GetAllUnsent(ctx context.Context) ([]*GetAllUnsentRow, error) + IncrementAttempt(ctx context.Context, arg IncrementAttemptParams) error + MarkAsSent(ctx context.Context, response *string, iD string) error } var _ Querier = (*Queries)(nil) diff --git a/internal/store/repos/repo_notification_providers/notification_providers.sql.go b/internal/store/repos/repo_notification_providers/notification_providers.sql.go new file mode 100644 index 0000000..b0b0be3 --- /dev/null +++ b/internal/store/repos/repo_notification_providers/notification_providers.sql.go @@ -0,0 +1,74 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.30.0 +// source: notification_providers.sql + +package repo_notification_providers + +import ( + "context" + + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/storecmn" +) + +const getAllEnabled = `-- name: GetAllEnabled :many +SELECT id, provider_type, json(config), is_enabled, created_at, updated_at FROM notification_providers WHERE is_enabled=true +` + +func (q *Queries) GetAllEnabled(ctx context.Context) ([]*models.NotificationProvider, error) { + rows, err := q.db.QueryContext(ctx, getAllEnabled) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*models.NotificationProvider{} + for rows.Next() { + var i models.NotificationProvider + if err := rows.Scan( + &i.ID, + &i.ProviderType, + &i.Config, + &i.IsEnabled, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const update = `-- name: Update :exec +UPDATE notification_providers + SET + provider_type = ?, + config = ?, + is_enabled = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ? +` + +type UpdateParams struct { + ProviderType models.NotificationProviderType `db:"provider_type" json:"provider_type"` + Config storecmn.JSONField `db:"config" json:"config"` + IsEnabled bool `db:"is_enabled" json:"is_enabled"` + ID string `db:"id" json:"id"` +} + +func (q *Queries) Update(ctx context.Context, arg UpdateParams) error { + _, err := q.db.ExecContext(ctx, update, + arg.ProviderType, + arg.Config, + arg.IsEnabled, + arg.ID, + ) + return err +} diff --git a/internal/store/repos/repo_notification_providers/notification_providers_gen.sql.go b/internal/store/repos/repo_notification_providers/notification_providers_gen.sql.go index 3dceb9c..093843c 100644 --- a/internal/store/repos/repo_notification_providers/notification_providers_gen.sql.go +++ b/internal/store/repos/repo_notification_providers/notification_providers_gen.sql.go @@ -13,25 +13,19 @@ import ( ) const create = `-- name: Create :one -INSERT INTO notification_providers (id, provider_type, config, is_enabled) - VALUES (?, ?, ?, ?) +INSERT INTO notification_providers (id, provider_type, config) + VALUES (?, ?, ?) RETURNING id, provider_type, json(config), is_enabled, created_at, updated_at ` type CreateParams struct { - ID string `db:"id" json:"id"` - ProviderType string `db:"provider_type" json:"provider_type"` - Config storecmn.JSONField `db:"config" json:"config"` - IsEnabled bool `db:"is_enabled" json:"is_enabled"` + ID string `db:"id" json:"id"` + ProviderType models.NotificationProviderType `db:"provider_type" json:"provider_type"` + Config storecmn.JSONField `db:"config" json:"config"` } func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.NotificationProvider, error) { - row := q.db.QueryRowContext(ctx, create, - arg.ID, - arg.ProviderType, - arg.Config, - arg.IsEnabled, - ) + row := q.db.QueryRowContext(ctx, create, arg.ID, arg.ProviderType, arg.Config) var i models.NotificationProvider err := row.Scan( &i.ID, @@ -53,6 +47,40 @@ func (q *Queries) Delete(ctx context.Context, id string) error { return err } +const getAll = `-- name: GetAll :many +SELECT id, provider_type, json(config), is_enabled, created_at, updated_at FROM notification_providers +` + +func (q *Queries) GetAll(ctx context.Context) ([]*models.NotificationProvider, error) { + rows, err := q.db.QueryContext(ctx, getAll) + if err != nil { + return nil, err + } + defer rows.Close() + items := []*models.NotificationProvider{} + for rows.Next() { + var i models.NotificationProvider + if err := rows.Scan( + &i.ID, + &i.ProviderType, + &i.Config, + &i.IsEnabled, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, &i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getByID = `-- name: GetByID :one SELECT id, provider_type, json(config), is_enabled, created_at, updated_at FROM notification_providers WHERE id=? LIMIT 1 ` diff --git a/internal/store/repos/repo_notification_providers/querier.go b/internal/store/repos/repo_notification_providers/querier.go index 23af2f0..eb39d85 100644 --- a/internal/store/repos/repo_notification_providers/querier.go +++ b/internal/store/repos/repo_notification_providers/querier.go @@ -13,7 +13,10 @@ import ( type Querier interface { Create(ctx context.Context, arg CreateParams) (*models.NotificationProvider, error) Delete(ctx context.Context, id string) error + GetAll(ctx context.Context) ([]*models.NotificationProvider, error) + GetAllEnabled(ctx context.Context) ([]*models.NotificationProvider, error) GetByID(ctx context.Context, id string) (*models.NotificationProvider, error) + Update(ctx context.Context, arg UpdateParams) error } var _ Querier = (*Queries)(nil) diff --git a/internal/store/storecmn/json.go b/internal/store/storecmn/json.go index f4804d1..3c0b729 100644 --- a/internal/store/storecmn/json.go +++ b/internal/store/storecmn/json.go @@ -26,7 +26,7 @@ func (j *JSONField) UnmarshalJSON(data []byte) error { } // Unmarshal any to JSONField -func (j *JSONField) UnmarshalAny(value any) error { +func (j *JSONField) UnmarshalFromAny(value any) error { if value == nil { *j = nil return nil @@ -76,7 +76,7 @@ func (j JSONField) Value() (driver.Value, error) { return string(j), nil } -// ToMap converts JSONField to map[string]any +// ConvertToMap converts JSONField to map[string]any func (j JSONField) ConvertToMap() map[string]any { if len(j) == 0 { return map[string]any{} @@ -87,3 +87,11 @@ func (j JSONField) ConvertToMap() map[string]any { } return result } + +// ConvertToAny converts JSONField to any +func (j JSONField) ConvertToAny(dst any) error { + if len(j) == 0 { + return nil + } + return json.Unmarshal(j, dst) +} diff --git a/internal/utils/duration.go b/internal/utils/duration.go new file mode 100644 index 0000000..a4f35c2 --- /dev/null +++ b/internal/utils/duration.go @@ -0,0 +1,24 @@ +package utils + +import ( + "fmt" + "time" +) + +// FormatDuration formats a duration in a human-readable way +func FormatDuration(d time.Duration) string { + if d < time.Second { + return fmt.Sprintf("%dms", int(d.Milliseconds())) + } + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + minutes := int(d.Minutes()) + seconds := int(d.Seconds()) % 60 + return fmt.Sprintf("%dm %ds", minutes, seconds) + } + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + return fmt.Sprintf("%dh %dm", hours, minutes) +} diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 64ef6e9..9000625 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -186,6 +186,16 @@ func (s *Server) setupRoutes() { api.Get("/tags", s.handleGetAllTags) api.Get("/tags/count", s.handleGetAllTagsWithCount) + // Settings API + settingsGroup := api.Group("/settings") + notificationsGroup := settingsGroup.Group("/notifications") + notificationsProviderGroup := notificationsGroup.Group("/providers") + notificationsProviderGroup.Get("/", s.notificationProviderList) + notificationsProviderGroup.Post("/", s.notificationProviderCreate) + notificationsProviderGroup.Put("/:id", s.notificationProviderUpdate) + notificationsProviderGroup.Delete("/:id", s.notificationProviderDelete) + notificationsProviderGroup.Post("/:id/test", s.notificationProviderTest) + // Server API serverGroup := api.Group("/server") serverGroup.Get("/info", s.handleAPIInfo) @@ -396,16 +406,16 @@ func (s *Server) handleAPIServiceDetail(c *fiber.Ctx) error { // @Tags incidents // @Accept json // @Produce json -// @Param id path string true "Service ID" -// @Param incident_id query string false "Filter by incident ID" -// @Param resolved query bool false "Filter by resolved status" -// @Param start_time query time.Time false "Filter by start time (RFC3339 format)" -// @Param end_time query time.Time false "Filter by end time (RFC3339 format)" -// @Param page query uint32 false "Page number (for pagination)" -// @Param page_size query uint32 false "Number of items per page (default 20)" -// @Success 200 {object} storecmn.FindResponseWithCount[storage.Incident] "List of incidents" -// @Failure 400 {object} ErrorResponse "Bad request" -// @Failure 500 {object} ErrorResponse "Internal server error" +// @Param id path string true "Service ID" +// @Param incident_id query string false "Filter by incident ID" +// @Param resolved query bool false "Filter by resolved status" +// @Param start_time query time.Time false "Filter by start time (RFC3339 format)" +// @Param end_time query time.Time false "Filter by end time (RFC3339 format)" +// @Param page query uint32 false "Page number (for pagination)" +// @Param page_size query uint32 false "Number of items per page (default 20)" +// @Success 200 {object} storecmn.FindResponseWithCount[models.Incident] "List of incidents" +// @Failure 400 {object} ErrorResponse "Bad request" +// @Failure 500 {object} ErrorResponse "Internal server error" // @Router /services/{id}/incidents [get] func (s *Server) handleAPIServiceIncidents(c *fiber.Ctx) error { serviceID := c.Params("id") @@ -549,14 +559,14 @@ func (s *Server) handleAPIServiceCheck(c *fiber.Ctx) error { // @Tags incidents // @Accept json // @Produce json -// @Param search query string false "Filter by service ID or incident ID" -// @Param resolved query bool false "Filter by resolved status" -// @Param start_time query time.Time false "Start time for filtering (RFC3339 format)" -// @Param end_time query time.Time false "End time for filtering (RFC3339 format)" -// @Param page query uint32 false "Page number (default 1)" -// @Param page_size query uint32 false "Number of items per page (default 100)" -// @Success 200 {object} storecmn.FindResponseWithCount[storage.Incident] "List of incidents" -// @Failure 500 {object} ErrorResponse "Internal server error" +// @Param search query string false "Filter by service ID or incident ID" +// @Param resolved query bool false "Filter by resolved status" +// @Param start_time query time.Time false "Start time for filtering (RFC3339 format)" +// @Param end_time query time.Time false "End time for filtering (RFC3339 format)" +// @Param page query uint32 false "Page number (default 1)" +// @Param page_size query uint32 false "Number of items per page (default 100)" +// @Success 200 {object} storecmn.FindResponseWithCount[models.Incident] "List of incidents" +// @Failure 500 {object} ErrorResponse "Internal server error" // @Router /incidents [get] func (s *Server) handleFindIncidents(c *fiber.Ctx) error { params := struct { @@ -716,7 +726,6 @@ func (s *Server) handleAPICreateService(c *fiber.Ctx) error { return newErrorResponse(c, fiber.StatusBadRequest, err) } - // Convert to storage.Service createParams := service.CreateUpdateParams{ Name: serviceDTO.Name, Protocol: serviceDTO.Protocol, @@ -783,7 +792,6 @@ func (s *Server) handleAPIUpdateService(c *fiber.Ctx) error { // Debug: log the received data s.logger.Debugf("update service request: %+v", serviceDTO) - // Convert to storage.Service updateParams := service.CreateUpdateParams{ Name: serviceDTO.Name, Protocol: serviceDTO.Protocol, diff --git a/internal/web/notifications.go b/internal/web/notifications.go new file mode 100644 index 0000000..b7b7f2e --- /dev/null +++ b/internal/web/notifications.go @@ -0,0 +1,136 @@ +package web + +import ( + "github.com/gofiber/fiber/v2" + "github.com/sxwebdev/sentinel/internal/services/notifications" + "github.com/sxwebdev/sentinel/internal/store/storecmn" +) + +// notificationProviderCreate creates a new notification provider +// +// @Summary Create new notification provider +// @Description Creates a new notification provider with the given configuration. +// @Tags notifications +// @Accept json +// @Produce json +// @Param service body notifications.CreateProviderParams true "Body params" +// @Success 201 {object} models.NotificationProvider "Notification provider created" +// @Failure 400 {object} ErrorResponse "Bad request" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /settings/notifications/providers [post] +func (s *Server) notificationProviderCreate(c *fiber.Ctx) error { + var data notifications.CreateProviderParams + if err := c.BodyParser(&data); err != nil { + return newErrorResponse(c, fiber.StatusBadRequest, err) + } + + // Add service + svc, err := s.baseServices.Notifications().Providers().Create(c.Context(), data) + if err != nil { + return newErrorResponse(c, fiber.StatusBadRequest, err) + } + + return c.Status(fiber.StatusCreated).JSON(svc) +} + +// notificationProviderUpdate updates a notification provider by ID +// +// @Summary Update a notification provider +// @Description Updates a notification provider with the given ID and configuration. +// @Tags notifications +// @Accept json +// @Produce json +// @Param id path string true "Notification Provider ID" +// @Param service body notifications.UpdateProviderParams true "Body params" +// @Success 200 {object} models.NotificationProvider "Notification provider updated" +// @Failure 400 {object} ErrorResponse "Bad request" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /settings/notifications/providers/{id} [put] +func (s *Server) notificationProviderUpdate(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return newErrorResponse(c, fiber.StatusBadRequest, storecmn.ErrEmptyID) + } + + var data notifications.UpdateProviderParams + if err := c.BodyParser(&data); err != nil { + return newErrorResponse(c, fiber.StatusBadRequest, err) + } + + // Update service + svc, err := s.baseServices.Notifications().Providers().Update(c.Context(), id, data) + if err != nil { + return newErrorResponse(c, fiber.StatusBadRequest, err) + } + + return c.JSON(svc) +} + +// notificationProviderList lists all notification providers +// +// @Summary List all notification providers +// @Description Retrieves a list of all configured notification providers. +// @Tags notifications +// @Accept json +// @Produce json +// @Success 200 {array} models.NotificationProvider "List of notification providers" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /settings/notifications/providers [get] +func (s *Server) notificationProviderList(c *fiber.Ctx) error { + providers, err := s.baseServices.Notifications().Providers().GetAll(c.Context()) + if err != nil { + return newErrorResponse(c, fiber.StatusInternalServerError, err) + } + + return c.JSON(providers) +} + +// notificationProviderDelete deletes a notification provider by ID +// +// @Summary Delete a notification provider +// @Description Deletes a notification provider by its ID. +// @Tags notifications +// @Accept json +// @Produce json +// @Param id path string true "Notification Provider ID" +// @Success 204 +// @Failure 400 {object} ErrorResponse "Bad request" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /settings/notifications/providers/{id} [delete] +func (s *Server) notificationProviderDelete(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return newErrorResponse(c, fiber.StatusBadRequest, storecmn.ErrEmptyID) + } + + if err := s.baseServices.Notifications().Providers().Delete(c.Context(), id); err != nil { + return newErrorResponse(c, fiber.StatusInternalServerError, err) + } + + return c.SendStatus(fiber.StatusNoContent) +} + +// notificationProviderTest tests a notification provider by ID +// +// @Summary Test a notification provider +// @Description Sends a test notification using the specified provider ID. +// @Tags notifications +// @Accept json +// @Produce json +// @Param id path string true "Notification Provider ID" +// @Success 200 +// @Failure 400 {object} ErrorResponse "Bad request" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /settings/notifications/providers/{id}/test [post] +func (s *Server) notificationProviderTest(c *fiber.Ctx) error { + id := c.Params("id") + if id == "" { + return newErrorResponse(c, fiber.StatusBadRequest, storecmn.ErrEmptyID) + } + + if err := s.baseServices.Notifications().Providers().Test(c.Context(), id); err != nil { + return newErrorResponse(c, fiber.StatusInternalServerError, err) + } + + return c.SendStatus(fiber.StatusOK) +} diff --git a/pgxgen.yaml b/pgxgen.yaml index c781d51..6e719b5 100644 --- a/pgxgen.yaml +++ b/pgxgen.yaml @@ -13,6 +13,8 @@ sqlc: go_type: ServiceProtocolType - path: github.com/sxwebdev/sentinel/internal/models go_type: ServiceStatus + - path: github.com/sxwebdev/sentinel/internal/models + go_type: NotificationProviderType crud: auto_remove_generated_files: true exclude_table_name_from_methods: true @@ -111,12 +113,15 @@ sqlc: methods: create: skip_columns: + - is_enabled - created_at - updated_at returning: "*" delete: get: name: GetByID + find: + name: GetAll # notification_history notification_history: @@ -125,12 +130,16 @@ sqlc: methods: create: skip_columns: + - status + - response + - attempts + - last_sending_error_message + - last_attempt_at + - sent_at - created_at - updated_at returning: "*" delete: - get: - name: GetByID constants: tables: diff --git a/pkg/loop/loop.go b/pkg/loop/loop.go new file mode 100644 index 0000000..e09e752 --- /dev/null +++ b/pkg/loop/loop.go @@ -0,0 +1,120 @@ +package loop + +import ( + "context" + "runtime/debug" + "sync" + "sync/atomic" + "time" +) + +type Loop struct { + options Options + + running atomic.Bool + stopped atomic.Bool + wg sync.WaitGroup + cancel context.CancelFunc + + fn func(context.Context) +} + +func New(fn func(context.Context), opts ...Option) *Loop { + if fn == nil { + panic("function cannot be nil") + } + + options := Options{ + period: time.Second * 60, + contextTimeout: time.Second * 30, + logger: &emptyLogger{}, + } + + for _, o := range opts { + o(&options) + } + + return &Loop{ + options: options, + fn: fn, + } +} + +func (l *Loop) Start(parent context.Context) { + ctx, cancel := context.WithCancel(parent) + l.cancel = cancel + + // Run immediately if leading is true + if l.options.leading { + l.tryRun(ctx, l.fn) + } + + l.wg.Go(func() { + t := time.NewTicker(l.options.period) + defer t.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-t.C: + if l.stopped.Load() { + return + } + l.tryRun(ctx, l.fn) + } + } + }) +} + +// Trigger triggers the loop to run the provided function immediately if not already running +func (l *Loop) Trigger(ctx context.Context) bool { + return l.tryRun(ctx, l.fn) +} + +// Stop stops the loop +func (l *Loop) Stop() { + if l.stopped.Swap(true) { + return + } + if l.cancel != nil { + l.cancel() + } +} + +// Wait waits for all running operations to complete (graceful shutdown) +func (l *Loop) Wait() { + l.wg.Wait() +} + +// tryRun tries to run a function within the loop's context +func (l *Loop) tryRun(parent context.Context, fn func(context.Context)) bool { + if l.stopped.Load() { + return false + } + if !l.running.CompareAndSwap(false, true) { + return false + } + + l.wg.Go(func() { + now := time.Now() + defer func() { + if r := recover(); r != nil { + l.options.logger.Errorf("panic recovered in loop: %v\n%s", r, debug.Stack()) + } + l.running.Store(false) + l.options.logger.Debugf("loop iteration took %s", time.Since(now)) + }() + + ctx := parent + var cancel context.CancelFunc + if l.options.contextTimeout > 0 { + ctx, cancel = context.WithTimeout(parent, l.options.contextTimeout) + defer cancel() + } + + fn(ctx) + }) + + return true +} diff --git a/pkg/loop/options.go b/pkg/loop/options.go new file mode 100644 index 0000000..02861e7 --- /dev/null +++ b/pkg/loop/options.go @@ -0,0 +1,46 @@ +package loop + +import "time" + +type Options struct { + logger logger + leading bool + period time.Duration + contextTimeout time.Duration +} + +type Option func(*Options) + +// WithLogger sets the logger for the loop. +func WithLogger(logger logger) Option { + return func(o *Options) { + if logger == nil { + return + } + o.logger = logger + } +} + +// WithLeading sets the loop to execute the task immediately upon starting. +func WithLeading() Option { + return func(o *Options) { + o.leading = true + } +} + +// WithPeriod sets the period for the loop execution. +func WithPeriod(d time.Duration) Option { + return func(o *Options) { + if d <= 0 { + return + } + o.period = d + } +} + +// WithContextTimeout sets a timeout for the context passed to the task function. +func WithContextTimeout(d time.Duration) Option { + return func(o *Options) { + o.contextTimeout = d + } +} diff --git a/pkg/loop/type.go b/pkg/loop/type.go new file mode 100644 index 0000000..d357aba --- /dev/null +++ b/pkg/loop/type.go @@ -0,0 +1,13 @@ +package loop + +type logger interface { + Debugf(format string, args ...any) + Errorf(format string, args ...any) +} + +type emptyLogger struct{} + +func (l *emptyLogger) Debugf(format string, args ...any) {} +func (l *emptyLogger) Errorf(format string, args ...any) {} + +var _ logger = (*emptyLogger)(nil) diff --git a/pkg/migrations/cli.go b/pkg/migrations/cli.go index cf8232a..e80f44f 100644 --- a/pkg/migrations/cli.go +++ b/pkg/migrations/cli.go @@ -71,6 +71,22 @@ func CliCmd(l logger, fs embed.FS, migrationsPath string) *cli.Command { return nil }, }, + { + Name: "up", + Usage: "apply the next database migrations", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "db-path", + Aliases: []string{"dp"}, + Usage: "path to the SQLite database file", + Required: true, + }, + }, + Action: func(ctx context.Context, cl *cli.Command) error { + m := New(l, fs, migrationsPath) + return m.MigrateUpAll(cl.String("db-path")) + }, + }, { Name: "down", Usage: "roll back the last database migration", diff --git a/sql/migrations/2_agents.down.sql b/sql/migrations/2_agents.down.sql index b2febd2..3c69244 100644 --- a/sql/migrations/2_agents.down.sql +++ b/sql/migrations/2_agents.down.sql @@ -1 +1,19 @@ -DROP TABLE IF EXISTS agents CASCADE; +-- Drop notification history table +DROP TABLE IF EXISTS notification_history; + +-- Drop notification providers table +DROP TABLE IF EXISTS notification_providers; + +-- Drop incident states table +DROP TABLE IF EXISTS incident_states; + +-- Revert service_states response_time back to response_time_ns (from milliseconds to nanoseconds) +ALTER TABLE service_states RENAME COLUMN response_time TO response_time_ns; +UPDATE service_states SET response_time_ns = response_time_ns * 1000000 WHERE response_time_ns IS NOT NULL AND response_time_ns > 0; + +-- Revert incidents duration back to duration_ns (from milliseconds to nanoseconds) +ALTER TABLE incidents RENAME COLUMN duration TO duration_ns; +UPDATE incidents SET duration_ns = duration_ns * 1000000 WHERE duration_ns IS NOT NULL AND duration_ns > 0; + +-- Drop agents table +DROP TABLE IF EXISTS agents; diff --git a/sql/migrations/2_agents.up.sql b/sql/migrations/2_agents.up.sql index 8aecf10..e889ca6 100644 --- a/sql/migrations/2_agents.up.sql +++ b/sql/migrations/2_agents.up.sql @@ -15,13 +15,11 @@ CREATE TABLE IF NOT EXISTS agents ( config jsonb NOT NULL DEFAULT '{}', system_info jsonb NOT NULL DEFAULT '{}', last_seen_at DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE(token_ct), UNIQUE(fingerprint) ); - --- Create indexes for performance CREATE INDEX IF NOT EXISTS idx_agents_name ON agents(name); CREATE INDEX IF NOT EXISTS idx_agents_enabled ON agents(is_enabled); @@ -39,8 +37,8 @@ CREATE TABLE IF NOT EXISTS incident_states ( incident_id TEXT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE, "status" TEXT NOT NULL CHECK (status != ''), level INTEGER NOT NULL DEFAULT 0, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_incident_states_incident_id ON incident_states(incident_id); CREATE INDEX IF NOT EXISTS idx_incident_states_status ON incident_states(status); @@ -51,8 +49,8 @@ CREATE TABLE IF NOT EXISTS notification_providers ( provider_type TEXT NOT NULL, config jsonb NOT NULL DEFAULT '{}', is_enabled BOOLEAN NOT NULL DEFAULT TRUE, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_notification_providers_enabled ON notification_providers(is_enabled); @@ -60,10 +58,16 @@ CREATE INDEX IF NOT EXISTS idx_notification_providers_enabled ON notification_pr CREATE TABLE IF NOT EXISTS notification_history ( id TEXT PRIMARY KEY, provider_id TEXT NOT NULL REFERENCES notification_providers(id) ON DELETE CASCADE, - incident_id TEXT REFERENCES incidents(id) ON DELETE DELETE SET NULL, + incident_id TEXT REFERENCES incidents(id) ON DELETE SET NULL, message TEXT NOT NULL CHECK (message != ''), "status" TEXT NOT NULL DEFAULT 'pending' CHECK (status != ''), + response TEXT, + attempts INTEGER NOT NULL DEFAULT 0, error_message TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP + last_attempt_at DATETIME, + sent_at DATETIME, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE INDEX IF NOT EXISTS idx_notification_history_provider ON notification_history(provider_id); +CREATE INDEX IF NOT EXISTS idx_notification_history_status ON notification_history(status); diff --git a/sql/queries/notification_history/notification_history.sql b/sql/queries/notification_history/notification_history.sql new file mode 100755 index 0000000..88a8e21 --- /dev/null +++ b/sql/queries/notification_history/notification_history.sql @@ -0,0 +1,34 @@ +-- name: GetAllUnsent :many +SELECT + h.*, + p.provider_type, + p.config + FROM notification_history h + LEFT JOIN notification_providers p ON p.id = h.provider_id + WHERE + h.status != 'sent' AND + p.is_enabled = true + ORDER BY h.created_at ASC + LIMIT 100; + +-- name: IncrementAttempt :exec +UPDATE notification_history + SET + response = ?, + attempts = attempts + 1, + error_message = ?, + last_attempt_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?; + +-- name: MarkAsSent :exec +UPDATE notification_history + SET + status = 'sent', + response = ?, + attempts = attempts + 1, + error_message = NULL, + last_attempt_at = CURRENT_TIMESTAMP, + sent_at = CURRENT_TIMESTAMP, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?; diff --git a/sql/queries/notification_history/notification_history_gen.sql b/sql/queries/notification_history/notification_history_gen.sql index 881810b..47f6bec 100755 --- a/sql/queries/notification_history/notification_history_gen.sql +++ b/sql/queries/notification_history/notification_history_gen.sql @@ -1,11 +1,8 @@ -- name: Create :one -INSERT INTO notification_history (id, provider_id, incident_id, message, status, error_message) - VALUES (?, ?, ?, ?, ?, ?) +INSERT INTO notification_history (id, provider_id, incident_id, message, error_message) + VALUES (?, ?, ?, ?, ?) RETURNING *; -- name: Delete :exec DELETE FROM notification_history WHERE id=?; --- name: GetByID :one -SELECT * FROM notification_history WHERE id=? LIMIT 1; - diff --git a/sql/queries/notification_providers/notification_providers.sql b/sql/queries/notification_providers/notification_providers.sql new file mode 100755 index 0000000..5007909 --- /dev/null +++ b/sql/queries/notification_providers/notification_providers.sql @@ -0,0 +1,11 @@ +-- name: GetAllEnabled :many +SELECT * FROM notification_providers WHERE is_enabled=true; + +-- name: Update :exec +UPDATE notification_providers + SET + provider_type = ?, + config = ?, + is_enabled = ?, + updated_at = CURRENT_TIMESTAMP + WHERE id = ?; diff --git a/sql/queries/notification_providers/notification_providers_gen.sql b/sql/queries/notification_providers/notification_providers_gen.sql index ab5662c..6115e23 100755 --- a/sql/queries/notification_providers/notification_providers_gen.sql +++ b/sql/queries/notification_providers/notification_providers_gen.sql @@ -1,11 +1,14 @@ -- name: Create :one -INSERT INTO notification_providers (id, provider_type, config, is_enabled) - VALUES (?, ?, ?, ?) +INSERT INTO notification_providers (id, provider_type, config) + VALUES (?, ?, ?) RETURNING *; -- name: Delete :exec DELETE FROM notification_providers WHERE id=?; +-- name: GetAll :many +SELECT * FROM notification_providers; + -- name: GetByID :one SELECT * FROM notification_providers WHERE id=? LIMIT 1; diff --git a/sqlc.yaml b/sqlc.yaml index 630cb88..5949bef 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -2,11 +2,12 @@ version: "2" overrides: go: overrides: - # Services - db_type: jsonb go_type: type: JSONField import: github.com/sxwebdev/sentinel/internal/store/storecmn + + # Services - column: services.protocol go_type: type: ServiceProtocolType @@ -24,10 +25,20 @@ overrides: go_type: type: ServiceStatus + # notification_providers + - column: notification_providers.provider_type + go_type: + type: NotificationProviderType + - column: notification_providers.config + go_type: + type: JSONField + import: github.com/sxwebdev/sentinel/internal/store/storecmn + # notification_history - column: notification_history.incident_id go_type: type: "*string" + sql: # Services - schema: sql/migrations @@ -147,7 +158,7 @@ sql: emit_params_struct_pointers: false emit_enum_valid_method: true emit_all_enum_values: true - query_parameter_limit: 3 + query_parameter_limit: 2 # notification_history - schema: sql/migrations @@ -167,4 +178,4 @@ sql: emit_params_struct_pointers: false emit_enum_valid_method: true emit_all_enum_values: true - query_parameter_limit: 3 + query_parameter_limit: 2 From e5e769fe3adc2ebe4578fe2a21fc872dc859de7c Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Tue, 23 Sep 2025 00:56:21 +0300 Subject: [PATCH 18/71] feat: Enhance notification sending logic to skip previously failed incidents and improve duration formatting --- internal/services/notifications/sender.go | 23 ++++++++++---- internal/store/repos/repo_incidents/find.go | 2 +- internal/utils/duration.go | 35 ++++++++++++++++++--- 3 files changed, 49 insertions(+), 11 deletions(-) diff --git a/internal/services/notifications/sender.go b/internal/services/notifications/sender.go index 2257c0b..bfd1caa 100644 --- a/internal/services/notifications/sender.go +++ b/internal/services/notifications/sender.go @@ -73,7 +73,16 @@ func (s *Sender) do(ctx context.Context) error { s.logger.Infof("found %d unsent notification history items", len(items)) + unsentIncidents := make(map[string]struct{}) + for _, item := range items { + if item.IncidentID != nil && *item.IncidentID != "" { + if _, exists := unsentIncidents[*item.IncidentID]; exists { + s.logger.Infof("skipping notification %s for incident %s as previous attempt failed", item.ID, *item.IncidentID) + continue + } + } + var err error switch item.ProviderType { case models.NotificationProviderTypeShoutrrr: @@ -84,12 +93,16 @@ func (s *Sender) do(ctx context.Context) error { } if err != nil { - createErr := s.store.NotificationHistory().IncrementAttempt(ctx, repo_notification_history.IncrementAttemptParams{ + incrementErr := s.store.NotificationHistory().IncrementAttempt(ctx, repo_notification_history.IncrementAttemptParams{ ID: item.ID, ErrorMessage: utils.Pointer(err.Error()), }) - if createErr != nil { - return createErr + if incrementErr != nil { + return incrementErr + } + + if item.IncidentID != nil && *item.IncidentID != "" { + unsentIncidents[*item.IncidentID] = struct{}{} } s.logger.Errorf("failed to send notification %s: %v", item.ID, err) @@ -126,10 +139,8 @@ func (s *Sender) processShoutrrr(ctx context.Context, item *repo_notification_hi select { case <-ctx.Done(): - errCh <- ctx.Err() + return ctx.Err() case err := <-errCh: return err } - - return nil } diff --git a/internal/store/repos/repo_incidents/find.go b/internal/store/repos/repo_incidents/find.go index a5fd8ca..64ec118 100644 --- a/internal/store/repos/repo_incidents/find.go +++ b/internal/store/repos/repo_incidents/find.go @@ -62,7 +62,7 @@ func findBuilder(params FindParams, col ...string) *sqlbuilder.SelectBuilder { func (s *CustomQueries) Find(ctx context.Context, params FindParams) (*storecmn.FindResponseWithCount[*models.Incident], error) { sb := findBuilder(params, IncidentsColumnNames().Strings()...) - sb.OrderBy("start_time").Desc() + sb.OrderBy("created_at").Desc() limit, offset, err := storecmn.Pagination(params.Page, params.PageSize) if err != nil { diff --git a/internal/utils/duration.go b/internal/utils/duration.go index a4f35c2..9f38019 100644 --- a/internal/utils/duration.go +++ b/internal/utils/duration.go @@ -8,17 +8,44 @@ import ( // FormatDuration formats a duration in a human-readable way func FormatDuration(d time.Duration) string { if d < time.Second { - return fmt.Sprintf("%dms", int(d.Milliseconds())) + return fmt.Sprintf("%dms", d.Milliseconds()) } + if d < time.Minute { return fmt.Sprintf("%ds", int(d.Seconds())) } + if d < time.Hour { minutes := int(d.Minutes()) seconds := int(d.Seconds()) % 60 - return fmt.Sprintf("%dm %ds", minutes, seconds) + if seconds > 0 { + return fmt.Sprintf("%dm %ds", minutes, seconds) + } + return fmt.Sprintf("%dm", minutes) } - hours := int(d.Hours()) + + if d < 24*time.Hour { + hours := int(d.Hours()) + minutes := int(d.Minutes()) % 60 + if minutes > 0 { + return fmt.Sprintf("%dh %dm", hours, minutes) + } + return fmt.Sprintf("%dh", hours) + } + + // For durations >= 24 hours + totalHours := int(d.Hours()) + days := totalHours / 24 + hours := totalHours % 24 minutes := int(d.Minutes()) % 60 - return fmt.Sprintf("%dh %dm", hours, minutes) + + if minutes > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) + } + + if hours > 0 { + return fmt.Sprintf("%dd %dh", days, hours) + } + + return fmt.Sprintf("%dd", days) } From 784f8a33fb0eeb6e3cf554946e0e85e5513a4841 Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Tue, 23 Sep 2025 01:02:37 +0300 Subject: [PATCH 19/71] refactor: Update incident types to use ModelsIncident for consistency across components --- .../src/pages/service/components/incidentsList.tsx | 10 +++++----- .../src/pages/service/store/useServiceDeteilStore.ts | 12 ++++++------ frontend/src/routes/incidents.tsx | 8 ++++---- 3 files changed, 15 insertions(+), 15 deletions(-) diff --git a/frontend/src/pages/service/components/incidentsList.tsx b/frontend/src/pages/service/components/incidentsList.tsx index f0e1027..cb2f9df 100644 --- a/frontend/src/pages/service/components/incidentsList.tsx +++ b/frontend/src/pages/service/components/incidentsList.tsx @@ -17,17 +17,17 @@ import { ExpandableText } from "@/shared/components/expandableText"; import { formatDuration } from "@/shared/utils"; import PaginationTable from "@/shared/components/paginationTable"; import type { - StorecmnFindResponseWithCountStorageIncident, + StorecmnFindResponseWithCountModelsIncident, GetServicesIdIncidentsParams, - StorageIncident, + ModelsIncident, } from "@/shared/types/model"; interface IncidentsListProps { - incidentsData: StorecmnFindResponseWithCountStorageIncident; + incidentsData: StorecmnFindResponseWithCountModelsIncident; incidentsCount: number | null; filters: GetServicesIdIncidentsParams; setFilters: (filters: Partial) => void; - setDeleteIncident: (incident: StorageIncident) => void; + setDeleteIncident: (incident: ModelsIncident) => void; } export const IncidentsList = ({ @@ -71,7 +71,7 @@ export const IncidentsList = ({
) : (
- {incidentsData?.items?.map((incident: StorageIncident) => ( + {incidentsData?.items?.map((incident: ModelsIncident) => (
) => void; - setDeleteIncident: (deleteIncident: StorageIncident | null) => void; + setDeleteIncident: (deleteIncident: ModelsIncident | null) => void; setServiceDetailData: (serviceDetailData: WebServiceDTO | null) => void; setIncidentsData: ( - incidentsData: StorecmnFindResponseWithCountStorageIncident | null, + incidentsData: StorecmnFindResponseWithCountModelsIncident | null, ) => void; setServiceStatsData: (serviceStatsData: ServiceStats | null) => void; setUpdateServiceStatsData: (serviceStatsData: ServiceStats | null) => void; diff --git a/frontend/src/routes/incidents.tsx b/frontend/src/routes/incidents.tsx index 46dd902..ac856ea 100644 --- a/frontend/src/routes/incidents.tsx +++ b/frontend/src/routes/incidents.tsx @@ -16,9 +16,9 @@ import { } from "@/shared/components/ui"; import { cn } from "@/shared/lib/utils"; import type { - StorecmnFindResponseWithCountStorageIncident, + StorecmnFindResponseWithCountModelsIncident, GetIncidentsParams, - StorageIncident, + ModelsIncident, WebErrorResponse, } from "@/shared/types/model"; import { formatDuration } from "@/shared/utils/duration"; @@ -49,7 +49,7 @@ function RouteComponent() { } interface IncidentsListProps { - incidentsData: StorecmnFindResponseWithCountStorageIncident; + incidentsData: StorecmnFindResponseWithCountModelsIncident; } export const IncidentsList = ({ incidentsData }: IncidentsListProps) => { @@ -113,7 +113,7 @@ export const IncidentsList = ({ incidentsData }: IncidentsListProps) => {
) : (
- {incidentsData?.items?.map((incident: StorageIncident) => ( + {incidentsData?.items?.map((incident: ModelsIncident) => (
Date: Tue, 23 Sep 2025 01:34:50 +0300 Subject: [PATCH 20/71] feat: Add notification history endpoint with filtering and pagination support --- docs/docsv1/docs.go | 122 ++++++++++++++++++ docs/docsv1/swagger.json | 122 ++++++++++++++++++ docs/docsv1/swagger.yaml | 81 ++++++++++++ .../api/gen/notifications/notifications.ts | 21 +++ .../getSettingsNotificationsHistoryParams.ts | 26 ++++ frontend/src/shared/types/model/index.ts | 3 + .../model/modelsNotificationHistoryView.ts | 24 ++++ ...eWithCountModelsNotificationHistoryView.ts | 13 ++ internal/models/models_gen.go | 1 + internal/models/notification.go | 5 + internal/scheduler/scheduler.go | 4 +- internal/services/notifications/history.go | 33 ++++- .../constants_gen.go | 2 + .../repos/repo_notification_history/custom.go | 35 +++++ .../repos/repo_notification_history/find.go | 73 +++++++++++ .../notification_history.sql.go | 4 +- .../notification_history_gen.sql.go | 17 +-- internal/store/repos/repos.go | 6 +- internal/web/handlers.go | 4 + internal/web/notifications.go | 34 +++++ pgxgen.yaml | 2 +- sql/migrations/2_agents.up.sql | 1 + .../notification_history_gen.sql | 2 +- 23 files changed, 615 insertions(+), 20 deletions(-) create mode 100644 frontend/src/shared/types/model/getSettingsNotificationsHistoryParams.ts create mode 100644 frontend/src/shared/types/model/modelsNotificationHistoryView.ts create mode 100644 frontend/src/shared/types/model/storecmnFindResponseWithCountModelsNotificationHistoryView.ts create mode 100644 internal/store/repos/repo_notification_history/custom.go create mode 100644 internal/store/repos/repo_notification_history/find.go diff --git a/docs/docsv1/docs.go b/docs/docsv1/docs.go index 270b8ec..f8dcd56 100644 --- a/docs/docsv1/docs.go +++ b/docs/docsv1/docs.go @@ -738,6 +738,67 @@ const docTemplate = `{ } } }, + "/settings/notifications/history": { + "get": { + "description": "Retrieves a list of notification history records with optional filtering by status and pagination.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "List notification history", + "parameters": [ + { + "type": "string", + "description": "Filter by status (e.g., 'sent', 'failed')", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Order by field (default is 'created_at')", + "name": "order_by", + "in": "query" + }, + { + "type": "integer", + "description": "Page number for pagination (default is 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Number of items per page (default is 20)", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of notification history records", + "schema": { + "$ref": "#/definitions/storecmn.FindResponseWithCount-models_NotificationHistoryView" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, "/settings/notifications/providers": { "get": { "description": "Retrieves a list of all configured notification providers.", @@ -1062,6 +1123,53 @@ const docTemplate = `{ } } }, + "models.NotificationHistoryView": { + "type": "object", + "properties": { + "attempts": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "error_message": { + "type": "string" + }, + "id": { + "type": "string" + }, + "incident_id": { + "type": "string" + }, + "last_attempt_at": { + "type": "string" + }, + "message": { + "type": "string" + }, + "provider_id": { + "type": "string" + }, + "response": { + "type": "string" + }, + "sent_at": { + "type": "string" + }, + "service_id": { + "type": "string" + }, + "service_name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "models.NotificationProvider": { "type": "object", "properties": { @@ -1318,6 +1426,20 @@ const docTemplate = `{ } } }, + "storecmn.FindResponseWithCount-models_NotificationHistoryView": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.NotificationHistoryView" + } + } + } + }, "storecmn.FindResponseWithCount-web_ServiceDTO": { "type": "object", "properties": { diff --git a/docs/docsv1/swagger.json b/docs/docsv1/swagger.json index 504a323..d0efaf8 100644 --- a/docs/docsv1/swagger.json +++ b/docs/docsv1/swagger.json @@ -731,6 +731,67 @@ } } }, + "/settings/notifications/history": { + "get": { + "description": "Retrieves a list of notification history records with optional filtering by status and pagination.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "notifications" + ], + "summary": "List notification history", + "parameters": [ + { + "type": "string", + "description": "Filter by status (e.g., 'sent', 'failed')", + "name": "status", + "in": "query" + }, + { + "type": "string", + "description": "Order by field (default is 'created_at')", + "name": "order_by", + "in": "query" + }, + { + "type": "integer", + "description": "Page number for pagination (default is 1)", + "name": "page", + "in": "query" + }, + { + "type": "integer", + "description": "Number of items per page (default is 20)", + "name": "page_size", + "in": "query" + } + ], + "responses": { + "200": { + "description": "List of notification history records", + "schema": { + "$ref": "#/definitions/storecmn.FindResponseWithCount-models_NotificationHistoryView" + } + }, + "400": { + "description": "Bad request", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + }, + "500": { + "description": "Internal server error", + "schema": { + "$ref": "#/definitions/web.ErrorResponse" + } + } + } + } + }, "/settings/notifications/providers": { "get": { "description": "Retrieves a list of all configured notification providers.", @@ -1055,6 +1116,53 @@ } } }, + "models.NotificationHistoryView": { + "type": "object", + "properties": { + "attempts": { + "type": "integer" + }, + "created_at": { + "type": "string" + }, + "error_message": { + "type": "string" + }, + "id": { + "type": "string" + }, + "incident_id": { + "type": "string" + }, + "last_attempt_at": { + "type": "string" + }, + "message": { + "type": "string" + }, + "provider_id": { + "type": "string" + }, + "response": { + "type": "string" + }, + "sent_at": { + "type": "string" + }, + "service_id": { + "type": "string" + }, + "service_name": { + "type": "string" + }, + "status": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "models.NotificationProvider": { "type": "object", "properties": { @@ -1311,6 +1419,20 @@ } } }, + "storecmn.FindResponseWithCount-models_NotificationHistoryView": { + "type": "object", + "properties": { + "count": { + "type": "integer" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/definitions/models.NotificationHistoryView" + } + } + } + }, "storecmn.FindResponseWithCount-web_ServiceDTO": { "type": "object", "properties": { diff --git a/docs/docsv1/swagger.yaml b/docs/docsv1/swagger.yaml index 7d39358..0801aa4 100644 --- a/docs/docsv1/swagger.yaml +++ b/docs/docsv1/swagger.yaml @@ -32,6 +32,37 @@ definitions: updated_at: type: string type: object + models.NotificationHistoryView: + properties: + attempts: + type: integer + created_at: + type: string + error_message: + type: string + id: + type: string + incident_id: + type: string + last_attempt_at: + type: string + message: + type: string + provider_id: + type: string + response: + type: string + sent_at: + type: string + service_id: + type: string + service_name: + type: string + status: + type: string + updated_at: + type: string + type: object models.NotificationProvider: properties: config: @@ -209,6 +240,15 @@ definitions: $ref: '#/definitions/models.Incident' type: array type: object + storecmn.FindResponseWithCount-models_NotificationHistoryView: + properties: + count: + type: integer + items: + items: + $ref: '#/definitions/models.NotificationHistoryView' + type: array + type: object storecmn.FindResponseWithCount-web_ServiceDTO: properties: count: @@ -897,6 +937,47 @@ paths: summary: Get service statistics tags: - statistics + /settings/notifications/history: + get: + consumes: + - application/json + description: Retrieves a list of notification history records with optional + filtering by status and pagination. + parameters: + - description: Filter by status (e.g., 'sent', 'failed') + in: query + name: status + type: string + - description: Order by field (default is 'created_at') + in: query + name: order_by + type: string + - description: Page number for pagination (default is 1) + in: query + name: page + type: integer + - description: Number of items per page (default is 20) + in: query + name: page_size + type: integer + produces: + - application/json + responses: + "200": + description: List of notification history records + schema: + $ref: '#/definitions/storecmn.FindResponseWithCount-models_NotificationHistoryView' + "400": + description: Bad request + schema: + $ref: '#/definitions/web.ErrorResponse' + "500": + description: Internal server error + schema: + $ref: '#/definitions/web.ErrorResponse' + summary: List notification history + tags: + - notifications /settings/notifications/providers: get: consumes: diff --git a/frontend/src/shared/api/gen/notifications/notifications.ts b/frontend/src/shared/api/gen/notifications/notifications.ts index 73b297c..b1ec5d9 100644 --- a/frontend/src/shared/api/gen/notifications/notifications.ts +++ b/frontend/src/shared/api/gen/notifications/notifications.ts @@ -6,14 +6,27 @@ * OpenAPI spec version: 1.0 */ import type { + GetSettingsNotificationsHistoryParams, ModelsNotificationProvider, NotificationsCreateProviderParams, NotificationsUpdateProviderParams, + StorecmnFindResponseWithCountModelsNotificationHistoryView, } from "../../../types/model"; import { customFetcher } from "../../baseApi"; export const getNotifications = () => { + /** + * Retrieves a list of notification history records with optional filtering by status and pagination. + * @summary List notification history + */ + const getSettingsNotificationsHistory = ( + params?: GetSettingsNotificationsHistoryParams, + ) => { + return customFetcher( + { url: `/settings/notifications/history`, method: "GET", params }, + ); + }; /** * Retrieves a list of all configured notification providers. * @summary List all notification providers @@ -74,6 +87,7 @@ export const getNotifications = () => { }); }; return { + getSettingsNotificationsHistory, getSettingsNotificationsProviders, postSettingsNotificationsProviders, putSettingsNotificationsProvidersId, @@ -81,6 +95,13 @@ export const getNotifications = () => { postSettingsNotificationsProvidersIdTest, }; }; +export type GetSettingsNotificationsHistoryResult = NonNullable< + Awaited< + ReturnType< + ReturnType["getSettingsNotificationsHistory"] + > + > +>; export type GetSettingsNotificationsProvidersResult = NonNullable< Awaited< ReturnType< diff --git a/frontend/src/shared/types/model/getSettingsNotificationsHistoryParams.ts b/frontend/src/shared/types/model/getSettingsNotificationsHistoryParams.ts new file mode 100644 index 0000000..1865125 --- /dev/null +++ b/frontend/src/shared/types/model/getSettingsNotificationsHistoryParams.ts @@ -0,0 +1,26 @@ +/** + * Generated by orval v7.11.2 🍺 + * Do not edit manually. + * Sentinel Monitoring API + * API for service monitoring and incident management + * OpenAPI spec version: 1.0 + */ + +export type GetSettingsNotificationsHistoryParams = { + /** + * Filter by status (e.g., 'sent', 'failed') + */ + status?: string; + /** + * Order by field (default is 'created_at') + */ + order_by?: string; + /** + * Page number for pagination (default is 1) + */ + page?: number; + /** + * Number of items per page (default is 20) + */ + page_size?: number; +}; diff --git a/frontend/src/shared/types/model/index.ts b/frontend/src/shared/types/model/index.ts index 1ee887c..df9882e 100644 --- a/frontend/src/shared/types/model/index.ts +++ b/frontend/src/shared/types/model/index.ts @@ -11,9 +11,11 @@ export * from "./getIncidentsStatsParams"; export * from "./getServicesIdIncidentsParams"; export * from "./getServicesIdStatsParams"; export * from "./getServicesParams"; +export * from "./getSettingsNotificationsHistoryParams"; export * from "./getTagsCount200"; export * from "./modelsAvailableUpdate"; export * from "./modelsIncident"; +export * from "./modelsNotificationHistoryView"; export * from "./modelsNotificationProvider"; export * from "./modelsNotificationProviderType"; export * from "./modelsServiceProtocolType"; @@ -30,6 +32,7 @@ export * from "./notificationsCreateProviderParams"; export * from "./notificationsUpdateProviderParams"; export * from "./serviceStats"; export * from "./storecmnFindResponseWithCountModelsIncident"; +export * from "./storecmnFindResponseWithCountModelsNotificationHistoryView"; export * from "./storecmnFindResponseWithCountWebServiceDTO"; export * from "./storecmnJSONField"; export * from "./webCreateUpdateServiceRequest"; diff --git a/frontend/src/shared/types/model/modelsNotificationHistoryView.ts b/frontend/src/shared/types/model/modelsNotificationHistoryView.ts new file mode 100644 index 0000000..702bd8e --- /dev/null +++ b/frontend/src/shared/types/model/modelsNotificationHistoryView.ts @@ -0,0 +1,24 @@ +/** + * Generated by orval v7.11.2 🍺 + * Do not edit manually. + * Sentinel Monitoring API + * API for service monitoring and incident management + * OpenAPI spec version: 1.0 + */ + +export interface ModelsNotificationHistoryView { + attempts?: number; + created_at?: string; + error_message?: string; + id?: string; + incident_id?: string; + last_attempt_at?: string; + message?: string; + provider_id?: string; + response?: string; + sent_at?: string; + service_id?: string; + service_name?: string; + status?: string; + updated_at?: string; +} diff --git a/frontend/src/shared/types/model/storecmnFindResponseWithCountModelsNotificationHistoryView.ts b/frontend/src/shared/types/model/storecmnFindResponseWithCountModelsNotificationHistoryView.ts new file mode 100644 index 0000000..e51dd33 --- /dev/null +++ b/frontend/src/shared/types/model/storecmnFindResponseWithCountModelsNotificationHistoryView.ts @@ -0,0 +1,13 @@ +/** + * Generated by orval v7.11.2 🍺 + * Do not edit manually. + * Sentinel Monitoring API + * API for service monitoring and incident management + * OpenAPI spec version: 1.0 + */ +import type { ModelsNotificationHistoryView } from "./modelsNotificationHistoryView"; + +export interface StorecmnFindResponseWithCountModelsNotificationHistoryView { + count?: number; + items?: ModelsNotificationHistoryView[]; +} diff --git a/internal/models/models_gen.go b/internal/models/models_gen.go index ecea0bc..7a75b2c 100644 --- a/internal/models/models_gen.go +++ b/internal/models/models_gen.go @@ -54,6 +54,7 @@ type IncidentState struct { type NotificationHistory struct { ID string `db:"id" json:"id"` ProviderID string `db:"provider_id" json:"provider_id"` + ServiceID *string `db:"service_id" json:"service_id"` IncidentID *string `db:"incident_id" json:"incident_id"` Message string `db:"message" json:"message"` Status string `db:"status" json:"status"` diff --git a/internal/models/notification.go b/internal/models/notification.go index d49435e..a4de199 100644 --- a/internal/models/notification.go +++ b/internal/models/notification.go @@ -36,3 +36,8 @@ func (n NotificationProviderShoutrrrConfig) Validate() error { return nil } + +type NotificationHistoryView struct { + NotificationHistory + ServiceName *string `json:"service_name"` +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 3e79085..43c1f00 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -513,7 +513,7 @@ func (m *Scheduler) createIncident(ctx context.Context, svc *models.ServiceFullV // Send alert notification message := m.formatAlertMessage(svc, incident) - if err := m.baseservices.Notifications().History().SendAlert(ctx, incident.ID, message); err != nil { + if err := m.baseservices.Notifications().History().SendAlert(ctx, svc.ID, incident.ID, message); err != nil { m.logger.Errorf("failed to send alert notification for %s: %v", svc.Name, err) } @@ -546,7 +546,7 @@ func (m *Scheduler) resolveActiveIncidents(ctx context.Context, serviceID string } message := m.formatRecoveryMessage(svc, resolverIncident) - if err := m.baseservices.Notifications().History().SendAlert(ctx, resolverIncident.ID, message); err != nil { + if err := m.baseservices.Notifications().History().SendAlert(ctx, svc.ID, resolverIncident.ID, message); err != nil { m.logger.Errorf("failed to send recovery notification for %s: %v", svc.Name, err) } } diff --git a/internal/services/notifications/history.go b/internal/services/notifications/history.go index 1a7c510..86a1717 100644 --- a/internal/services/notifications/history.go +++ b/internal/services/notifications/history.go @@ -27,7 +27,7 @@ func newHistory(l logger.Logger, store *store.Store, sender *Sender) *History { } // SendAlert sends an alert notification to all enabled providers -func (s *History) SendAlert(ctx context.Context, incidentID, message string) error { +func (s *History) SendAlert(ctx context.Context, serviceID, incidentID, message string) error { // Get all enabled providers providers, err := s.store.NotificationProviders().GetAllEnabled(ctx) if err != nil { @@ -41,6 +41,10 @@ func (s *History) SendAlert(ctx context.Context, incidentID, message string) err Message: message, } + if serviceID != "" { + params.ServiceID = &serviceID + } + if incidentID != "" { params.IncidentID = &incidentID } @@ -54,9 +58,10 @@ func (s *History) SendAlert(ctx context.Context, incidentID, message string) err } type CreateHistoryParams struct { - ProviderID string `db:"provider_id" json:"provider_id"` - IncidentID *string `db:"incident_id" json:"incident_id"` - Message string `db:"message" json:"message"` + ProviderID string `json:"provider_id"` + ServiceID *string `json:"service_id"` + IncidentID *string `json:"incident_id"` + Message string `json:"message"` } // Validate validates the CreateHistoryParams fields @@ -81,6 +86,7 @@ func (s *History) Create(ctx context.Context, params CreateHistoryParams) (*mode createParams := repo_notification_history.CreateParams{ ID: utils.GenerateULID(), ProviderID: params.ProviderID, + ServiceID: params.ServiceID, IncidentID: params.IncidentID, Message: params.Message, } @@ -103,3 +109,22 @@ func (s *History) Delete(ctx context.Context, id string) error { return s.store.NotificationHistory().Delete(ctx, id) } + +type FindHistoryParams struct { + Status string `query:"status"` + OrderBy string `query:"order_by"` + Page *uint32 `query:"page"` + PageSize *uint32 `query:"page_size"` +} + +// Find returns list of notification histories by given filters with pagination +func (s *History) Find(ctx context.Context, params FindHistoryParams) (*storecmn.FindResponseWithCount[*models.NotificationHistoryView], error) { + p := repo_notification_history.FindParams{ + Status: params.Status, + OrderBy: params.OrderBy, + Page: params.Page, + PageSize: params.PageSize, + } + + return s.store.NotificationHistory().Find(ctx, p) +} diff --git a/internal/store/repos/repo_notification_history/constants_gen.go b/internal/store/repos/repo_notification_history/constants_gen.go index 67d77b8..3621890 100755 --- a/internal/store/repos/repo_notification_history/constants_gen.go +++ b/internal/store/repos/repo_notification_history/constants_gen.go @@ -41,6 +41,7 @@ func (s ColumnNames) Strings() []string { const ( ColumnNameNotificationHistoryId ColumnName = "id" ColumnNameNotificationHistoryProviderId ColumnName = "provider_id" + ColumnNameNotificationHistoryServiceId ColumnName = "service_id" ColumnNameNotificationHistoryIncidentId ColumnName = "incident_id" ColumnNameNotificationHistoryMessage ColumnName = "message" ColumnNameNotificationHistoryStatus ColumnName = "status" @@ -57,6 +58,7 @@ func NotificationHistoryColumnNames() ColumnNames { return ColumnNames{ ColumnNameNotificationHistoryId, ColumnNameNotificationHistoryProviderId, + ColumnNameNotificationHistoryServiceId, ColumnNameNotificationHistoryIncidentId, ColumnNameNotificationHistoryMessage, ColumnNameNotificationHistoryStatus, diff --git a/internal/store/repos/repo_notification_history/custom.go b/internal/store/repos/repo_notification_history/custom.go new file mode 100644 index 0000000..c1d2c4c --- /dev/null +++ b/internal/store/repos/repo_notification_history/custom.go @@ -0,0 +1,35 @@ +package repo_notification_history + +import ( + "context" + "database/sql" + + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/storecmn" +) + +type ICustomQuerier interface { + Querier + Find(ctx context.Context, params FindParams) (*storecmn.FindResponseWithCount[*models.NotificationHistoryView], error) +} + +type CustomQueries struct { + *Queries + db DBTX +} + +func NewCustom(db DBTX) *CustomQueries { + return &CustomQueries{ + Queries: New(db), + db: db, + } +} + +func (s *CustomQueries) WithTx(tx *sql.Tx) *CustomQueries { + return &CustomQueries{ + Queries: New(tx), + db: tx, + } +} + +var _ ICustomQuerier = (*CustomQueries)(nil) diff --git a/internal/store/repos/repo_notification_history/find.go b/internal/store/repos/repo_notification_history/find.go new file mode 100644 index 0000000..3e7b8c7 --- /dev/null +++ b/internal/store/repos/repo_notification_history/find.go @@ -0,0 +1,73 @@ +package repo_notification_history + +import ( + "context" + + "github.com/georgysavva/scany/v2/sqlscan" + "github.com/huandu/go-sqlbuilder" + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/store/storecmn" +) + +type FindParams struct { + Status string + OrderBy string + Page *uint32 + PageSize *uint32 +} + +func findBuilder(params FindParams, col ...string) *sqlbuilder.SelectBuilder { + sb := sqlbuilder.NewSelectBuilder() + sb.Select(col...) + sb.From(TableNameNotificationHistory.String() + " h") + + if params.Status != "" { + sb.Where(sb.Equal("status", params.Status)) + } + + return sb +} + +// Find returns list of notification histories by given filters with pagination +func (s *CustomQueries) Find(ctx context.Context, params FindParams) (*storecmn.FindResponseWithCount[*models.NotificationHistoryView], error) { + columns := NotificationHistoryColumnNames().Strings() + for i := range columns { + columns[i] = "h." + columns[i] + } + columns = append(columns, "s.name AS service_name") + + sb := findBuilder(params, columns...) + sb.JoinWithOption(sqlbuilder.LeftJoin, "services s", "h.service_id is not null and h.service_id = s.id") + + if params.OrderBy != "" { + sb.OrderBy(params.OrderBy) + } else { + sb.OrderBy("h.created_at") + } + + sb.Desc() + + limit, offset, err := storecmn.Pagination(params.Page, params.PageSize) + if err != nil { + return nil, err + } + sb.Limit(int(limit)).Offset(int(offset)) + + items := []*models.NotificationHistoryView{} + sql, args := sb.Build() + if err := sqlscan.Select(ctx, s.db, &items, sql, args...); err != nil { + return nil, err + } + + // Get total count of services + var totalCount uint32 + countSQL, countArgs := findBuilder(params, "count(*)").Build() + if err := sqlscan.Get(ctx, s.db, &totalCount, countSQL, countArgs...); err != nil { + return nil, err + } + + return &storecmn.FindResponseWithCount[*models.NotificationHistoryView]{ + Count: totalCount, + Items: items, + }, nil +} diff --git a/internal/store/repos/repo_notification_history/notification_history.sql.go b/internal/store/repos/repo_notification_history/notification_history.sql.go index eb46dc0..577dce6 100644 --- a/internal/store/repos/repo_notification_history/notification_history.sql.go +++ b/internal/store/repos/repo_notification_history/notification_history.sql.go @@ -15,7 +15,7 @@ import ( const getAllUnsent = `-- name: GetAllUnsent :many SELECT - h.id, h.provider_id, h.incident_id, h.message, h.status, h.response, h.attempts, h.error_message, h.last_attempt_at, h.sent_at, h.created_at, h.updated_at, + h.id, h.provider_id, h.service_id, h.incident_id, h.message, h.status, h.response, h.attempts, h.error_message, h.last_attempt_at, h.sent_at, h.created_at, h.updated_at, p.provider_type, p.config FROM notification_history h @@ -30,6 +30,7 @@ SELECT type GetAllUnsentRow struct { ID string `db:"id" json:"id"` ProviderID string `db:"provider_id" json:"provider_id"` + ServiceID *string `db:"service_id" json:"service_id"` IncidentID *string `db:"incident_id" json:"incident_id"` Message string `db:"message" json:"message"` Status string `db:"status" json:"status"` @@ -56,6 +57,7 @@ func (q *Queries) GetAllUnsent(ctx context.Context) ([]*GetAllUnsentRow, error) if err := rows.Scan( &i.ID, &i.ProviderID, + &i.ServiceID, &i.IncidentID, &i.Message, &i.Status, diff --git a/internal/store/repos/repo_notification_history/notification_history_gen.sql.go b/internal/store/repos/repo_notification_history/notification_history_gen.sql.go index 5a39894..eb4b632 100644 --- a/internal/store/repos/repo_notification_history/notification_history_gen.sql.go +++ b/internal/store/repos/repo_notification_history/notification_history_gen.sql.go @@ -12,31 +12,32 @@ import ( ) const create = `-- name: Create :one -INSERT INTO notification_history (id, provider_id, incident_id, message, error_message) +INSERT INTO notification_history (id, provider_id, service_id, incident_id, message) VALUES (?, ?, ?, ?, ?) - RETURNING id, provider_id, incident_id, message, status, response, attempts, error_message, last_attempt_at, sent_at, created_at, updated_at + RETURNING id, provider_id, service_id, incident_id, message, status, response, attempts, error_message, last_attempt_at, sent_at, created_at, updated_at ` type CreateParams struct { - ID string `db:"id" json:"id"` - ProviderID string `db:"provider_id" json:"provider_id"` - IncidentID *string `db:"incident_id" json:"incident_id"` - Message string `db:"message" json:"message"` - ErrorMessage *string `db:"error_message" json:"error_message"` + ID string `db:"id" json:"id"` + ProviderID string `db:"provider_id" json:"provider_id"` + ServiceID *string `db:"service_id" json:"service_id"` + IncidentID *string `db:"incident_id" json:"incident_id"` + Message string `db:"message" json:"message"` } func (q *Queries) Create(ctx context.Context, arg CreateParams) (*models.NotificationHistory, error) { row := q.db.QueryRowContext(ctx, create, arg.ID, arg.ProviderID, + arg.ServiceID, arg.IncidentID, arg.Message, - arg.ErrorMessage, ) var i models.NotificationHistory err := row.Scan( &i.ID, &i.ProviderID, + &i.ServiceID, &i.IncidentID, &i.Message, &i.Status, diff --git a/internal/store/repos/repos.go b/internal/store/repos/repos.go index 23cb05c..1a49c9f 100644 --- a/internal/store/repos/repos.go +++ b/internal/store/repos/repos.go @@ -19,7 +19,7 @@ type Repos struct { incidents *repo_incidents.CustomQueries incidentsStates *repo_incident_states.Queries notificationProviders *repo_notification_providers.Queries - notificationHistory *repo_notification_history.Queries + notificationHistory *repo_notification_history.CustomQueries } func New(sqlite *sql.DB) *Repos { @@ -30,7 +30,7 @@ func New(sqlite *sql.DB) *Repos { incidents: repo_incidents.NewCustom(sqlite), incidentsStates: repo_incident_states.New(sqlite), notificationProviders: repo_notification_providers.New(sqlite), - notificationHistory: repo_notification_history.New(sqlite), + notificationHistory: repo_notification_history.NewCustom(sqlite), } } @@ -101,7 +101,7 @@ func (s *Repos) NotificationProviders(opts ...Option) repo_notification_provider } // NotificationHistory returns repo for notification history -func (s *Repos) NotificationHistory(opts ...Option) repo_notification_history.Querier { +func (s *Repos) NotificationHistory(opts ...Option) repo_notification_history.ICustomQuerier { options := parseOptions(opts...) if options.Tx != nil { diff --git a/internal/web/handlers.go b/internal/web/handlers.go index 9000625..f680694 100644 --- a/internal/web/handlers.go +++ b/internal/web/handlers.go @@ -189,6 +189,7 @@ func (s *Server) setupRoutes() { // Settings API settingsGroup := api.Group("/settings") notificationsGroup := settingsGroup.Group("/notifications") + notificationsProviderGroup := notificationsGroup.Group("/providers") notificationsProviderGroup.Get("/", s.notificationProviderList) notificationsProviderGroup.Post("/", s.notificationProviderCreate) @@ -196,6 +197,9 @@ func (s *Server) setupRoutes() { notificationsProviderGroup.Delete("/:id", s.notificationProviderDelete) notificationsProviderGroup.Post("/:id/test", s.notificationProviderTest) + notificationsHistoryGroup := notificationsGroup.Group("/history") + notificationsHistoryGroup.Get("/", s.notificationHistoryList) + // Server API serverGroup := api.Group("/server") serverGroup.Get("/info", s.handleAPIInfo) diff --git a/internal/web/notifications.go b/internal/web/notifications.go index b7b7f2e..f06d108 100644 --- a/internal/web/notifications.go +++ b/internal/web/notifications.go @@ -134,3 +134,37 @@ func (s *Server) notificationProviderTest(c *fiber.Ctx) error { return c.SendStatus(fiber.StatusOK) } + +// notificationHistoryList lists notification history with optional filters and pagination +// +// @Summary List notification history +// @Description Retrieves a list of notification history records with optional filtering by status and pagination. +// @Tags notifications +// @Accept json +// @Produce json +// @Param status query string false "Filter by status (e.g., 'sent', 'failed')" +// @Param order_by query string false "Order by field (default is 'created_at')" +// @Param page query int32 false "Page number for pagination (default is 1)" +// @Param page_size query int32 false "Number of items per page (default is 20)" +// @Success 200 {object} storecmn.FindResponseWithCount[models.NotificationHistoryView] "List of notification history records" +// @Failure 400 {object} ErrorResponse "Bad request" +// @Failure 500 {object} ErrorResponse "Internal server error" +// @Router /settings/notifications/history [get] +func (s *Server) notificationHistoryList(c *fiber.Ctx) error { + var params notifications.FindHistoryParams + if err := c.QueryParser(¶ms); err != nil { + return newErrorResponse(c, fiber.StatusBadRequest, err) + } + + histories, err := s.baseServices.Notifications().History().Find(c.Context(), notifications.FindHistoryParams{ + Status: params.Status, + OrderBy: params.OrderBy, + Page: params.Page, + PageSize: params.PageSize, + }) + if err != nil { + return newErrorResponse(c, fiber.StatusInternalServerError, err) + } + + return c.JSON(histories) +} diff --git a/pgxgen.yaml b/pgxgen.yaml index 6e719b5..bec4a3e 100644 --- a/pgxgen.yaml +++ b/pgxgen.yaml @@ -133,7 +133,7 @@ sqlc: - status - response - attempts - - last_sending_error_message + - error_message - last_attempt_at - sent_at - created_at diff --git a/sql/migrations/2_agents.up.sql b/sql/migrations/2_agents.up.sql index e889ca6..9e33adb 100644 --- a/sql/migrations/2_agents.up.sql +++ b/sql/migrations/2_agents.up.sql @@ -58,6 +58,7 @@ CREATE INDEX IF NOT EXISTS idx_notification_providers_enabled ON notification_pr CREATE TABLE IF NOT EXISTS notification_history ( id TEXT PRIMARY KEY, provider_id TEXT NOT NULL REFERENCES notification_providers(id) ON DELETE CASCADE, + service_id TEXT REFERENCES services(id) ON DELETE SET NULL, incident_id TEXT REFERENCES incidents(id) ON DELETE SET NULL, message TEXT NOT NULL CHECK (message != ''), "status" TEXT NOT NULL DEFAULT 'pending' CHECK (status != ''), diff --git a/sql/queries/notification_history/notification_history_gen.sql b/sql/queries/notification_history/notification_history_gen.sql index 47f6bec..b9f4ea2 100755 --- a/sql/queries/notification_history/notification_history_gen.sql +++ b/sql/queries/notification_history/notification_history_gen.sql @@ -1,5 +1,5 @@ -- name: Create :one -INSERT INTO notification_history (id, provider_id, incident_id, message, error_message) +INSERT INTO notification_history (id, provider_id, service_id, incident_id, message) VALUES (?, ?, ?, ?, ?) RETURNING *; From 68055c403701390774e1a86e8f04c09ad97b1ecd Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Tue, 23 Sep 2025 01:39:40 +0300 Subject: [PATCH 21/71] refactor: Simplify logging message for unsent notifications in sender --- internal/scheduler/scheduler.go | 7 +++---- internal/services/notifications/sender.go | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 43c1f00..62b0b79 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -166,14 +166,13 @@ func (s *Scheduler) addJob(ctx context.Context, job *job) { s.jobs.Store(job.serviceID, job) // Start monitoring in a new goroutine - s.wg.Add(1) - go s.monitorService(ctx, job) + s.wg.Go(func() { + s.monitorService(ctx, job) + }) } // monitorService runs the monitoring loop for a single service func (s *Scheduler) monitorService(ctx context.Context, job *job) { - defer s.wg.Done() - // Create ticker for regular checks job.ticker = time.NewTicker(job.interval) defer job.ticker.Stop() diff --git a/internal/services/notifications/sender.go b/internal/services/notifications/sender.go index bfd1caa..63cfe45 100644 --- a/internal/services/notifications/sender.go +++ b/internal/services/notifications/sender.go @@ -71,7 +71,7 @@ func (s *Sender) do(ctx context.Context) error { return nil } - s.logger.Infof("found %d unsent notification history items", len(items)) + s.logger.Infof("found %d unsent notifications", len(items)) unsentIncidents := make(map[string]struct{}) From 1e598d8dc552149fd3232a7e2432d4afcf34a8f4 Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Tue, 23 Sep 2025 04:51:33 +0300 Subject: [PATCH 22/71] feat: Implement multi-server support with TCP, gRPC, and HTTP options --- cmd/testserver/main.go | 310 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 cmd/testserver/main.go diff --git a/cmd/testserver/main.go b/cmd/testserver/main.go new file mode 100644 index 0000000..bb5e613 --- /dev/null +++ b/cmd/testserver/main.go @@ -0,0 +1,310 @@ +package main + +import ( + "bufio" + "context" + "flag" + "fmt" + "log" + "net" + "net/http" + "os" + "os/signal" + "sync" + "syscall" + "time" + + "github.com/sxwebdev/sentinel/internal/utils" + "google.golang.org/grpc" + "google.golang.org/grpc/health" + "google.golang.org/grpc/health/grpc_health_v1" + "google.golang.org/grpc/reflection" +) + +var ( + enableTCP = flag.Bool("tcp", false, "Enable TCP server") + enableGRPC = flag.Bool("grpc", false, "Enable gRPC server") + enableHTTP = flag.Bool("http", false, "Enable HTTP server") + tcpPort = flag.Int("tcp-port", 12345, "TCP server port") + grpcPort = flag.Int("grpc-port", 50051, "gRPC server port") + httpPort = flag.Int("http-port", 8085, "HTTP server port") +) + +func main() { + flag.Parse() + + // Check if at least one server is enabled + if !*enableTCP && !*enableGRPC && !*enableHTTP { + log.Fatal("At least one server must be enabled. Use -tcp, -grpc, or -http flags.") + } + + if err := run(); err != nil { + log.Fatalf("Failed to run servers: %v", err) + } +} + +func run() error { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + var wg sync.WaitGroup + errChan := make(chan error, 3) // Buffer for all possible servers + + // Start enabled servers + if *enableTCP { + wg.Add(1) + go func() { + defer wg.Done() + log.Printf("Starting TCP server on port %d", *tcpPort) + if err := runTCPServer(ctx, *tcpPort); err != nil { + errChan <- fmt.Errorf("TCP server error: %w", err) + } + }() + } + + if *enableGRPC { + wg.Add(1) + go func() { + defer wg.Done() + log.Printf("Starting gRPC server on port %d", *grpcPort) + if err := runGRPCServer(ctx, *grpcPort); err != nil { + errChan <- fmt.Errorf("gRPC server error: %w", err) + } + }() + } + + if *enableHTTP { + wg.Add(1) + go func() { + defer wg.Done() + log.Printf("Starting HTTP server on port %d", *httpPort) + if err := runHTTPServer(ctx, *httpPort); err != nil { + errChan <- fmt.Errorf("HTTP server error: %w", err) + } + }() + } + + // Wait for interrupt signal or server error + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + select { + case <-sigChan: + log.Println("Received interrupt signal, shutting down...") + cancel() // Signal all servers to stop + case err := <-errChan: + log.Printf("Server error: %v", err) + cancel() + return err + } + + // Wait for all servers to shut down gracefully + done := make(chan struct{}) + go func() { + wg.Wait() + close(done) + }() + + select { + case <-done: + log.Println("All servers shut down gracefully") + case <-time.After(10 * time.Second): + log.Println("Timeout waiting for servers to shut down") + } + + return nil +} + +// runTCPServer runs the TCP server with original logic from cmd/tcpserver/main.go +func runTCPServer(ctx context.Context, port int) error { + addr := fmt.Sprintf("127.0.0.1:%d", port) + listener, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("failed to start TCP server: %w", err) + } + defer listener.Close() + + log.Printf("TCP server is listening on %s", addr) + + // Channel to signal when to stop accepting new connections + stopChan := make(chan struct{}) + + // Goroutine to handle context cancellation + go func() { + <-ctx.Done() + close(stopChan) + listener.Close() // This will cause Accept() to return an error + }() + + for { + select { + case <-stopChan: + return nil + default: + } + + conn, err := listener.Accept() + if err != nil { + select { + case <-stopChan: + return nil // Expected error due to context cancellation + default: + log.Printf("Failed to accept TCP connection: %v", err) + continue + } + } + + log.Printf("TCP client connected: %s", conn.RemoteAddr()) + go handleTCPConnection(conn) + } +} + +// handleTCPConnection handles individual TCP connections (original logic from cmd/tcpserver/main.go) +func handleTCPConnection(conn net.Conn) { + defer func() { + conn.Close() + log.Printf("TCP connection closed: %s\n", conn.RemoteAddr()) + }() + + // Set connection timeout - close if no data received in 5 seconds + conn.SetReadDeadline(time.Now().Add(5 * time.Second)) + + var accumulated []byte + reader := bufio.NewReader(conn) + + for { + buffer := make([]byte, 1024) + n, err := reader.Read(buffer) + if err != nil { + if utils.IsErrTimeout(err) { + break + } + log.Printf("Failed to read from TCP connection: %v", err) + return + } + + if n == 0 { + break + } + + // Accumulate received data + accumulated = append(accumulated, buffer[:n]...) + + // If no more data buffered, client likely finished sending + if reader.Buffered() == 0 { + break + } + } + + // Now process the complete message + if len(accumulated) == 0 { + log.Println("No data received from TCP client") + return + } + + msg := string(accumulated) + log.Printf("TCP complete message received: %s", msg) + + // Simple ping-pong protocol - exact match + switch msg { + case "ping": + log.Println("TCP: Sending pong") + _, err := conn.Write([]byte("pong")) + if err != nil { + log.Printf("Failed to send TCP response: %v", err) + } + case "noresponse": + log.Println("TCP: No response expected") + default: + log.Printf("TCP: Unknown message '%s', sending ok", msg) + _, err := conn.Write([]byte("ok")) + if err != nil { + log.Printf("Failed to send TCP response: %v", err) + } + } +} + +// runGRPCServer runs the gRPC server with original logic from cmd/grpcserver/main.go +func runGRPCServer(ctx context.Context, port int) error { + addr := fmt.Sprintf("localhost:%d", port) + log.Printf("Starting gRPC server on %s", addr) + + lis, err := net.Listen("tcp", addr) + if err != nil { + return fmt.Errorf("failed to listen: %w", err) + } + + // Create gRPC server + grpcServer := grpc.NewServer() + + // Create and register health server + healthServer := health.NewServer() + grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) + + // Register gRPC reflection service for debugging + reflection.Register(grpcServer) + + // Set initial serving status + healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) + healthServer.SetServingStatus("health", grpc_health_v1.HealthCheckResponse_SERVING) + healthServer.SetServingStatus("test-service", grpc_health_v1.HealthCheckResponse_SERVING) + + // Start server in a goroutine + serverErr := make(chan error, 1) + go func() { + if err := grpcServer.Serve(lis); err != nil { + serverErr <- fmt.Errorf("failed to serve: %w", err) + } + }() + + // Wait for context cancellation or server error + select { + case <-ctx.Done(): + // Graceful shutdown + log.Println("Shutting down gRPC server...") + grpcServer.GracefulStop() + return nil + case err := <-serverErr: + return err + } +} + +// runHTTPServer runs a simple HTTP server using standard library +func runHTTPServer(ctx context.Context, port int) error { + mux := http.NewServeMux() + + // Simple handler that returns {"ok":true} + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + }) + + server := &http.Server{ + Addr: fmt.Sprintf(":%d", port), + Handler: mux, + } + + // Start server in a goroutine + serverErr := make(chan error, 1) + go func() { + log.Printf("HTTP server is listening on :%d", port) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + serverErr <- fmt.Errorf("failed to start HTTP server: %w", err) + } + }() + + // Wait for context cancellation or server error + select { + case <-ctx.Done(): + log.Println("Shutting down HTTP server...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := server.Shutdown(shutdownCtx); err != nil { + return fmt.Errorf("failed to shutdown HTTP server: %w", err) + } + return nil + case err := <-serverErr: + return err + } +} From d9991ad1d2f74845929b70a9da0cd495ae51b7b9 Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Tue, 23 Sep 2025 05:05:41 +0300 Subject: [PATCH 23/71] refactor: Consolidate server initialization and improve monitor creation logic --- Makefile | 7 ++-- cmd/sentinel/hub.go | 2 +- cmd/testserver/main.go | 32 ++++++----------- internal/monitors/grpc.go | 15 +++----- internal/monitors/http.go | 63 +++++++++++++++------------------ internal/monitors/monitor.go | 52 ++++++++++++--------------- internal/monitors/tcp.go | 17 ++++----- internal/scheduler/scheduler.go | 32 ++++++++--------- 8 files changed, 90 insertions(+), 130 deletions(-) diff --git a/Makefile b/Makefile index 2d48e66..aa3ef88 100644 --- a/Makefile +++ b/Makefile @@ -33,11 +33,8 @@ agent: ## Run in development mode with auto-reload run: build ## Build and run the application ./$(BUILD_DIR)/$(BINARY_NAME) -runtcpserver: - go run ./cmd/tcpserver - -rungrpcserver: - go run ./cmd/grpcserver +runtestservers: + go run ./cmd/testserver -http -grpc -tcp front: cd frontend && pnpm dev diff --git a/cmd/sentinel/hub.go b/cmd/sentinel/hub.go index 272bd5e..8011e1c 100644 --- a/cmd/sentinel/hub.go +++ b/cmd/sentinel/hub.go @@ -106,7 +106,7 @@ func hubStartCMD() *cli.Command { baseServices := baseservices.New(l, st, rc) // Initialize scheduler - sched := scheduler.New(l, st, rc, baseServices) + sched := scheduler.New(l, rc, baseServices) serverInfo := models.GetSystemInfo(version, commitHash, buildDate) serverInfo.SqliteVersion = sqliteVersion diff --git a/cmd/testserver/main.go b/cmd/testserver/main.go index bb5e613..5de25d3 100644 --- a/cmd/testserver/main.go +++ b/cmd/testserver/main.go @@ -52,36 +52,30 @@ func run() error { // Start enabled servers if *enableTCP { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { log.Printf("Starting TCP server on port %d", *tcpPort) if err := runTCPServer(ctx, *tcpPort); err != nil { errChan <- fmt.Errorf("TCP server error: %w", err) } - }() + }) } if *enableGRPC { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { log.Printf("Starting gRPC server on port %d", *grpcPort) if err := runGRPCServer(ctx, *grpcPort); err != nil { errChan <- fmt.Errorf("gRPC server error: %w", err) } - }() + }) } if *enableHTTP { - wg.Add(1) - go func() { - defer wg.Done() + wg.Go(func() { log.Printf("Starting HTTP server on port %d", *httpPort) if err := runHTTPServer(ctx, *httpPort); err != nil { errChan <- fmt.Errorf("HTTP server error: %w", err) } - }() + }) } // Wait for interrupt signal or server error @@ -117,15 +111,13 @@ func run() error { // runTCPServer runs the TCP server with original logic from cmd/tcpserver/main.go func runTCPServer(ctx context.Context, port int) error { - addr := fmt.Sprintf("127.0.0.1:%d", port) + addr := fmt.Sprintf("localhost:%d", port) listener, err := net.Listen("tcp", addr) if err != nil { return fmt.Errorf("failed to start TCP server: %w", err) } defer listener.Close() - log.Printf("TCP server is listening on %s", addr) - // Channel to signal when to stop accepting new connections stopChan := make(chan struct{}) @@ -154,7 +146,7 @@ func runTCPServer(ctx context.Context, port int) error { } } - log.Printf("TCP client connected: %s", conn.RemoteAddr()) + // log.Printf("TCP client connected: %s", conn.RemoteAddr()) go handleTCPConnection(conn) } } @@ -163,7 +155,7 @@ func runTCPServer(ctx context.Context, port int) error { func handleTCPConnection(conn net.Conn) { defer func() { conn.Close() - log.Printf("TCP connection closed: %s\n", conn.RemoteAddr()) + // log.Printf("TCP connection closed: %s\n", conn.RemoteAddr()) }() // Set connection timeout - close if no data received in 5 seconds @@ -203,12 +195,12 @@ func handleTCPConnection(conn net.Conn) { } msg := string(accumulated) - log.Printf("TCP complete message received: %s", msg) + // log.Printf("TCP complete message received: %s", msg) // Simple ping-pong protocol - exact match switch msg { case "ping": - log.Println("TCP: Sending pong") + // log.Println("TCP: Sending pong") _, err := conn.Write([]byte("pong")) if err != nil { log.Printf("Failed to send TCP response: %v", err) @@ -227,7 +219,6 @@ func handleTCPConnection(conn net.Conn) { // runGRPCServer runs the gRPC server with original logic from cmd/grpcserver/main.go func runGRPCServer(ctx context.Context, port int) error { addr := fmt.Sprintf("localhost:%d", port) - log.Printf("Starting gRPC server on %s", addr) lis, err := net.Listen("tcp", addr) if err != nil { @@ -288,7 +279,6 @@ func runHTTPServer(ctx context.Context, port int) error { // Start server in a goroutine serverErr := make(chan error, 1) go func() { - log.Printf("HTTP server is listening on :%d", port) if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { serverErr <- fmt.Errorf("failed to start HTTP server: %w", err) } diff --git a/internal/monitors/grpc.go b/internal/monitors/grpc.go index 93ef6aa..4aef585 100644 --- a/internal/monitors/grpc.go +++ b/internal/monitors/grpc.go @@ -25,23 +25,18 @@ type GRPCConfig struct { // GRPCMonitor monitors gRPC services type GRPCMonitor struct { - BaseMonitor + baseMonitor conf GRPCConfig conn *grpc.ClientConn } -// NewGRPCMonitor creates a new gRPC monitor -func NewGRPCMonitor(svc *models.Service) (*GRPCMonitor, error) { +// newGRPCMonitor creates a new gRPC monitor +func newGRPCMonitor(params MonitorParams) (*GRPCMonitor, error) { monitor := &GRPCMonitor{ - BaseMonitor: NewBaseMonitor(svc), + baseMonitor: newBaseMonitor(params.ServiceName, params.Protocol, params.Timeout), } - svcConfig, err := svc.GetConfig() - if err != nil { - return nil, fmt.Errorf("failed to parse service config: %w", err) - } - - conf, err := GetConfig[GRPCConfig](svcConfig, models.ServiceProtocolTypeGRPC) + conf, err := GetConfig[GRPCConfig](params.Config, models.ServiceProtocolTypeGRPC) if err != nil { return nil, fmt.Errorf("failed to get gRPC config: %w", err) } diff --git a/internal/monitors/http.go b/internal/monitors/http.go index 1f3868f..f4e08f5 100644 --- a/internal/monitors/http.go +++ b/internal/monitors/http.go @@ -34,8 +34,8 @@ type EndpointConfig struct { Password string `json:"password"` // Basic Auth password } -// EndpointResult represents result from a single endpoint -type EndpointResult struct { +// endpointResult represents result from a single endpoint +type endpointResult struct { Name string `json:"name"` URL string `json:"url"` Success bool `json:"success"` @@ -47,27 +47,20 @@ type EndpointResult struct { // HTTPMonitor monitors HTTP/HTTPS endpoints type HTTPMonitor struct { - BaseMonitor - conf HTTPConfig - retries int64 + baseMonitor + conf HTTPConfig } -// NewHTTPMonitor creates a new HTTP monitor -func NewHTTPMonitor(svc *models.Service) (*HTTPMonitor, error) { - svcConfig, err := svc.GetConfig() - if err != nil { - return nil, fmt.Errorf("failed to parse service config: %w", err) - } - - conf, err := GetConfig[HTTPConfig](svcConfig, models.ServiceProtocolTypeHTTP) +// newHTTPMonitor creates a new HTTP monitor +func newHTTPMonitor(params MonitorParams) (*HTTPMonitor, error) { + conf, err := GetConfig[HTTPConfig](params.Config, models.ServiceProtocolTypeHTTP) if err != nil { return nil, fmt.Errorf("HTTP config not found") } monitor := &HTTPMonitor{ - BaseMonitor: NewBaseMonitor(svc), + baseMonitor: newBaseMonitor(params.ServiceName, params.Protocol, params.Timeout), conf: conf, - retries: svc.Retries, } return monitor, nil @@ -86,22 +79,22 @@ func (h *HTTPMonitor) Close() error { // checkEndpoints performs health checks on multiple endpoints and evaluates conditions func (h *HTTPMonitor) checkEndpoints(ctx context.Context) error { config := h.conf.Endpoints - results := make([]EndpointResult, 0, len(config)) + results := make([]endpointResult, 0, len(config)) // Check all endpoints concurrently - type endpointResult struct { - result EndpointResult + type checkResult struct { + result endpointResult index int } - resultChan := make(chan endpointResult, len(config)) + resultChan := make(chan checkResult, len(config)) // Start all endpoint checks for i, endpoint := range config { go func(ep EndpointConfig, idx int) { result := h.checkEndpoint(ctx, ep) select { - case resultChan <- endpointResult{result: result, index: idx}: + case resultChan <- checkResult{result: result, index: idx}: case <-ctx.Done(): // Context cancelled, don't block on channel send } @@ -147,15 +140,15 @@ func (h *HTTPMonitor) checkEndpoints(ctx context.Context) error { } // checkEndpoint performs a health check on a single endpoint -func (h *HTTPMonitor) checkEndpoint(ctx context.Context, endpoint EndpointConfig) EndpointResult { +func (h *HTTPMonitor) checkEndpoint(ctx context.Context, endpoint EndpointConfig) endpointResult { start := time.Now() client := &http.Client{} - client.Timeout = h.config.Timeout.ToDuration() + client.Timeout = h.timeout req, err := http.NewRequestWithContext(ctx, endpoint.Method, endpoint.URL, strings.NewReader(endpoint.Body)) if err != nil { - return EndpointResult{ + return endpointResult{ Name: endpoint.Name, URL: endpoint.URL, Success: false, @@ -183,7 +176,7 @@ func (h *HTTPMonitor) checkEndpoint(ctx context.Context, endpoint EndpointConfig duration := time.Since(start) if err != nil { - return EndpointResult{ + return endpointResult{ Name: endpoint.Name, URL: endpoint.URL, Success: false, @@ -196,7 +189,7 @@ func (h *HTTPMonitor) checkEndpoint(ctx context.Context, endpoint EndpointConfig // Read response body body, err := io.ReadAll(resp.Body) if err != nil { - return EndpointResult{ + return endpointResult{ Name: endpoint.Name, URL: endpoint.URL, Success: false, @@ -206,7 +199,7 @@ func (h *HTTPMonitor) checkEndpoint(ctx context.Context, endpoint EndpointConfig } if endpoint.ExpectedStatus != 0 && resp.StatusCode != endpoint.ExpectedStatus { - return EndpointResult{ + return endpointResult{ Name: endpoint.Name, URL: endpoint.URL, Success: false, @@ -221,7 +214,7 @@ func (h *HTTPMonitor) checkEndpoint(ctx context.Context, endpoint EndpointConfig if endpoint.JSONPath != "" { value, err = extractValueFromJSON(body, endpoint.JSONPath) if err != nil { - return EndpointResult{ + return endpointResult{ Name: endpoint.Name, URL: endpoint.URL, Success: false, @@ -232,7 +225,7 @@ func (h *HTTPMonitor) checkEndpoint(ctx context.Context, endpoint EndpointConfig } } - return EndpointResult{ + return endpointResult{ Name: endpoint.Name, URL: endpoint.URL, Success: true, @@ -243,8 +236,8 @@ func (h *HTTPMonitor) checkEndpoint(ctx context.Context, endpoint EndpointConfig } // extractValueFromJSON extracts value from JSON response using JSONPath-like syntax -func extractValueFromJSON(data []byte, path string) (interface{}, error) { - var jsonData interface{} +func extractValueFromJSON(data []byte, path string) (any, error) { + var jsonData any if err := json.Unmarshal(data, &jsonData); err != nil { return nil, fmt.Errorf("failed to parse JSON: %w", err) } @@ -259,13 +252,13 @@ func extractValueFromJSON(data []byte, path string) (interface{}, error) { for _, part := range parts { switch v := current.(type) { - case map[string]interface{}: + case map[string]any: if val, exists := v[part]; exists { current = val } else { return nil, fmt.Errorf("path not found: %s", path) } - case []interface{}: + case []any: // Handle array indexing like "items.0.name" if idx, err := parseInt(part); err == nil && idx >= 0 && idx < len(v) { current = v[idx] @@ -288,9 +281,7 @@ func parseInt(s string) (int, error) { } // evaluateCondition evaluates JavaScript condition with endpoint results -func evaluateCondition(condition string, results []EndpointResult) (bool, error) { - vm := goja.New() - +func evaluateCondition(condition string, results []endpointResult) (bool, error) { // Create results object for JavaScript resultsObj := make(map[string]any) for _, result := range results { @@ -303,6 +294,8 @@ func evaluateCondition(condition string, results []EndpointResult) (bool, error) } } + vm := goja.New() + // Set global variables vm.Set("results", resultsObj) vm.Set("console", map[string]any{ diff --git a/internal/monitors/monitor.go b/internal/monitors/monitor.go index e076c7a..3a39a5e 100644 --- a/internal/monitors/monitor.go +++ b/internal/monitors/monitor.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "io" + "time" "github.com/sxwebdev/sentinel/internal/models" ) @@ -11,50 +12,41 @@ import ( // ServiceMonitor defines the interface for all service monitors type ServiceMonitor interface { io.Closer - - Name() string - Protocol() models.ServiceProtocolType Check(ctx context.Context) error - Config() *models.Service +} + +type MonitorParams struct { + ServiceName string + Protocol models.ServiceProtocolType + Timeout time.Duration + Config map[string]any } // NewMonitor creates a new monitor based on the service configuration -func NewMonitor(cfg *models.Service) (ServiceMonitor, error) { - switch cfg.Protocol { +func NewMonitor(params MonitorParams) (ServiceMonitor, error) { + switch params.Protocol { case models.ServiceProtocolTypeHTTP: - return NewHTTPMonitor(cfg) + return newHTTPMonitor(params) case models.ServiceProtocolTypeTCP: - return NewTCPMonitor(cfg) + return newTCPMonitor(params) case models.ServiceProtocolTypeGRPC: - return NewGRPCMonitor(cfg) + return newGRPCMonitor(params) default: - return nil, fmt.Errorf("unsupported protocol: %s", cfg.Protocol) + return nil, fmt.Errorf("unsupported protocol: %s", params.Protocol) } } -// BaseMonitor provides common functionality for all monitors -type BaseMonitor struct { +// baseMonitor provides common functionality for all monitors +type baseMonitor struct { name string protocol models.ServiceProtocolType - config *models.Service + timeout time.Duration } -func NewBaseMonitor(cfg *models.Service) BaseMonitor { - return BaseMonitor{ - name: cfg.Name, - protocol: cfg.Protocol, - config: cfg, +func newBaseMonitor(name string, protocol models.ServiceProtocolType, timeout time.Duration) baseMonitor { + return baseMonitor{ + name: name, + protocol: protocol, + timeout: timeout, } } - -func (b *BaseMonitor) Name() string { - return b.name -} - -func (b *BaseMonitor) Protocol() models.ServiceProtocolType { - return b.protocol -} - -func (b *BaseMonitor) Config() *models.Service { - return b.config -} diff --git a/internal/monitors/tcp.go b/internal/monitors/tcp.go index 51f565d..2d8713a 100644 --- a/internal/monitors/tcp.go +++ b/internal/monitors/tcp.go @@ -21,24 +21,19 @@ type TCPConfig struct { // TCPMonitor monitors TCP endpoints type TCPMonitor struct { - BaseMonitor + baseMonitor conf TCPConfig } -// NewTCPMonitor creates a new TCP monitor -func NewTCPMonitor(svc *models.Service) (*TCPMonitor, error) { - svcConfig, err := svc.GetConfig() - if err != nil { - return nil, fmt.Errorf("failed to parse service config: %w", err) - } - - conf, err := GetConfig[TCPConfig](svcConfig, models.ServiceProtocolTypeTCP) +// newTCPMonitor creates a new TCP monitor +func newTCPMonitor(params MonitorParams) (*TCPMonitor, error) { + conf, err := GetConfig[TCPConfig](params.Config, models.ServiceProtocolTypeTCP) if err != nil { return nil, fmt.Errorf("failed to get TCP config: %w", err) } monitor := &TCPMonitor{ - BaseMonitor: NewBaseMonitor(svc), + baseMonitor: newBaseMonitor(params.ServiceName, params.Protocol, params.Timeout), conf: conf, } @@ -53,7 +48,7 @@ func (t *TCPMonitor) Check(ctx context.Context) error { } endpoint := t.conf.Endpoint - timeout := t.config.Timeout.ToDuration() + timeout := t.timeout if timeout <= 0 { timeout = 5 * time.Second } diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 62b0b79..5f36f68 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "io" "strings" "sync" "time" @@ -16,7 +17,6 @@ import ( "github.com/sxwebdev/sentinel/internal/services/incidents" "github.com/sxwebdev/sentinel/internal/services/service" "github.com/sxwebdev/sentinel/internal/services/servicestate" - "github.com/sxwebdev/sentinel/internal/store" "github.com/sxwebdev/sentinel/internal/store/repos/repo_service_states" "github.com/sxwebdev/sentinel/internal/utils" "github.com/tkcrm/modules/pkg/db/dbutils" @@ -30,11 +30,10 @@ var ErrServiceNotFound = fmt.Errorf("service not found") type Scheduler struct { logger logger.Logger - store *store.Store - receiver *receiver.Receiver baseservices *baseservices.BaseServices + // Job management, key is service ID jobs *xsync.MapOf[string, *job] wg sync.WaitGroup } @@ -42,13 +41,11 @@ type Scheduler struct { // New creates a new scheduler func New( l logger.Logger, - store *store.Store, receiver *receiver.Receiver, baseServices *baseservices.BaseServices, ) *Scheduler { return &Scheduler{ logger: l, - store: store, receiver: receiver, baseservices: baseServices, jobs: xsync.NewMapOf[string, *job](), @@ -219,15 +216,25 @@ func (s *Scheduler) performCheck(job *job) error { return fmt.Errorf("failed to get service %s: %w", serviceName, err) } + svcConfig, err := service.GetConfig() + if err != nil { + return fmt.Errorf("failed to parse service config for %s: %w", serviceName, err) + } + // Create monitor for this check - monitor, err := monitors.NewMonitor(service) + monitor, err := monitors.NewMonitor(monitors.MonitorParams{ + ServiceName: service.Name, + Protocol: service.Protocol, + Timeout: service.Timeout.ToDuration(), + Config: svcConfig, + }) if err != nil { return fmt.Errorf("failed to create monitor for %s: %w", serviceName, err) } // Ensure monitor resources are cleaned up defer func() { - if closer, ok := monitor.(interface{ Close() error }); ok { + if closer, ok := monitor.(io.Closer); ok { if err := closer.Close(); err != nil { s.logger.Errorf("Error closing monitor for %s: %v", serviceName, err) } @@ -352,13 +359,6 @@ func (s *Scheduler) removeJob(serviceID string) error { return nil } -// updateJob updates a service configuration dynamically -func (s *Scheduler) updateJob(ctx context.Context, svc *models.ServiceFullView) error { - s.addService(ctx, svc) - - return nil -} - func (s *Scheduler) subscribeEvents(ctx context.Context) error { broker := s.receiver.TriggerService() sub := broker.Subscribe() @@ -383,9 +383,7 @@ func (s *Scheduler) subscribeEvents(ctx context.Context) error { } } else { // Update or add to monitoring if service is enabled - if err := s.updateJob(ctx, item.Svc); err != nil { - s.logger.Errorf("update service error: %v", err) - } + s.addService(ctx, item.Svc) } case receiver.TriggerServiceEventTypeDeleted: if err := s.removeJob(item.Svc.ID); err != nil { From 0b520180a52f887ae1514751fd88012dc44798ab Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Tue, 23 Sep 2025 05:06:16 +0300 Subject: [PATCH 24/71] refactor: Remove unused gRPC and TCP server implementations --- cmd/grpcserver/main.go | 96 --------------------------------------- cmd/tcpserver/main.go | 101 ----------------------------------------- 2 files changed, 197 deletions(-) delete mode 100644 cmd/grpcserver/main.go delete mode 100644 cmd/tcpserver/main.go diff --git a/cmd/grpcserver/main.go b/cmd/grpcserver/main.go deleted file mode 100644 index d022b9b..0000000 --- a/cmd/grpcserver/main.go +++ /dev/null @@ -1,96 +0,0 @@ -package main - -import ( - "flag" - "fmt" - "log" - "net" - "os" - "os/signal" - "syscall" - - "google.golang.org/grpc" - "google.golang.org/grpc/health" - "google.golang.org/grpc/health/grpc_health_v1" - "google.golang.org/grpc/reflection" -) - -var port = flag.Int("port", 50051, "The server port") - -func main() { - flag.Parse() - - if err := run(); err != nil { - log.Fatalf("Failed to run server: %v", err) - } -} - -func run() error { - addr := fmt.Sprintf("localhost:%d", *port) - log.Printf("Starting gRPC server on %s\n", addr) - - lis, err := net.Listen("tcp", addr) - if err != nil { - return fmt.Errorf("failed to listen: %w", err) - } - - // Create gRPC server - grpcServer := grpc.NewServer() - - // Create and register health server - healthServer := health.NewServer() - grpc_health_v1.RegisterHealthServer(grpcServer, healthServer) - - // Register gRPC reflection service for debugging - reflection.Register(grpcServer) - - // Set initial serving status - healthServer.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING) - healthServer.SetServingStatus("health", grpc_health_v1.HealthCheckResponse_SERVING) - healthServer.SetServingStatus("test-service", grpc_health_v1.HealthCheckResponse_SERVING) - - // Start server in a goroutine - serverErr := make(chan error, 1) - go func() { - if err := grpcServer.Serve(lis); err != nil { - serverErr <- fmt.Errorf("failed to serve: %w", err) - } - }() - - // Wait for interrupt signal or server error - sigChan := make(chan os.Signal, 1) - signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) - - select { - case <-sigChan: - // Graceful shutdown - case err := <-serverErr: - return err - } - - // Graceful shutdown - grpcServer.GracefulStop() - - return nil -} - -// simulateServiceStatusChanges simulates service status changes for testing -// func simulateServiceStatusChanges(healthServer *health.Server) { -// ticker := time.NewTicker(30 * time.Second) -// defer ticker.Stop() - -// status := grpc_health_v1.HealthCheckResponse_SERVING - -// for range ticker.C { -// // Toggle service status for testing -// if status == grpc_health_v1.HealthCheckResponse_SERVING { -// status = grpc_health_v1.HealthCheckResponse_NOT_SERVING -// log.Println("Setting test-service status to NOT_SERVING") -// } else { -// status = grpc_health_v1.HealthCheckResponse_SERVING -// log.Println("Setting test-service status to SERVING") -// } - -// healthServer.SetServingStatus("test-service", status) -// } -// } diff --git a/cmd/tcpserver/main.go b/cmd/tcpserver/main.go deleted file mode 100644 index 4d9d91a..0000000 --- a/cmd/tcpserver/main.go +++ /dev/null @@ -1,101 +0,0 @@ -package main - -import ( - "bufio" - "fmt" - "net" - "time" - - "github.com/sxwebdev/sentinel/internal/utils" -) - -func main() { - if err := run(); err != nil { - fmt.Println("Error:", err) - } -} - -func run() error { - listener, err := net.Listen("tcp", "127.0.0.1:12345") - if err != nil { - return fmt.Errorf("failed to start server: %w", err) - } - defer listener.Close() - - fmt.Println("Server is listening on 127.0.0.1:12345") - - for { - conn, err := listener.Accept() - if err != nil { - fmt.Println("Failed to accept connection:", err) - continue - } - fmt.Println("Client connected:", conn.RemoteAddr()) - go handleConnection(conn) - } -} - -func handleConnection(conn net.Conn) { - defer func() { - conn.Close() - fmt.Printf("Connection closed: %s\n\n", conn.RemoteAddr()) - }() - - // Set connection timeout - close if no data received in 5 seconds - conn.SetReadDeadline(time.Now().Add(5 * time.Second)) - - var accumulated []byte - reader := bufio.NewReader(conn) - - for { - buffer := make([]byte, 1024) - n, err := reader.Read(buffer) - if err != nil { - if utils.IsErrTimeout(err) { - break - } - - fmt.Println("failed to read from connection:", err) - return - } - - if n == 0 { - break - } - - // Accumulate received data - accumulated = append(accumulated, buffer[:n]...) - - // If no more data buffered, client likely finished sending - if reader.Buffered() == 0 { - break - } - } - - // Now process the complete message - if len(accumulated) == 0 { - fmt.Println("No data received from client") - return - } - - msg := string(accumulated) - fmt.Println("Complete message received:", msg) - - // Simple ping-pong protocol - exact match - switch msg { - case "ping": - fmt.Println("Sending pong") - _, err := conn.Write([]byte("pong")) - if err != nil { - fmt.Println("Failed to send response:", err) - } - case "noresponse": - fmt.Println("No response expected") - default: - fmt.Printf("Unknown message '%s', sending ok\n", msg) - _, err := conn.Write([]byte("ok")) - if err != nil { - fmt.Println("Failed to send response:", err) - } - } -} From b18a4063738c034d0232d642dae3ca842022ce2c Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Tue, 23 Sep 2025 05:37:31 +0300 Subject: [PATCH 25/71] feat: Implement service monitoring with checker and job management --- internal/checker/checker.go | 371 ++++++++++++++++++++++++++++++ internal/checker/errors.go | 6 + internal/checker/job.go | 26 +++ internal/scheduler/scheduler.go | 389 +++++--------------------------- internal/web/release_checker.go | 2 +- 5 files changed, 464 insertions(+), 330 deletions(-) create mode 100644 internal/checker/checker.go create mode 100644 internal/checker/errors.go create mode 100644 internal/checker/job.go diff --git a/internal/checker/checker.go b/internal/checker/checker.go new file mode 100644 index 0000000..73e4680 --- /dev/null +++ b/internal/checker/checker.go @@ -0,0 +1,371 @@ +package checker + +import ( + "context" + "errors" + "fmt" + "io" + "sync" + "time" + + "github.com/puzpuzpuz/xsync/v3" + "github.com/sxwebdev/sentinel/internal/models" + "github.com/sxwebdev/sentinel/internal/monitors" + "github.com/sxwebdev/sentinel/internal/receiver" + "github.com/tkcrm/mx/logger" +) + +type Checker struct { + logger logger.Logger + + receiver *receiver.Receiver + + // Job management, key is service ID + jobs *xsync.MapOf[string, *job] + wg sync.WaitGroup + + onSuccessFn func(ctx context.Context, serviceID string, responseTime time.Duration) error + onFailureFn func(ctx context.Context, serviceID string, checkErr error, responseTime time.Duration) error +} + +func New( + l logger.Logger, + receiver *receiver.Receiver, + onSuccessFn func(ctx context.Context, serviceID string, responseTime time.Duration) error, + onFailureFn func(ctx context.Context, serviceID string, checkErr error, responseTime time.Duration) error, +) *Checker { + return &Checker{ + logger: l, + receiver: receiver, + jobs: xsync.NewMapOf[string, *job](), + onSuccessFn: onSuccessFn, + onFailureFn: onFailureFn, + } +} + +// Name returns the name of the service +func (c *Checker) Name() string { + return "checker" +} + +// Start begins monitoring all configured services +func (s *Checker) Start(ctx context.Context) error { + errChan := make(chan error, 1) + go func() { + errChan <- s.subscribeEvents(ctx) + }() + + select { + case err := <-errChan: + return err + case <-ctx.Done(): + } + + return nil +} + +func (s *Checker) Stop(ctx context.Context) error { + s.stopAll() + s.wg.Wait() + return nil +} + +// stopAll stops monitoring for all services +func (s *Checker) stopAll() { + s.jobs.Range(func(key string, value *job) bool { + // Cancel any ongoing checks + if value.checkCancel != nil { + value.checkCancel() + } + + select { + case <-value.stopChan: + // Channel already closed + default: + close(value.stopChan) + } + if value.ticker != nil { + value.ticker.Stop() + } + return true + }) +} + +type AddServiceParams struct { + ID string + Name string + Protocol models.ServiceProtocolType + IsEnabled bool + Interval time.Duration + Timeout time.Duration + Retries int64 + Config map[string]any +} + +// AddService adds a service to be monitored +func (s *Checker) AddService(ctx context.Context, svc AddServiceParams) { + // Only add enabled services to monitoring + if !svc.IsEnabled { + s.logger.Warnf("Skipping disabled service: %s (ID: %s)", svc.Name, svc.ID) + return + } + + // Create new service job with minimal info + checkCtx, checkCancel := context.WithCancel(ctx) + job := &job{ + serviceID: svc.ID, + serviceName: svc.Name, + serviceProtocol: svc.Protocol, + interval: svc.Interval, + timeout: svc.Timeout, + retries: svc.Retries, + serviceConfig: svc.Config, + stopChan: make(chan struct{}), + checkCtx: checkCtx, + checkCancel: checkCancel, + } + + s.addJob(ctx, job) +} + +// addJob adds a new job to the scheduler +func (s *Checker) addJob(ctx context.Context, job *job) { + // Check if job already exists + if existingJob, exists := s.jobs.Load(job.serviceID); exists { + // Cancel any ongoing checks for the existing job + if existingJob.checkCancel != nil { + existingJob.checkCancel() + } + + // Stop existing job gracefully + select { + case <-existingJob.stopChan: + // Channel already closed + default: + close(existingJob.stopChan) + } + if existingJob.ticker != nil { + existingJob.ticker.Stop() + } + } + + // Store the new job + s.jobs.Store(job.serviceID, job) + + // Start monitoring in a new goroutine + s.wg.Go(func() { + s.monitorService(ctx, job) + }) +} + +// removeJob removes a service dynamically (for runtime removals) +func (s *Checker) removeJob(serviceID string) error { + job, exists := s.jobs.Load(serviceID) + if !exists { + return ErrServiceNotFound + } + + // Cancel any ongoing checks for this job + if job.checkCancel != nil { + job.checkCancel() + } + + // Stop the monitoring gracefully + select { + case <-job.stopChan: + // Channel already closed + default: + close(job.stopChan) + } + if job.ticker != nil { + job.ticker.Stop() + } + + // Remove from services map + s.jobs.Delete(serviceID) + + return nil +} + +// monitorService runs the monitoring loop for a single service +func (s *Checker) monitorService(ctx context.Context, job *job) { + // Create ticker for regular checks + job.ticker = time.NewTicker(job.interval) + defer job.ticker.Stop() + + // Perform initial check + if err := s.performCheck(job); err != nil { + s.logger.Errorf("Error performing initial check for service %s: %v", job.serviceName, err) + return + } + + for { + select { + case <-ctx.Done(): + return + case <-job.stopChan: + return + case <-job.ticker.C: + if err := s.performCheck(job); err != nil && !errors.Is(err, context.Canceled) { + s.logger.Errorf("error performing check for service %s: %v", job.serviceName, err) + continue + } + } + } +} + +// checkService manually triggers a check for a specific service +func (s *Checker) checkService(serviceID string) error { + job, exists := s.jobs.Load(serviceID) + if !exists { + return ErrServiceNotFound + } + + return s.performCheck(job) +} + +func (s *Checker) subscribeEvents(ctx context.Context) error { + broker := s.receiver.TriggerService() + sub := broker.Subscribe() + defer broker.Unsubscribe(sub) + + for ctx.Err() == nil { + select { + case item := <-sub: + switch item.EventType { + case receiver.TriggerServiceEventTypeCheck: + if err := s.checkService(item.Svc.ID); err != nil { + s.logger.Errorf("check service error: %v", err) + } + case receiver.TriggerServiceEventTypeCreated: + s.AddService(ctx, AddServiceParams{ + ID: item.Svc.ID, + Name: item.Svc.Name, + Protocol: item.Svc.Protocol, + IsEnabled: item.Svc.IsEnabled, + Interval: item.Svc.Interval, + Timeout: item.Svc.Timeout, + Retries: item.Svc.Retries, + Config: item.Svc.Config, + }) + case receiver.TriggerServiceEventTypeUpdated: + // Check if service was disabled + if !item.Svc.IsEnabled { + // Remove from monitoring if service is now disabled + if err := s.removeJob(item.Svc.ID); err != nil { + s.logger.Errorf("remove disabled service error: %v", err) + } + } else { + // Update or add to monitoring if service is enabled + s.AddService(ctx, AddServiceParams{ + ID: item.Svc.ID, + Name: item.Svc.Name, + Protocol: item.Svc.Protocol, + IsEnabled: item.Svc.IsEnabled, + Interval: item.Svc.Interval, + Timeout: item.Svc.Timeout, + Retries: item.Svc.Retries, + Config: item.Svc.Config, + }) + } + case receiver.TriggerServiceEventTypeDeleted: + if err := s.removeJob(item.Svc.ID); err != nil { + s.logger.Errorf("remove service error: %v", err) + } + } + + case <-ctx.Done(): + return nil + } + } + + return nil +} + +// performCheck executes a health check for a service +func (s *Checker) performCheck(job *job) error { + if !job.inProgress.CompareAndSwap(false, true) { + // Another check is already in progress + return nil + } + defer job.inProgress.Store(false) + + // Check if job context is cancelled (handles job updates/deletions) + if job.checkCtx.Err() != nil { + return job.checkCtx.Err() + } + + // Create monitor for this check + monitor, err := monitors.NewMonitor(monitors.MonitorParams{ + ServiceName: job.serviceName, + Protocol: job.serviceProtocol, + Timeout: job.timeout, + Config: job.serviceConfig, + }) + if err != nil { + return fmt.Errorf("failed to create monitor for %s: %w", job.serviceName, err) + } + + // Ensure monitor resources are cleaned up + defer func() { + if closer, ok := monitor.(io.Closer); ok { + if err := closer.Close(); err != nil { + s.logger.Errorf("Error closing monitor for %s: %v", job.serviceName, err) + } + } + }() + + // Perform the check with retries + var lastErr error + var lastAttemptResponseTime time.Duration + + for attempt := int64(1); attempt <= job.retries; attempt++ { + // Create context with timeout for this specific check + attemptCtx, cancel := context.WithTimeout(job.checkCtx, job.timeout) + + // Measure time for this specific attempt + attemptStartTime := time.Now() + err := monitor.Check(attemptCtx) + attemptResponseTime := time.Since(attemptStartTime) + lastAttemptResponseTime = attemptResponseTime + + // Cancel context immediately after use to avoid memory leak + cancel() + + if err == nil { + if attempt == 1 { + s.logger.Debugf("service %s check successful in %v", job.serviceName, attemptResponseTime) + } else { + s.logger.Debugf("service %s check successful (attempt %d/%d) in %v", job.serviceName, attempt, job.retries, attemptResponseTime) + } + + if err := s.onSuccessFn(job.checkCtx, job.serviceID, attemptResponseTime); err != nil { + return err + } + + return nil + } + + lastErr = err + + // If not the last attempt, wait a bit before retrying + if attempt < job.retries { + // Check if job context is cancelled before retrying + select { + case <-job.checkCtx.Done(): + // Job context cancelled, stop retrying + return job.checkCtx.Err() + case <-time.After(time.Millisecond * 500 * time.Duration(attempt)): + // Exponential backoff - continue to next attempt + } + } + + s.logger.Debugf("service %s check failed (attempt %d/%d): %s", job.serviceName, attempt, job.retries, err) + } + + if err := s.onFailureFn(job.checkCtx, job.serviceID, lastErr, lastAttemptResponseTime); err != nil { + return err + } + + return nil +} diff --git a/internal/checker/errors.go b/internal/checker/errors.go new file mode 100644 index 0000000..39f18ac --- /dev/null +++ b/internal/checker/errors.go @@ -0,0 +1,6 @@ +package checker + +import "errors" + +// ErrServiceNotFound is returned when a service is not found +var ErrServiceNotFound = errors.New("service not found") diff --git a/internal/checker/job.go b/internal/checker/job.go new file mode 100644 index 0000000..db07136 --- /dev/null +++ b/internal/checker/job.go @@ -0,0 +1,26 @@ +package checker + +import ( + "context" + "sync/atomic" + "time" + + "github.com/sxwebdev/sentinel/internal/models" +) + +// job represents a scheduled monitoring job for a service +type job struct { + serviceID string + serviceName string + serviceConfig map[string]any + serviceProtocol models.ServiceProtocolType + interval time.Duration + timeout time.Duration + retries int64 + ticker *time.Ticker + stopChan chan struct{} + inProgress atomic.Bool + // Context and cancel function for canceling ongoing checks + checkCtx context.Context + checkCancel context.CancelFunc +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 5f36f68..2027050 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -2,16 +2,12 @@ package scheduler import ( "context" - "errors" "fmt" - "io" "strings" - "sync" "time" - "github.com/puzpuzpuz/xsync/v3" + "github.com/sxwebdev/sentinel/internal/checker" "github.com/sxwebdev/sentinel/internal/models" - "github.com/sxwebdev/sentinel/internal/monitors" "github.com/sxwebdev/sentinel/internal/receiver" "github.com/sxwebdev/sentinel/internal/services/baseservices" "github.com/sxwebdev/sentinel/internal/services/incidents" @@ -23,19 +19,14 @@ import ( "github.com/tkcrm/mx/logger" ) -// ErrServiceNotFound is returned when a service is not found -var ErrServiceNotFound = fmt.Errorf("service not found") - // Scheduler manages the monitoring of multiple services type Scheduler struct { logger logger.Logger + checker *checker.Checker + receiver *receiver.Receiver baseservices *baseservices.BaseServices - - // Job management, key is service ID - jobs *xsync.MapOf[string, *job] - wg sync.WaitGroup } // New creates a new scheduler @@ -44,12 +35,20 @@ func New( receiver *receiver.Receiver, baseServices *baseservices.BaseServices, ) *Scheduler { - return &Scheduler{ + s := &Scheduler{ logger: l, receiver: receiver, baseservices: baseServices, - jobs: xsync.NewMapOf[string, *job](), } + + s.checker = checker.New( + l, + receiver, + s.onSuccess, + s.onFailure, + ) + + return s } // Name returns the name of the scheduler @@ -70,245 +69,35 @@ func (s *Scheduler) Start(ctx context.Context) error { // Get all services under read lock for _, svc := range services.Items { - s.addService(ctx, svc) - } - - errChan := make(chan error, 1) - go func() { - errChan <- s.subscribeEvents(ctx) - }() - - select { - case err := <-errChan: - return err - case <-ctx.Done(): - } - - return nil + s.checker.AddService(ctx, checker.AddServiceParams{ + ID: svc.ID, + Name: svc.Name, + Protocol: svc.Protocol, + IsEnabled: svc.IsEnabled, + Interval: svc.Interval, + Timeout: svc.Timeout, + Retries: svc.Retries, + Config: svc.Config, + }) + } + + return s.checker.Start(ctx) } func (s *Scheduler) Stop(ctx context.Context) error { - s.stopAll() - s.wg.Wait() - return nil -} - -// stopAll stops monitoring for all services -func (s *Scheduler) stopAll() { - s.jobs.Range(func(key string, value *job) bool { - // Cancel any ongoing checks - if value.checkCancel != nil { - value.checkCancel() - } - - select { - case <-value.stopChan: - // Channel already closed - default: - close(value.stopChan) - } - if value.ticker != nil { - value.ticker.Stop() - } - return true - }) -} - -// addService adds a service to be monitored -func (s *Scheduler) addService(ctx context.Context, svc *models.ServiceFullView) { - // Only add enabled services to monitoring - if !svc.IsEnabled { - s.logger.Warnf("Skipping disabled service: %s (ID: %s)", svc.Name, svc.ID) - return - } - - // Create new service job with minimal info - checkCtx, checkCancel := context.WithCancel(ctx) - job := &job{ - serviceID: svc.ID, - serviceName: svc.Name, - interval: svc.Interval, - timeout: svc.Timeout, - retries: svc.Retries, - stopChan: make(chan struct{}), - checkCtx: checkCtx, - checkCancel: checkCancel, - } - - s.addJob(ctx, job) + return s.checker.Stop(ctx) } -// addJob adds a new job to the scheduler -func (s *Scheduler) addJob(ctx context.Context, job *job) { - // Check if job already exists - if existingJob, exists := s.jobs.Load(job.serviceID); exists { - // Cancel any ongoing checks for the existing job - if existingJob.checkCancel != nil { - existingJob.checkCancel() - } - - // Stop existing job gracefully - select { - case <-existingJob.stopChan: - // Channel already closed - default: - close(existingJob.stopChan) - } - if existingJob.ticker != nil { - existingJob.ticker.Stop() - } - } - - // Store the new job - s.jobs.Store(job.serviceID, job) - - // Start monitoring in a new goroutine - s.wg.Go(func() { - s.monitorService(ctx, job) - }) -} - -// monitorService runs the monitoring loop for a single service -func (s *Scheduler) monitorService(ctx context.Context, job *job) { - // Create ticker for regular checks - job.ticker = time.NewTicker(job.interval) - defer job.ticker.Stop() - - // Perform initial check - if err := s.performCheck(job); err != nil { - s.logger.Errorf("Error performing initial check for service %s: %v", job.serviceName, err) - return - } - - for { - select { - case <-ctx.Done(): - return - case <-job.stopChan: - return - case <-job.ticker.C: - if err := s.performCheck(job); err != nil && !errors.Is(err, context.Canceled) { - s.logger.Errorf("error performing check for service %s: %v", job.serviceName, err) - continue - } - } - } -} - -// performCheck executes a health check for a service -func (s *Scheduler) performCheck(job *job) error { - if !job.inProgress.CompareAndSwap(false, true) { - // Another check is already in progress - return nil - } - defer job.inProgress.Store(false) - - // Check if job context is cancelled (handles job updates/deletions) - if job.checkCtx.Err() != nil { - return job.checkCtx.Err() - } - - serviceName := job.serviceName - - // Get current service configuration from database - service, err := s.baseservices.Services().GetByID(job.checkCtx, job.serviceID) - if err != nil { - return fmt.Errorf("failed to get service %s: %w", serviceName, err) +// onSuccess is called when a service check succeeds +func (s *Scheduler) onSuccess(ctx context.Context, serviceID string, responseTime time.Duration) error { + // Success - record the time of this successful attempt + if err := s.recordSuccess(ctx, serviceID, responseTime); err != nil { + return fmt.Errorf("failed to record success: %w", err) } - svcConfig, err := service.GetConfig() + svc, err := s.baseservices.Services().GetViewByID(ctx, serviceID) if err != nil { - return fmt.Errorf("failed to parse service config for %s: %w", serviceName, err) - } - - // Create monitor for this check - monitor, err := monitors.NewMonitor(monitors.MonitorParams{ - ServiceName: service.Name, - Protocol: service.Protocol, - Timeout: service.Timeout.ToDuration(), - Config: svcConfig, - }) - if err != nil { - return fmt.Errorf("failed to create monitor for %s: %w", serviceName, err) - } - - // Ensure monitor resources are cleaned up - defer func() { - if closer, ok := monitor.(io.Closer); ok { - if err := closer.Close(); err != nil { - s.logger.Errorf("Error closing monitor for %s: %v", serviceName, err) - } - } - }() - - // Perform the check with retries - var lastErr error - var lastAttemptResponseTime time.Duration - - for attempt := int64(1); attempt <= job.retries; attempt++ { - // Create context with timeout for this specific check - attemptCtx, cancel := context.WithTimeout(job.checkCtx, job.timeout) - - // Measure time for this specific attempt - attemptStartTime := time.Now() - err := monitor.Check(attemptCtx) - attemptResponseTime := time.Since(attemptStartTime) - lastAttemptResponseTime = attemptResponseTime - - // Cancel context immediately after use to avoid memory leak - cancel() - - if err == nil { - // Success - record the time of this successful attempt - if err := s.recordSuccess(job.checkCtx, job.serviceID, attemptResponseTime); err != nil { - return fmt.Errorf("failed to record success for %s: %w", serviceName, err) - } - - if attempt == 1 { - s.logger.Debugf("service %s check successful in %v", serviceName, attemptResponseTime) - } else { - s.logger.Debugf("service %s check successful (attempt %d/%d) in %v", serviceName, attempt, job.retries, attemptResponseTime) - } - - svc, err := s.baseservices.Services().GetViewByID(job.checkCtx, job.serviceID) - if err != nil { - return fmt.Errorf("failed to get service view for %s: %w", serviceName, err) - } - - // Publish update to receiver - s.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( - receiver.TriggerServiceEventTypeUpdatedState, - svc, - )) - - return nil - } - - lastErr = err - - // If not the last attempt, wait a bit before retrying - if attempt < job.retries { - // Check if job context is cancelled before retrying - select { - case <-job.checkCtx.Done(): - // Job context cancelled, stop retrying - return job.checkCtx.Err() - case <-time.After(time.Millisecond * 500 * time.Duration(attempt)): - // Exponential backoff - continue to next attempt - } - } - - s.logger.Debugf("service %s check failed (attempt %d/%d): %s", serviceName, attempt, job.retries, err) - } - - // All attempts failed - record the time of the last attempt - if err := s.recordFailure(job.checkCtx, job.serviceID, lastErr, lastAttemptResponseTime); err != nil { - return fmt.Errorf("failed to record failure for %s: %w", serviceName, err) - } - - svc, err := s.baseservices.Services().GetViewByID(job.checkCtx, job.serviceID) - if err != nil { - return fmt.Errorf("failed to get service view for %s: %w", serviceName, err) + return fmt.Errorf("failed to get service view: %w", err) } // Publish update to receiver @@ -320,81 +109,23 @@ func (s *Scheduler) performCheck(job *job) error { return nil } -// checkService manually triggers a check for a specific service -func (s *Scheduler) checkService(serviceID string) error { - job, exists := s.jobs.Load(serviceID) - if !exists { - return ErrServiceNotFound - } - - return s.performCheck(job) -} - -// removeJob removes a service dynamically (for runtime removals) -func (s *Scheduler) removeJob(serviceID string) error { - job, exists := s.jobs.Load(serviceID) - if !exists { - return ErrServiceNotFound - } - - // Cancel any ongoing checks for this job - if job.checkCancel != nil { - job.checkCancel() +// onFailure is called when a service check fails +func (s *Scheduler) onFailure(ctx context.Context, serviceID string, checkErr error, responseTime time.Duration) error { + // All attempts failed - record the time of the last attempt + if err := s.recordFailure(ctx, serviceID, checkErr, responseTime); err != nil { + return fmt.Errorf("failed to record failure: %w", err) } - // Stop the monitoring gracefully - select { - case <-job.stopChan: - // Channel already closed - default: - close(job.stopChan) - } - if job.ticker != nil { - job.ticker.Stop() + svc, err := s.baseservices.Services().GetViewByID(ctx, serviceID) + if err != nil { + return fmt.Errorf("failed to get service view: %w", err) } - // Remove from services map - s.jobs.Delete(serviceID) - - return nil -} - -func (s *Scheduler) subscribeEvents(ctx context.Context) error { - broker := s.receiver.TriggerService() - sub := broker.Subscribe() - defer broker.Unsubscribe(sub) - - for ctx.Err() == nil { - select { - case item := <-sub: - switch item.EventType { - case receiver.TriggerServiceEventTypeCheck: - if err := s.checkService(item.Svc.ID); err != nil { - s.logger.Errorf("check service error: %v", err) - } - case receiver.TriggerServiceEventTypeCreated: - s.addService(ctx, item.Svc) - case receiver.TriggerServiceEventTypeUpdated: - // Check if service was disabled - if !item.Svc.IsEnabled { - // Remove from monitoring if service is now disabled - if err := s.removeJob(item.Svc.ID); err != nil { - s.logger.Errorf("remove disabled service error: %v", err) - } - } else { - // Update or add to monitoring if service is enabled - s.addService(ctx, item.Svc) - } - case receiver.TriggerServiceEventTypeDeleted: - if err := s.removeJob(item.Svc.ID); err != nil { - s.logger.Errorf("remove service error: %v", err) - } - } - - case <-ctx.Done(): - return nil - } - } + // Publish update to receiver + s.receiver.TriggerService().Publish(*receiver.NewTriggerServiceData( + receiver.TriggerServiceEventTypeUpdatedState, + svc, + )) return nil } @@ -445,12 +176,6 @@ func (m *Scheduler) recordSuccess(ctx context.Context, serviceID string, respons // RecordFailure records a failed check for a service func (m *Scheduler) recordFailure(ctx context.Context, serviceID string, checkErr error, responseTime time.Duration) error { - // Get current service from database - service, err := m.baseservices.Services().GetViewByID(ctx, serviceID) - if err != nil { - return fmt.Errorf("service %s not found in database: %w", serviceID, err) - } - // Get current service state serviceState, err := m.baseservices.ServiceStates().GetByServiceID(ctx, serviceID) if err != nil { @@ -484,12 +209,12 @@ func (m *Scheduler) recordFailure(ctx context.Context, serviceID string, checkEr // Save to database if _, err := m.baseservices.ServiceStates().Update(ctx, serviceState.ID, updateParams); err != nil { - return fmt.Errorf("failed to update service state for %s: %w", service.Name, err) + return fmt.Errorf("failed to update service state: %w", err) } // Create incident if service was up before if wasUp { - if err := m.createIncident(ctx, service, checkErr); err != nil { + if err := m.createIncident(ctx, serviceID, checkErr); err != nil { return fmt.Errorf("failed to create incident: %w", err) } } @@ -498,20 +223,26 @@ func (m *Scheduler) recordFailure(ctx context.Context, serviceID string, checkEr } // createIncident creates a new incident when a service goes down -func (m *Scheduler) createIncident(ctx context.Context, svc *models.ServiceFullView, err error) error { +func (m *Scheduler) createIncident(ctx context.Context, serviceID string, err error) error { // Save incident to storage incident, err := m.baseservices.Incidents().Create(ctx, incidents.CreateParams{ - ServiceID: svc.ID, + ServiceID: serviceID, Error: err.Error(), }) if err != nil { - return fmt.Errorf("failed to save incident for %s: %w", svc.Name, err) + return fmt.Errorf("failed to save incident: %w", err) + } + + // Get current service from database + svc, err := m.baseservices.Services().GetViewByID(ctx, serviceID) + if err != nil { + return fmt.Errorf("service %s not found in database: %w", serviceID, err) } // Send alert notification message := m.formatAlertMessage(svc, incident) - if err := m.baseservices.Notifications().History().SendAlert(ctx, svc.ID, incident.ID, message); err != nil { - m.logger.Errorf("failed to send alert notification for %s: %v", svc.Name, err) + if err := m.baseservices.Notifications().History().SendAlert(ctx, serviceID, incident.ID, message); err != nil { + m.logger.Errorf("failed to send alert notification: %v", err) } return nil diff --git a/internal/web/release_checker.go b/internal/web/release_checker.go index e361e9e..c92b6e9 100644 --- a/internal/web/release_checker.go +++ b/internal/web/release_checker.go @@ -14,7 +14,7 @@ import ( // checkNewVersion checks if a new version is available from github releases func (s *Server) checkNewVersionWrapper(ctx context.Context) { - ticker := time.NewTicker(time.Minute * 30) + ticker := time.NewTicker(time.Hour * 1) defer ticker.Stop() go func() { From 5c8e737e5e3bc66c21276a1590a2be3c7e0aa92b Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Tue, 23 Sep 2025 14:08:59 +0300 Subject: [PATCH 26/71] fix: Update Shoutrrr import path to the correct repository --- README.md | 2 +- go.mod | 2 +- go.sum | 24 +++++++++++++---------- internal/services/notifications/sender.go | 2 +- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 4b2c887..60252b9 100644 --- a/README.md +++ b/README.md @@ -218,7 +218,7 @@ The gRPC monitor supports three types of checks: ## Notification Setup -Sentinel uses [Shoutrrr](https://github.com/containrrr/shoutrrr) for notifications, which supports multiple providers +Sentinel uses [Shoutrrr](https://github.com/nicholas-fedor/shoutrrr) for notifications, which supports multiple providers You can configure multiple notification providers simultaneously. If one provider fails, notifications will still be sent to the others: diff --git a/go.mod b/go.mod index f77421c..89cfc10 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,6 @@ require ( connectrpc.com/connect v1.18.1 connectrpc.com/cors v0.1.0 github.com/Masterminds/semver/v3 v3.4.0 - github.com/containrrr/shoutrrr v0.8.0 github.com/dop251/goja v0.0.0-20250630131328-58d95d85e994 github.com/dromara/carbon/v2 v2.6.12 github.com/georgysavva/scany/v2 v2.1.4 @@ -16,6 +15,7 @@ require ( github.com/gofiber/contrib/websocket v1.3.4 github.com/gofiber/fiber/v2 v2.52.9 github.com/huandu/go-sqlbuilder v1.37.0 + github.com/nicholas-fedor/shoutrrr v0.9.1 github.com/oklog/ulid/v2 v2.1.1 github.com/puzpuzpuz/xsync/v3 v3.5.1 github.com/rs/cors v1.11.1 diff --git a/go.sum b/go.sum index be6c459..4445604 100644 --- a/go.sum +++ b/go.sum @@ -21,8 +21,6 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UF github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cockroachdb/cockroach-go/v2 v2.2.0 h1:/5znzg5n373N/3ESjHF5SMLxiW4RKB05Ql//KWfeTFs= github.com/cockroachdb/cockroach-go/v2 v2.2.0/go.mod h1:u3MiKYGupPPjkn3ozknpMUpxPaNLTFWAya419/zv6eI= -github.com/containrrr/shoutrrr v0.8.0 h1:mfG2ATzIS7NR2Ec6XL+xyoHzN97H8WPjir8aYzJUSec= -github.com/containrrr/shoutrrr v0.8.0/go.mod h1:ioyQAyu1LJY6sILuNyKaQaw+9Ttik5QePU8atnAdO2o= github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -102,8 +100,8 @@ github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHO github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= github.com/go-sourcemap/sourcemap v2.1.4+incompatible h1:a+iTbH5auLKxaNwQFg0B+TCYl6lbukKPc7b5x0n1s6Q= github.com/go-sourcemap/sourcemap v2.1.4+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= -github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/gobeam/stringy v0.0.7 h1:TD8SfhedUoiANhW88JlJqfrMsihskIRpU/VTsHGnAps= github.com/gobeam/stringy v0.0.7/go.mod h1:W3620X9dJHf2FSZF5fRnWekHcHQjwmCz8ZQ2d1qloqE= github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4= @@ -148,8 +146,8 @@ github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk= github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= -github.com/jarcoal/httpmock v1.3.0 h1:2RJ8GP0IIaWwcC9Fp2BmVi8Kog3v2Hn7VXM3fTd+nuc= -github.com/jarcoal/httpmock v1.3.0/go.mod h1:3yb8rc4BI7TCBhFY8ng0gjuLKJNquuDNiPaZjnENuYg= +github.com/jarcoal/httpmock v1.4.1 h1:0Ju+VCFuARfFlhVXFc2HxlcQkfB+Xq12/EotHko+x2A= +github.com/jarcoal/httpmock v1.4.1/go.mod h1:ftW1xULwo+j0R0JJkJIIi7UKigZUXCLLanykgjwBXL0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= @@ -190,13 +188,15 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/nicholas-fedor/shoutrrr v0.9.1 h1:SEBhM6P1favzILO0f55CY3P9JwvM9RZ7B1ZMCl+Injs= +github.com/nicholas-fedor/shoutrrr v0.9.1/go.mod h1:khue5m8LYyMzdPWuJxDTJeT89l9gjwjA+a+r0e8qxxk= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= -github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU= -github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts= -github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= -github.com/onsi/gomega v1.27.6/go.mod h1:PIQNjfQwkP3aQAH7lf7j87O/5FiNr+ZR8+ipb+qQlhg= +github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw= +github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE= +github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= +github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= github.com/otiai10/copy v1.7.0/go.mod h1:rmRl6QPdJj6EiUqXQ/4Nn2lLXoNQjFCQbbNrxgc/t3U= github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= github.com/otiai10/curr v1.0.0/go.mod h1:LskTG5wDwr8Rs+nNQ+1LlxRjAtTZZjtJW4rMXl6j4vs= @@ -305,6 +305,8 @@ go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6 go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= +go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs= +go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -313,6 +315,8 @@ go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= diff --git a/internal/services/notifications/sender.go b/internal/services/notifications/sender.go index 63cfe45..55353ce 100644 --- a/internal/services/notifications/sender.go +++ b/internal/services/notifications/sender.go @@ -5,7 +5,7 @@ import ( "fmt" "time" - "github.com/containrrr/shoutrrr" + "github.com/nicholas-fedor/shoutrrr" "github.com/sxwebdev/sentinel/internal/models" "github.com/sxwebdev/sentinel/internal/store" "github.com/sxwebdev/sentinel/internal/store/repos/repo_notification_history" From c4b1b01162d4db43935316139e402e3363c2e807 Mon Sep 17 00:00:00 2001 From: sxwebdev Date: Tue, 23 Sep 2025 14:33:30 +0300 Subject: [PATCH 27/71] refactor: Remove timeout configurations from HTTP monitoring and related tests --- cmd/testapi/extended_tests.go | 5 ----- cmd/testapi/main.go | 3 --- cmd/testapi/model_tests.go | 10 --------- docs/docsv1/docs.go | 4 ---- docs/docsv1/swagger.json | 4 ---- docs/docsv1/swagger.yaml | 3 --- frontend/src/features/service/serviceForm.tsx | 15 +++++-------- .../pages/service/hooks/useServiceCreate.ts | 1 - .../shared/types/model/monitorsHTTPConfig.ts | 1 - internal/monitors/http.go | 1 - internal/scheduler/job.go | 22 ------------------- 11 files changed, 6 insertions(+), 63 deletions(-) delete mode 100644 internal/scheduler/job.go diff --git a/cmd/testapi/extended_tests.go b/cmd/testapi/extended_tests.go index f81a3c9..3721cb2 100644 --- a/cmd/testapi/extended_tests.go +++ b/cmd/testapi/extended_tests.go @@ -89,7 +89,6 @@ func testServiceCRUDCompleteFlow(s *TestSuite) error { Tags: []string{"complex", "test", "http"}, Config: monitors.Config{ HTTP: &monitors.HTTPConfig{ - Timeout: 8000, Endpoints: []monitors.EndpointConfig{ { Name: "Main API", @@ -284,7 +283,6 @@ func testAdvancedIncidentManagement(s *TestSuite) error { Tags: []string{"incident-test"}, Config: monitors.Config{ HTTP: &monitors.HTTPConfig{ - Timeout: 5000, Endpoints: []monitors.EndpointConfig{ { Name: "Test Endpoint", @@ -596,7 +594,6 @@ func testAdvancedPaginationAndSorting(s *TestSuite) error { Tags: []string{fmt.Sprintf("page-test-%d", i%3), "pagination"}, Config: monitors.Config{ HTTP: &monitors.HTTPConfig{ - Timeout: 5000, Endpoints: []monitors.EndpointConfig{ { Name: "Test Endpoint", @@ -792,7 +789,6 @@ func testAdvancedErrorScenarios(s *TestSuite) error { Retries: 3, Config: monitors.Config{ HTTP: &monitors.HTTPConfig{ - Timeout: 30000, Endpoints: []monitors.EndpointConfig{ { Name: "test", @@ -879,7 +875,6 @@ func testStatsWithDifferentParameters(s *TestSuite) error { Tags: []string{"stats-test"}, Config: monitors.Config{ HTTP: &monitors.HTTPConfig{ - Timeout: 5000, Endpoints: []monitors.EndpointConfig{ { Name: "Test Endpoint", diff --git a/cmd/testapi/main.go b/cmd/testapi/main.go index 9824e4d..2440502 100644 --- a/cmd/testapi/main.go +++ b/cmd/testapi/main.go @@ -53,7 +53,6 @@ var testServices = []TestService{ Tags: []string{"http", "production", "api"}, Config: monitors.Config{ HTTP: &monitors.HTTPConfig{ - Timeout: 5000, Endpoints: []monitors.EndpointConfig{ { Name: "Health Check", @@ -72,7 +71,6 @@ var testServices = []TestService{ Tags: []string{"http", "staging", "web"}, Config: monitors.Config{ HTTP: &monitors.HTTPConfig{ - Timeout: 3000, Endpoints: []monitors.EndpointConfig{ { Name: "Home Page", @@ -115,7 +113,6 @@ var testServices = []TestService{ Tags: []string{"disabled", "test"}, Config: monitors.Config{ HTTP: &monitors.HTTPConfig{ - Timeout: 5000, Endpoints: []monitors.EndpointConfig{ { Name: "Test Endpoint", diff --git a/cmd/testapi/model_tests.go b/cmd/testapi/model_tests.go index 8fab81d..fbcb846 100644 --- a/cmd/testapi/model_tests.go +++ b/cmd/testapi/model_tests.go @@ -16,7 +16,6 @@ func testModelsValidation(s *TestSuite) error { // Test HTTP config validation httpConfig := monitors.Config{ HTTP: &monitors.HTTPConfig{ - Timeout: 5000, Endpoints: []monitors.EndpointConfig{ { Name: "Valid Endpoint", @@ -44,7 +43,6 @@ func testModelsValidation(s *TestSuite) error { // Test invalid HTTP config - missing endpoints invalidHTTPConfig := monitors.Config{ HTTP: &monitors.HTTPConfig{ - Timeout: 5000, Endpoints: []monitors.EndpointConfig{}, // Empty endpoints }, } @@ -56,7 +54,6 @@ func testModelsValidation(s *TestSuite) error { // Test invalid HTTP config - invalid endpoint invalidEndpointConfig := monitors.Config{ HTTP: &monitors.HTTPConfig{ - Timeout: 5000, Endpoints: []monitors.EndpointConfig{ { Name: "", // Empty name @@ -126,7 +123,6 @@ func testModelsValidation(s *TestSuite) error { // Test config with wrong protocol httpConfigForTCP := monitors.Config{ HTTP: &monitors.HTTPConfig{ - Timeout: 5000, Endpoints: []monitors.EndpointConfig{ { Name: "Test", @@ -156,7 +152,6 @@ func testServiceDTOFields(s *TestSuite) error { Tags: []string{"dto", "test", "validation"}, Config: monitors.Config{ HTTP: &monitors.HTTPConfig{ - Timeout: 5000, Endpoints: []monitors.EndpointConfig{ { Name: "Test Endpoint", @@ -211,9 +206,6 @@ func testServiceDTOFields(s *TestSuite) error { if service.Config.HTTP == nil { return fmt.Errorf("HTTP config should not be nil") } - if service.Config.HTTP.Timeout != testService.Config.HTTP.Timeout { - return fmt.Errorf("HTTP config timeout mismatch") - } if len(service.Config.HTTP.Endpoints) != len(testService.Config.HTTP.Endpoints) { return fmt.Errorf("HTTP endpoints count mismatch") } @@ -360,7 +352,6 @@ func testResponseModels(s *TestSuite) error { Tags: []string{"test", "response-models"}, Config: monitors.Config{ HTTP: &monitors.HTTPConfig{ - Timeout: 30000, Endpoints: []monitors.EndpointConfig{ { Name: "test", @@ -480,7 +471,6 @@ func testServiceStatsModel(s *TestSuite) error { Tags: []string{"stats-model-test"}, Config: monitors.Config{ HTTP: &monitors.HTTPConfig{ - Timeout: 5000, Endpoints: []monitors.EndpointConfig{ { Name: "Test Endpoint", diff --git a/docs/docsv1/docs.go b/docs/docsv1/docs.go index f8dcd56..5263115 100644 --- a/docs/docsv1/docs.go +++ b/docs/docsv1/docs.go @@ -1342,10 +1342,6 @@ const docTemplate = `{ "items": { "$ref": "#/definitions/monitors.EndpointConfig" } - }, - "timeout": { - "type": "integer", - "example": 30000 } } }, diff --git a/docs/docsv1/swagger.json b/docs/docsv1/swagger.json index d0efaf8..c72e9dd 100644 --- a/docs/docsv1/swagger.json +++ b/docs/docsv1/swagger.json @@ -1335,10 +1335,6 @@ "items": { "$ref": "#/definitions/monitors.EndpointConfig" } - }, - "timeout": { - "type": "integer", - "example": 30000 } } }, diff --git a/docs/docsv1/swagger.yaml b/docs/docsv1/swagger.yaml index 0801aa4..ebcc47c 100644 --- a/docs/docsv1/swagger.yaml +++ b/docs/docsv1/swagger.yaml @@ -184,9 +184,6 @@ definitions: $ref: '#/definitions/monitors.EndpointConfig' minItems: 1 type: array - timeout: - example: 30000 - type: integer required: - endpoints type: object diff --git a/frontend/src/features/service/serviceForm.tsx b/frontend/src/features/service/serviceForm.tsx index 8c10305..5a05a05 100644 --- a/frontend/src/features/service/serviceForm.tsx +++ b/frontend/src/features/service/serviceForm.tsx @@ -239,24 +239,21 @@ const HTTPForm = React.memo(