From 9baae65816c295bc81b7d512f6cb3e7cffff898f Mon Sep 17 00:00:00 2001 From: Jianyun8023 Date: Mon, 17 Nov 2025 21:34:19 +0800 Subject: [PATCH] Implement Prometheus metrics support in Scrutiny - Added configuration options for enabling Prometheus metrics in `example.scrutiny.yaml` and `config.go`. - Introduced a new `metrics` package to handle metrics collection and registration. - Created a `ScrutinyCollector` to gather device metrics and expose them via a `/metrics` endpoint. - Updated the web server to conditionally register the metrics endpoint based on configuration. - Implemented caching for device details to optimize metrics collection. - Added tests for utility functions related to metrics sanitization and parsing. This commit enhances the monitoring capabilities of Scrutiny by integrating Prometheus metrics support. --- example.scrutiny.yaml | 5 + go.mod | 19 +- go.sum | 45 +-- webapp/backend/pkg/config/config.go | 10 +- webapp/backend/pkg/metrics/collector.go | 261 ++++++++++++++++++ webapp/backend/pkg/metrics/utils.go | 63 +++++ webapp/backend/pkg/metrics/utils_test.go | 218 +++++++++++++++ webapp/backend/pkg/models/metrics/types.go | 15 + webapp/backend/pkg/web/handler/get_metrics.go | 23 ++ .../pkg/web/handler/upload_device_metrics.go | 8 + webapp/backend/pkg/web/middleware/metrics.go | 14 + webapp/backend/pkg/web/server.go | 50 +++- 12 files changed, 700 insertions(+), 31 deletions(-) create mode 100644 webapp/backend/pkg/metrics/collector.go create mode 100644 webapp/backend/pkg/metrics/utils.go create mode 100644 webapp/backend/pkg/metrics/utils_test.go create mode 100644 webapp/backend/pkg/models/metrics/types.go create mode 100644 webapp/backend/pkg/web/handler/get_metrics.go create mode 100644 webapp/backend/pkg/web/middleware/metrics.go diff --git a/example.scrutiny.yaml b/example.scrutiny.yaml index 039bcbf0..a32eb15f 100644 --- a/example.scrutiny.yaml +++ b/example.scrutiny.yaml @@ -47,6 +47,11 @@ web: # org: 'my-org' # bucket: 'bucket' retention_policy: true + + # Prometheus metrics endpoint configuration + metrics: + # Enable or disable Prometheus metrics endpoint (/api/metrics) + enabled: true # if you wish to disable TLS certificate verification, # when using self-signed certificates for example, # then uncomment the lines below and set `insecure_skip_verify: true` diff --git a/go.mod b/go.mod index bc60bf6d..e1a234da 100644 --- a/go.mod +++ b/go.mod @@ -13,17 +13,20 @@ require ( github.com/influxdata/influxdb-client-go/v2 v2.9.0 github.com/jaypipes/ghw v0.6.1 github.com/mitchellh/mapstructure v1.5.0 + github.com/prometheus/client_golang v1.17.0 github.com/samber/lo v1.25.0 github.com/sirupsen/logrus v1.6.0 github.com/spf13/viper v1.15.0 github.com/stretchr/testify v1.8.1 github.com/urfave/cli/v2 v2.2.0 - golang.org/x/sync v0.1.0 + golang.org/x/sync v0.3.0 gorm.io/gorm v1.23.5 ) require ( github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/deepmap/oapi-codegen v1.8.2 // indirect @@ -49,12 +52,16 @@ require ( github.com/magiconair/properties v1.8.7 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.18 // indirect + github.com/matttproud/golang_protobuf_extensions v1.0.4 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/pelletier/go-toml/v2 v2.0.6 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 // indirect + github.com/prometheus/common v0.44.0 // indirect + github.com/prometheus/procfs v0.11.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/spf13/afero v1.9.3 // indirect @@ -65,11 +72,11 @@ require ( github.com/ugorji/go/codec v1.1.7 // indirect golang.org/x/crypto v0.1.0 // indirect golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect - golang.org/x/net v0.8.0 // indirect - golang.org/x/sys v0.7.0 // indirect - golang.org/x/term v0.6.0 // indirect - golang.org/x/text v0.8.0 // indirect - google.golang.org/protobuf v1.28.1 // indirect + golang.org/x/net v0.10.0 // indirect + golang.org/x/sys v0.11.0 // indirect + golang.org/x/term v0.8.0 // indirect + golang.org/x/text v0.9.0 // indirect + google.golang.org/protobuf v1.31.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 5985bb87..72c2d863 100644 --- a/go.sum +++ b/go.sum @@ -44,7 +44,11 @@ github.com/StackExchange/wmi v0.0.0-20190523213315-cbe66965904d/go.mod h1:3eOhrU github.com/analogj/go-util v0.0.0-20190301173314-5295e364eb14 h1:wsrSjiqQtseStRIoLLxS4C5IEtXkazZVEPDHq8jW7r8= github.com/analogj/go-util v0.0.0-20190301173314-5295e364eb14/go.mod h1:lJQVqFKMV5/oDGYR2bra2OljcF3CvolAoyDRyOA4k4E= github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= +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/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= +github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -274,7 +278,7 @@ github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJ github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= @@ -313,6 +317,8 @@ github.com/mattn/go-isatty v0.0.18/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= github.com/mattn/go-sqlite3 v1.14.12 h1:TJ1bhYJPV44phC+IMu1u2K/i5RriLTPe+yc68XDJ1Z0= github.com/mattn/go-sqlite3 v1.14.12/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= +github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo= +github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4= github.com/mitchellh/go-homedir v1.0.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= @@ -324,7 +330,6 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= -github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU= github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE= github.com/pelletier/go-toml/v2 v2.0.6 h1:nrzqCb7j9cDFj2coyLNLaZuJTLjWjlaz6nvTvIwycIU= @@ -336,11 +341,19 @@ github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINE github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= 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/prometheus/client_golang v1.17.0 h1:rl2sfwZMtSthVU752MqfjQozy7blglC+1SOtjMAMh+Q= +github.com/prometheus/client_golang v1.17.0/go.mod h1:VeL+gMmOAxkS2IqfCq0ZmHSL+LjWfWDUmp1mBz9JgUY= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16 h1:v7DLqVdK4VrYkVD5diGdl4sxJurKJEMnODWRJlxV9oM= +github.com/prometheus/client_model v0.4.1-0.20230718164431-9a2bf3000d16/go.mod h1:oMQmHW1/JoDwqLtg57MGgP/Fb1CJEYF2imWWhWtMkYU= +github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdOOfY= +github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= +github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= +github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0 h1:OdAsTTz6OkFY5QxjkYwrChwuRruF69c169dPK26NUlk= github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= -github.com/rogpeppe/go-internal v1.6.1 h1:/FiVV8dS/e+YqF2JvO3yXRFbBLTIuSDkuC7aBOAvL+k= +github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rs/xid v1.2.1/go.mod h1:+uKXf+4Djp6Md1KODXJxgGQPKngRmWyn10oCKFzNHOQ= github.com/rs/zerolog v1.13.0/go.mod h1:YbFCdg8HfsridGWAh22vktObvhZbQsZXe4/zB0OKkWU= github.com/rs/zerolog v1.15.0/go.mod h1:xYTKnLHcpfU2225ny5qZjxnj9NvkumZYjJHlAThCjNc= @@ -501,8 +514,8 @@ golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20210119194325-5f4716e94777/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= -golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= +golang.org/x/net v0.10.0 h1:X2//UzNDwYmtCLn7To6G58Wr6f5ahEAQgKNzv9Y951M= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -523,8 +536,8 @@ golang.org/x/sync v0.0.0-20200625203802-6e8e738ad208/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/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.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0 h1:ftCYgMx6zT/asHUrPw8BLLscYtGznsLAnjq5RH9P66E= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -577,12 +590,12 @@ golang.org/x/sys v0.0.0-20220405052023-b1e9470b6e64/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= -golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM= +golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.6.0 h1:clScbb1cHjoCkyRbWwBEUZ5H/tIFu5TAXIqaZD0Gcjw= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= +golang.org/x/term v0.8.0 h1:n5xxQn2i3PC0yLAbjTpNT85q/Kgzcr2gIoX9OrJUols= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -590,8 +603,8 @@ golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.8.0 h1:57P1ETyNKtuIjB4SRd15iJxuhj8Gc416Y78H3qgMh68= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= @@ -748,11 +761,11 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.28.1 h1:d0NfwRgPtno5B1Wa6L2DAG+KivqkdutMf1UhdNx175w= -google.golang.org/protobuf v1.28.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= +google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8= +google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/inconshreveable/log15.v2 v2.0.0-20180818164646-67afb5ed74ec/go.mod h1:aPpfJ7XW+gOuirDoZ8gHhLh3kZ1B08FtV2bbmy7Jv3s= gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= diff --git a/webapp/backend/pkg/config/config.go b/webapp/backend/pkg/config/config.go index 605de860..f42921f9 100644 --- a/webapp/backend/pkg/config/config.go +++ b/webapp/backend/pkg/config/config.go @@ -1,12 +1,13 @@ package config import ( - "github.com/analogj/go-util/utils" - "github.com/analogj/scrutiny/webapp/backend/pkg/errors" - "github.com/spf13/viper" "log" "os" "strings" + + "github.com/analogj/go-util/utils" + "github.com/analogj/scrutiny/webapp/backend/pkg/errors" + "github.com/spf13/viper" ) const DB_USER_SETTINGS_SUBKEY = "user" @@ -52,6 +53,9 @@ func (c *configuration) Init() error { c.SetDefault("web.influxdb.tls.insecure_skip_verify", false) c.SetDefault("web.influxdb.retention_policy", true) + // Metrics settings + c.SetDefault("web.metrics.enabled", true) + //c.SetDefault("disks.include", []string{}) //c.SetDefault("disks.exclude", []string{}) diff --git a/webapp/backend/pkg/metrics/collector.go b/webapp/backend/pkg/metrics/collector.go new file mode 100644 index 00000000..b5b0e4c3 --- /dev/null +++ b/webapp/backend/pkg/metrics/collector.go @@ -0,0 +1,261 @@ +package metrics + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/analogj/scrutiny/webapp/backend/pkg/database" + "github.com/analogj/scrutiny/webapp/backend/pkg/models" + "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements" + metricsModels "github.com/analogj/scrutiny/webapp/backend/pkg/models/metrics" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/collectors" + "github.com/sirupsen/logrus" +) + +// Collector manages Prometheus metrics for all devices +type Collector struct { + mu sync.RWMutex + devices map[string]*metricsModels.DeviceMetricsData // key: wwn + registry *prometheus.Registry + logger *logrus.Entry +} + +// NewCollector creates a new metrics collector +func NewCollector(logger *logrus.Entry) *Collector { + mc := &Collector{ + devices: make(map[string]*metricsModels.DeviceMetricsData), + registry: prometheus.NewRegistry(), + logger: logger, + } + + // Register Go runtime metrics (memory, GC, goroutines, etc.) + mc.registry.MustRegister(collectors.NewGoCollector()) + + // Register custom device metrics collector + mc.registry.MustRegister(mc) + return mc +} + +// UpdateDeviceMetrics updates device metrics (called from UploadDeviceMetrics) +func (mc *Collector) UpdateDeviceMetrics(wwn string, device models.Device, smartData measurements.Smart) { + mc.mu.Lock() + defer mc.mu.Unlock() + + mc.devices[wwn] = &metricsModels.DeviceMetricsData{ + Device: device, + SmartData: smartData, + UpdatedAt: time.Now(), + } + mc.logger.Debugf("Updated metrics for device %s", wwn) +} + +// LoadInitialData loads initial data from database (called at startup) +func (mc *Collector) LoadInitialData(deviceRepo database.DeviceRepo, ctx context.Context) error { + start := time.Now() + mc.logger.Info("Loading initial metrics data from database...") + + // Get device summary + summary, err := deviceRepo.GetSummary(ctx) + if err != nil { + return fmt.Errorf("failed to load device summary: %w", err) + } + + // Concurrently fetch latest SMART data for each device + smartDataMap := make(map[string][]measurements.Smart) + var wg sync.WaitGroup + var mu sync.Mutex + + for wwn := range summary { + wg.Add(1) + go func(w string) { + defer wg.Done() + smarts, err := deviceRepo.GetSmartAttributeHistory(ctx, w, "forever", 1, 0, nil) + if err == nil && len(smarts) > 0 { + mu.Lock() + smartDataMap[w] = smarts + mu.Unlock() + } + }(wwn) + } + + wg.Wait() + + // Load into memory + mc.mu.Lock() + defer mc.mu.Unlock() + + for wwn, deviceSummary := range summary { + if smartResults, ok := smartDataMap[wwn]; ok && len(smartResults) > 0 { + mc.devices[wwn] = &metricsModels.DeviceMetricsData{ + Device: deviceSummary.Device, + SmartData: smartResults[0], + UpdatedAt: time.Now(), + } + } + } + + mc.logger.Infof("Loaded metrics for %d devices in %v", len(mc.devices), time.Since(start)) + return nil +} + +// GetRegistry returns the Prometheus registry +func (mc *Collector) GetRegistry() *prometheus.Registry { + return mc.registry +} + +// Describe implements prometheus.Collector interface +func (mc *Collector) Describe(ch chan<- *prometheus.Desc) { + // Dynamic metrics, no need to pre-describe +} + +// Collect implements prometheus.Collector interface +func (mc *Collector) Collect(ch chan<- prometheus.Metric) { + start := time.Now() + mc.mu.RLock() + defer mc.mu.RUnlock() + + mc.collectDeviceInfo(ch) + mc.collectDeviceCapacity(ch) + mc.collectDeviceStatus(ch) + mc.collectSmartAttributes(ch) + mc.collectSummaryMetrics(ch) + mc.collectStatistics(ch) + + mc.logger.Debugf("Metrics collected in %v for %d devices", time.Since(start), len(mc.devices)) +} + +// collectDeviceInfo generates device information metrics +func (mc *Collector) collectDeviceInfo(ch chan<- prometheus.Metric) { + for wwn, data := range mc.devices { + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc("scrutiny_device_info", "Device information", + []string{"wwn", "device_name", "model_name", "serial_number", + "firmware", "protocol", "host_id", "form_factor"}, nil), + prometheus.GaugeValue, 1, + wwn, data.Device.DeviceName, data.Device.ModelName, + data.Device.SerialNumber, data.Device.Firmware, + data.Device.DeviceProtocol, data.Device.HostId, data.Device.FormFactor, + ) + } +} + +// collectDeviceCapacity generates device capacity metrics +func (mc *Collector) collectDeviceCapacity(ch chan<- prometheus.Metric) { + for wwn, data := range mc.devices { + if data.Device.Capacity > 0 { + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc("scrutiny_device_capacity_bytes", "Device capacity in bytes", + []string{"wwn", "device_name", "model_name", "protocol", "host_id"}, nil), + prometheus.GaugeValue, float64(data.Device.Capacity), + wwn, data.Device.DeviceName, data.Device.ModelName, + data.Device.DeviceProtocol, data.Device.HostId, + ) + } + } +} + +// collectDeviceStatus generates device status metrics +func (mc *Collector) collectDeviceStatus(ch chan<- prometheus.Metric) { + for wwn, data := range mc.devices { + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc("scrutiny_device_status", "Device status (0=passed, 1=failed)", + []string{"wwn", "device_name", "model_name", "protocol", "host_id"}, nil), + prometheus.GaugeValue, float64(data.Device.DeviceStatus), + wwn, data.Device.DeviceName, data.Device.ModelName, + data.Device.DeviceProtocol, data.Device.HostId, + ) + } +} + +// collectSmartAttributes generates SMART attribute metrics +func (mc *Collector) collectSmartAttributes(ch chan<- prometheus.Metric) { + for wwn, data := range mc.devices { + baseLabels := []string{wwn, data.Device.DeviceName, data.Device.ModelName, + data.Device.DeviceProtocol, data.Device.HostId} + + for attrID, attr := range data.SmartData.Attributes { + attrLabels := append(baseLabels, attrID) + flattenedAttrs := attr.Flatten() + + for key, value := range flattenedAttrs { + metricName := SanitizeMetricName(key) + if floatVal, ok := TryParseFloat(value); ok { + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc(metricName, fmt.Sprintf("SMART attribute %s", key), + []string{"wwn", "device_name", "model_name", "protocol", "host_id", "attribute_id"}, nil), + prometheus.GaugeValue, floatVal, attrLabels..., + ) + } + } + } + } +} + +// collectSummaryMetrics generates summary metrics +func (mc *Collector) collectSummaryMetrics(ch chan<- prometheus.Metric) { + for wwn, data := range mc.devices { + labels := []string{wwn, data.Device.DeviceName, data.Device.ModelName, + data.Device.DeviceProtocol, data.Device.HostId} + + if data.SmartData.Temp > 0 { + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc("scrutiny_smart_temperature_celsius", + "Device temperature in Celsius", + []string{"wwn", "device_name", "model_name", "protocol", "host_id"}, nil), + prometheus.GaugeValue, float64(data.SmartData.Temp), labels..., + ) + } + + if data.SmartData.PowerOnHours > 0 { + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc("scrutiny_smart_power_on_hours", "Device power on hours", + []string{"wwn", "device_name", "model_name", "protocol", "host_id"}, nil), + prometheus.GaugeValue, float64(data.SmartData.PowerOnHours), labels..., + ) + } + + if data.SmartData.PowerCycleCount > 0 { + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc("scrutiny_smart_power_cycle_count", "Device power cycle count", + []string{"wwn", "device_name", "model_name", "protocol", "host_id"}, nil), + prometheus.GaugeValue, float64(data.SmartData.PowerCycleCount), labels..., + ) + } + + timestampMs := float64(data.SmartData.Date.Unix() * 1000) + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc("scrutiny_smart_collector_timestamp", + "Timestamp of last data collection", + []string{"wwn", "device_name", "model_name", "protocol", "host_id"}, nil), + prometheus.GaugeValue, timestampMs, labels..., + ) + } +} + +// collectStatistics generates statistics metrics +func (mc *Collector) collectStatistics(ch chan<- prometheus.Metric) { + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc("scrutiny_devices_total", "Total number of monitored devices", nil, nil), + prometheus.GaugeValue, float64(len(mc.devices)), + ) + + protocolCount := make(map[string]int) + for _, data := range mc.devices { + protocol := data.Device.DeviceProtocol + if protocol == "" { + protocol = "unknown" + } + protocolCount[protocol]++ + } + + for protocol, count := range protocolCount { + ch <- prometheus.MustNewConstMetric( + prometheus.NewDesc("scrutiny_devices_by_protocol", "Number of devices by protocol", + []string{"protocol"}, nil), + prometheus.GaugeValue, float64(count), protocol, + ) + } +} diff --git a/webapp/backend/pkg/metrics/utils.go b/webapp/backend/pkg/metrics/utils.go new file mode 100644 index 00000000..3df0efea --- /dev/null +++ b/webapp/backend/pkg/metrics/utils.go @@ -0,0 +1,63 @@ +package metrics + +import ( + "strconv" + "strings" + + "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements" +) + +// SanitizeMetricName converts a string to a valid Prometheus metric name +// Example: converts "attr.5.raw_value" to "scrutiny_smart_attr_5_raw_value" +func SanitizeMetricName(name string) string { + name = strings.ReplaceAll(name, ".", "_") + name = strings.ReplaceAll(name, "-", "_") + name = strings.ReplaceAll(name, " ", "_") + name = strings.ToLower(name) + + if strings.HasPrefix(name, "attr_") { + return "scrutiny_smart_" + name + } + return name +} + +// TryParseFloat attempts to convert any type to float64 +// Supports: int, int64, float32, float64, string, hexadecimal strings +func TryParseFloat(value interface{}) (float64, bool) { + switch v := value.(type) { + case int: + return float64(v), true + case int64: + return float64(v), true + case float64: + return v, true + case float32: + return float64(v), true + case string: + if f, err := strconv.ParseFloat(v, 64); err == nil { + return f, true + } + // Try parsing hexadecimal + if strings.HasPrefix(v, "0x") || strings.HasPrefix(v, "0X") { + if i, err := strconv.ParseInt(v, 0, 64); err == nil { + return float64(i), true + } + } + } + return 0, false +} + +// SelectLatestSmartResult selects the latest SMART result from a list (by timestamp) +func SelectLatestSmartResult(smartResults []measurements.Smart) *measurements.Smart { + if len(smartResults) == 0 { + return nil + } + + latest := &smartResults[0] + for i := 1; i < len(smartResults); i++ { + if smartResults[i].Date.After(latest.Date) { + latest = &smartResults[i] + } + } + return latest +} diff --git a/webapp/backend/pkg/metrics/utils_test.go b/webapp/backend/pkg/metrics/utils_test.go new file mode 100644 index 00000000..e4f078f4 --- /dev/null +++ b/webapp/backend/pkg/metrics/utils_test.go @@ -0,0 +1,218 @@ +package metrics + +import ( + "testing" + "time" + + "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements" +) + +func TestSanitizeMetricName(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "convert dots to underscores", + input: "attr.5.raw_value", + expected: "scrutiny_smart_attr_5_raw_value", + }, + { + name: "convert hyphens to underscores", + input: "attr-5-raw-value", + expected: "scrutiny_smart_attr_5_raw_value", + }, + { + name: "convert spaces to underscores", + input: "attr 5 raw value", + expected: "scrutiny_smart_attr_5_raw_value", + }, + { + name: "convert to lowercase", + input: "Attr.5.Raw_Value", + expected: "scrutiny_smart_attr_5_raw_value", + }, + { + name: "already valid name", + input: "valid_metric_name", + expected: "valid_metric_name", + }, + { + name: "without attr prefix", + input: "some.metric.name", + expected: "some_metric_name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SanitizeMetricName(tt.input) + if result != tt.expected { + t.Errorf("SanitizeMetricName(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestTryParseFloat(t *testing.T) { + tests := []struct { + name string + input interface{} + expected float64 + shouldOk bool + }{ + { + name: "parse int", + input: 42, + expected: 42.0, + shouldOk: true, + }, + { + name: "parse int64", + input: int64(12345), + expected: 12345.0, + shouldOk: true, + }, + { + name: "parse float64", + input: 3.14159, + expected: 3.14159, + shouldOk: true, + }, + { + name: "parse float32", + input: float32(2.71), + expected: float64(float32(2.71)), // Account for float32 precision + shouldOk: true, + }, + { + name: "parse string number", + input: "123.45", + expected: 123.45, + shouldOk: true, + }, + { + name: "parse hexadecimal with 0x", + input: "0x1A", + expected: 26.0, + shouldOk: true, + }, + { + name: "parse hexadecimal with 0X", + input: "0XFF", + expected: 255.0, + shouldOk: true, + }, + { + name: "parse empty string", + input: "", + expected: 0, + shouldOk: false, + }, + { + name: "parse invalid string", + input: "not_a_number", + expected: 0, + shouldOk: false, + }, + { + name: "parse nil", + input: nil, + expected: 0, + shouldOk: false, + }, + { + name: "parse bool", + input: true, + expected: 0, + shouldOk: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, ok := TryParseFloat(tt.input) + if ok != tt.shouldOk { + t.Errorf("TryParseFloat(%v) ok = %v, want %v", tt.input, ok, tt.shouldOk) + } + if tt.shouldOk && result != tt.expected { + t.Errorf("TryParseFloat(%v) = %v, want %v", tt.input, result, tt.expected) + } + }) + } +} + +func TestSelectLatestSmartResult(t *testing.T) { + now := time.Now() + older := now.Add(-1 * time.Hour) + oldest := now.Add(-2 * time.Hour) + + tests := []struct { + name string + input []measurements.Smart + expected *time.Time + }{ + { + name: "empty list", + input: []measurements.Smart{}, + expected: nil, + }, + { + name: "single result", + input: []measurements.Smart{ + {Date: now}, + }, + expected: &now, + }, + { + name: "multiple results in order", + input: []measurements.Smart{ + {Date: now}, + {Date: older}, + {Date: oldest}, + }, + expected: &now, + }, + { + name: "multiple results out of order", + input: []measurements.Smart{ + {Date: older}, + {Date: now}, + {Date: oldest}, + }, + expected: &now, + }, + { + name: "multiple results reverse order", + input: []measurements.Smart{ + {Date: oldest}, + {Date: older}, + {Date: now}, + }, + expected: &now, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := SelectLatestSmartResult(tt.input) + + if tt.expected == nil { + if result != nil { + t.Errorf("SelectLatestSmartResult() = %v, want nil", result) + } + return + } + + if result == nil { + t.Errorf("SelectLatestSmartResult() = nil, want non-nil") + return + } + + if !result.Date.Equal(*tt.expected) { + t.Errorf("SelectLatestSmartResult() date = %v, want %v", result.Date, *tt.expected) + } + }) + } +} diff --git a/webapp/backend/pkg/models/metrics/types.go b/webapp/backend/pkg/models/metrics/types.go new file mode 100644 index 00000000..8dd5d9b4 --- /dev/null +++ b/webapp/backend/pkg/models/metrics/types.go @@ -0,0 +1,15 @@ +package metrics + +import ( + "time" + + "github.com/analogj/scrutiny/webapp/backend/pkg/models" + "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements" +) + +// DeviceMetricsData stores metrics data for a single device +type DeviceMetricsData struct { + Device models.Device `json:"device"` + SmartData measurements.Smart `json:"smart_data"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/webapp/backend/pkg/web/handler/get_metrics.go b/webapp/backend/pkg/web/handler/get_metrics.go new file mode 100644 index 00000000..0add2641 --- /dev/null +++ b/webapp/backend/pkg/web/handler/get_metrics.go @@ -0,0 +1,23 @@ +package handler + +import ( + "github.com/analogj/scrutiny/webapp/backend/pkg/metrics" + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/sirupsen/logrus" +) + +// GetMetrics handles Prometheus metrics endpoint +func GetMetrics(c *gin.Context) { + logger := c.MustGet("LOGGER").(*logrus.Entry) + collector, exists := c.MustGet("METRICS_COLLECTOR").(*metrics.Collector) + + if !exists || collector == nil { + logger.Errorln("Metrics collector not found in context") + c.String(500, "Metrics collector not initialized") + return + } + + handler := promhttp.HandlerFor(collector.GetRegistry(), promhttp.HandlerOpts{}) + handler.ServeHTTP(c.Writer, c.Request) +} diff --git a/webapp/backend/pkg/web/handler/upload_device_metrics.go b/webapp/backend/pkg/web/handler/upload_device_metrics.go index 08144338..682a658f 100644 --- a/webapp/backend/pkg/web/handler/upload_device_metrics.go +++ b/webapp/backend/pkg/web/handler/upload_device_metrics.go @@ -7,6 +7,7 @@ import ( "github.com/analogj/scrutiny/webapp/backend/pkg" "github.com/analogj/scrutiny/webapp/backend/pkg/config" "github.com/analogj/scrutiny/webapp/backend/pkg/database" + "github.com/analogj/scrutiny/webapp/backend/pkg/metrics" "github.com/analogj/scrutiny/webapp/backend/pkg/models/collector" "github.com/analogj/scrutiny/webapp/backend/pkg/notify" "github.com/gin-gonic/gin" @@ -90,5 +91,12 @@ func UploadDeviceMetrics(c *gin.Context) { _ = liveNotify.Send() //we ignore error message when sending notifications. } + // Update Prometheus metrics (if enabled) + if collectorVal, exists := c.Get("METRICS_COLLECTOR"); exists { + if collector, ok := collectorVal.(*metrics.Collector); ok && collector != nil { + collector.UpdateDeviceMetrics(c.Param("wwn"), updatedDevice, smartData) + } + } + c.JSON(http.StatusOK, gin.H{"success": true}) } diff --git a/webapp/backend/pkg/web/middleware/metrics.go b/webapp/backend/pkg/web/middleware/metrics.go new file mode 100644 index 00000000..373a6ee2 --- /dev/null +++ b/webapp/backend/pkg/web/middleware/metrics.go @@ -0,0 +1,14 @@ +package middleware + +import ( + "github.com/analogj/scrutiny/webapp/backend/pkg/metrics" + "github.com/gin-gonic/gin" +) + +// MetricsMiddleware injects metrics collector into gin context +func MetricsMiddleware(collector *metrics.Collector) gin.HandlerFunc { + return func(c *gin.Context) { + c.Set("METRICS_COLLECTOR", collector) + c.Next() + } +} diff --git a/webapp/backend/pkg/web/server.go b/webapp/backend/pkg/web/server.go index 38383c92..dad2a5ef 100644 --- a/webapp/backend/pkg/web/server.go +++ b/webapp/backend/pkg/web/server.go @@ -1,22 +1,27 @@ package web import ( + "context" "fmt" + "net/http" + "path/filepath" + "strings" + "github.com/analogj/go-util/utils" "github.com/analogj/scrutiny/webapp/backend/pkg/config" + "github.com/analogj/scrutiny/webapp/backend/pkg/database" "github.com/analogj/scrutiny/webapp/backend/pkg/errors" + "github.com/analogj/scrutiny/webapp/backend/pkg/metrics" "github.com/analogj/scrutiny/webapp/backend/pkg/web/handler" "github.com/analogj/scrutiny/webapp/backend/pkg/web/middleware" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" - "net/http" - "path/filepath" - "strings" ) type AppEngine struct { - Config config.Interface - Logger *logrus.Entry + Config config.Interface + Logger *logrus.Entry + MetricsCollector *metrics.Collector } func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine { @@ -25,6 +30,17 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine { r.Use(middleware.LoggerMiddleware(logger)) r.Use(middleware.RepositoryMiddleware(ae.Config, logger)) r.Use(middleware.ConfigMiddleware(ae.Config)) + + // Initialize metrics collector if enabled + if ae.Config.GetBool("web.metrics.enabled") { + if ae.MetricsCollector == nil { + ae.MetricsCollector = metrics.NewCollector(logger) + } + r.Use(middleware.MetricsMiddleware(ae.MetricsCollector)) + logger.Info("Prometheus metrics endpoint enabled") + } else { + logger.Info("Prometheus metrics endpoint disabled") + } r.Use(gin.Recovery()) basePath := ae.Config.GetString("web.listen.basepath") @@ -40,7 +56,13 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine { api.POST("/devices/register", handler.RegisterDevices) //used by Collector to register new devices and retrieve filtered list api.GET("/summary", handler.GetDevicesSummary) //used by Dashboard api.GET("/summary/temp", handler.GetDevicesSummaryTempHistory) //used by Dashboard (Temperature history dropdown) - api.POST("/device/:wwn/smart", handler.UploadDeviceMetrics) //used by Collector to upload data + + // Prometheus metrics endpoint (only registered if enabled) + if ae.Config.GetBool("web.metrics.enabled") { + api.GET("/metrics", handler.GetMetrics) + } + + api.POST("/device/:wwn/smart", handler.UploadDeviceMetrics) //used by Collector to upload data api.POST("/device/:wwn/selftest", handler.UploadDeviceSelfTests) api.GET("/device/:wwn/details", handler.GetDeviceDetails) //used by Details api.POST("/device/:wwn/archive", handler.ArchiveDevice) //used by UI to archive device @@ -83,5 +105,21 @@ func (ae *AppEngine) Start() error { r := ae.Setup(ae.Logger) + // Load initial metrics data asynchronously at startup (if metrics enabled) + if ae.Config.GetBool("web.metrics.enabled") && ae.MetricsCollector != nil { + go func() { + deviceRepo, err := database.NewScrutinyRepository(ae.Config, ae.Logger) + if err != nil { + ae.Logger.Errorln("Failed to create repository for loading metrics:", err) + return + } + defer deviceRepo.Close() + + if err := ae.MetricsCollector.LoadInitialData(deviceRepo, context.Background()); err != nil { + ae.Logger.Errorln("Failed to load initial metrics data:", err) + } + }() + } + return r.Run(fmt.Sprintf("%s:%s", ae.Config.GetString("web.listen.host"), ae.Config.GetString("web.listen.port"))) }