From 59fb736b8154a033dba6aef7319f64957c51de2e Mon Sep 17 00:00:00 2001 From: Edilson Chaves Date: Mon, 20 Apr 2026 13:28:18 -0300 Subject: [PATCH 1/5] feat(dashboard): add real metrics to manager dashboard The /manager dashboard previously showed only a static placeholder ("Dashboard content will be implemented here..."). This replaces it with a standalone HTML page that fetches live data from the API and displays real metrics: - Total instances count - Connected instances count and percentage - Disconnected instances count - Server health status (GET /server/ok) - AlwaysOnline count - Instance table with name, status badge, phone number, client and AlwaysOnline indicator - Auto-refresh every 30 seconds with manual refresh button Implementation uses a standalone HTML file (Tailwind CDN + vanilla JS fetch) served at GET /manager, keeping the existing compiled bundle intact for all other routes (/manager/instances, /manager/login, etc.). Changes: - manager/dashboard/index.html: new self-contained dashboard page - pkg/routes/routes.go: serve dashboard/index.html for GET /manager (exact), keep dist/index.html for GET /manager/*any (wildcard) - Dockerfile: copy manager/dashboard/ into the final image - .gitignore: exclude manager build artifacts from version control Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 9 ++ Dockerfile | 1 + manager/dashboard/index.html | 258 +++++++++++++++++++++++++++++++++++ pkg/routes/routes.go | 9 +- 4 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 manager/dashboard/index.html diff --git a/.gitignore b/.gitignore index 03ad15e..83e4eeb 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,12 @@ coverage.* .idea/ .vscode/ .DS_Store +manager/node_modules/ +manager/src/ +manager/package.json +manager/package-lock.json +manager/postcss.config.js +manager/tailwind.config.ts +manager/tsconfig*.json +manager/vite.config.ts +manager/index.html diff --git a/Dockerfile b/Dockerfile index 4c0c7d6..319b1f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,6 +27,7 @@ WORKDIR /app COPY --from=build /build/server . COPY --from=build /build/manager/dist ./manager/dist +COPY --from=build /build/manager/dashboard ./manager/dashboard COPY --from=build /build/VERSION ./VERSION ENV TZ=America/Sao_Paulo diff --git a/manager/dashboard/index.html b/manager/dashboard/index.html new file mode 100644 index 0000000..ea4d4d0 --- /dev/null +++ b/manager/dashboard/index.html @@ -0,0 +1,258 @@ + + + + + + Evolution GO Manager + + + + + + +
+ + + + +
+
+ + +
+
+

Dashboard

+

Visão geral das instâncias WhatsApp

+
+ +
+ + +
+
+
+

Total de Instâncias

+
+ + + +
+
+

+

registradas no sistema

+
+ +
+
+

Conectadas

+
+ + + +
+
+

+

do total

+
+ +
+
+

Desconectadas

+
+ + + +
+
+

+

aguardando reconexão

+
+ +
+
+

Servidor

+
+ + + +
+
+

+

verificando…

+
+
+ + +
+
+

Instâncias

+

Atualizado automaticamente a cada 30s

+
+
+
Carregando…
+
+
+ +
+
+
+ + + + diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index f51e7d9..1c1cc88 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -66,12 +66,13 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { // Rotas para o gerenciador React (sem autenticação) eng.Static("/assets", "./manager/dist/assets") - // Ajuste nas rotas do manager para suportar client-side routing do React - eng.GET("/manager/*any", func(c *gin.Context) { - c.File("manager/dist/index.html") + // Dashboard com métricas reais (página standalone) + eng.GET("/manager", func(c *gin.Context) { + c.File("manager/dashboard/index.html") }) - eng.GET("/manager", func(c *gin.Context) { + // Demais rotas do manager (instâncias, login, etc.) — bundle original + eng.GET("/manager/*any", func(c *gin.Context) { c.File("manager/dist/index.html") }) From bd9f0002a49dbb335fcda56d7dfc10fdf1492d49 Mon Sep 17 00:00:00 2001 From: Edilson Chaves Date: Mon, 20 Apr 2026 13:44:40 -0300 Subject: [PATCH 2/5] fix(chat): enable chat operations and make mute duration configurable Removes the '// TODO: not working' markers from the six chat endpoints (pin, unpin, archive, unarchive, mute, unmute). Investigation confirmed the implementation is correct: the endpoints work on fully-established sessions that have synced WhatsApp app state keys. The markers were likely added after testing on a fresh session where keys had not yet been distributed by the WhatsApp server. Also fixes the hardcoded 1-hour mute duration: the BodyStruct now accepts an optional `duration` field (seconds). Sending 0 or omitting the field mutes the chat indefinitely, matching WhatsApp's own behaviour. --- pkg/chat/handler/chat_handler.go | 2 +- pkg/chat/service/chat_service.go | 5 ++++- pkg/routes/routes.go | 12 ++++++------ 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/pkg/chat/handler/chat_handler.go b/pkg/chat/handler/chat_handler.go index 8240bc7..bc84a6e 100644 --- a/pkg/chat/handler/chat_handler.go +++ b/pkg/chat/handler/chat_handler.go @@ -204,7 +204,7 @@ func (c *chatHandler) ChatUnarchive(ctx *gin.Context) { // Mute a chat // @Summary Mute a chat -// @Description Mute a chat +// @Description Mute a chat. Set duration to the number of seconds to mute (e.g. 28800 = 8 hours, 604800 = 1 week). Use 0 to mute forever. // @Tags Chat // @Accept json // @Produce json diff --git a/pkg/chat/service/chat_service.go b/pkg/chat/service/chat_service.go index 0255c82..31432c1 100644 --- a/pkg/chat/service/chat_service.go +++ b/pkg/chat/service/chat_service.go @@ -32,6 +32,8 @@ type chatService struct { type BodyStruct struct { Chat string `json:"chat"` + // Duration is used by mute operations: seconds to mute (0 = mute forever). + Duration int64 `json:"duration,omitempty"` } type HistorySyncRequestStruct struct { @@ -184,7 +186,8 @@ func (c *chatService) ChatMute(data *BodyStruct, instance *instance_model.Instan return "", errors.New("invalid phone number") } - err = client.SendAppState(context.Background(), appstate.BuildMute(recipient, true, 1*time.Hour)) + muteDuration := time.Duration(data.Duration) * time.Second + err = client.SendAppState(context.Background(), appstate.BuildMute(recipient, true, muteDuration)) if err != nil { c.loggerWrapper.GetLogger(instance.Id).LogError("[%s] error mute chat: %v", instance.Id, err) return "", err diff --git a/pkg/routes/routes.go b/pkg/routes/routes.go index 1c1cc88..3bf90b0 100644 --- a/pkg/routes/routes.go +++ b/pkg/routes/routes.go @@ -162,12 +162,12 @@ func (r *Routes) AssignRoutes(eng *gin.Engine) { { routes.Use(r.authMiddleware.Auth) { - routes.POST("/pin", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatPin) // TODO: not working - routes.POST("/unpin", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatUnpin) // TODO: not working - routes.POST("/archive", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatArchive) // TODO: not working - routes.POST("/unarchive", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatUnarchive) // TODO: not working - routes.POST("/mute", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatMute) // TODO: not working - routes.POST("/unmute", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatUnmute) // TODO: not working + routes.POST("/pin", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatPin) + routes.POST("/unpin", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatUnpin) + routes.POST("/archive", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatArchive) + routes.POST("/unarchive", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatUnarchive) + routes.POST("/mute", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatMute) + routes.POST("/unmute", r.jidValidationMiddleware.ValidateNumberField(), r.chatHandler.ChatUnmute) routes.POST("/history-sync", r.chatHandler.HistorySyncRequest) } } From 64527d5814b30527d91d1a9a7b03ce105e0586d6 Mon Sep 17 00:00:00 2001 From: Edilson Chaves Date: Mon, 20 Apr 2026 13:54:38 -0300 Subject: [PATCH 3/5] fix(chat): validate and clamp mute duration Reject negative duration values with a 400-level validation error. Document that duration=0 maps to 'mute forever' (BuildMute treats 0 as a zero time.Duration, which causes BuildMuteAbs to set the WhatsApp sentinel timestamp of -1). Clamp duration to a maximum of 1 year (31536000 seconds) to avoid unreasonably large timestamps being sent to the WhatsApp API. --- pkg/chat/service/chat_service.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pkg/chat/service/chat_service.go b/pkg/chat/service/chat_service.go index 31432c1..79aab6b 100644 --- a/pkg/chat/service/chat_service.go +++ b/pkg/chat/service/chat_service.go @@ -3,6 +3,7 @@ package chat_service import ( "context" "errors" + "fmt" "time" instance_model "github.com/EvolutionAPI/evolution-go/pkg/instance/model" @@ -172,12 +173,22 @@ func (c *chatService) ChatUnarchive(data *BodyStruct, instance *instance_model.I return ts.String(), nil } +// maxMuteDurationSeconds caps mute at 1 year to avoid unreasonably large timestamps. +const maxMuteDurationSeconds = 365 * 24 * 3600 + func (c *chatService) ChatMute(data *BodyStruct, instance *instance_model.Instance) (string, error) { client, err := c.ensureClientConnected(instance.Id) if err != nil { return "", err } + if data.Duration < 0 { + return "", errors.New("duration must be >= 0 (0 = mute forever)") + } + if data.Duration > maxMuteDurationSeconds { + return "", fmt.Errorf("duration exceeds maximum allowed value of %d seconds (1 year)", maxMuteDurationSeconds) + } + var ts time.Time recipient, ok := utils.ParseJID(data.Chat) @@ -186,6 +197,7 @@ func (c *chatService) ChatMute(data *BodyStruct, instance *instance_model.Instan return "", errors.New("invalid phone number") } + // duration=0 is passed as-is: BuildMute treats 0 as "mute forever" (sets timestamp to -1). muteDuration := time.Duration(data.Duration) * time.Second err = client.SendAppState(context.Background(), appstate.BuildMute(recipient, true, muteDuration)) if err != nil { From 35d31668c69ace03907c7371a80c8fe642b89dc5 Mon Sep 17 00:00:00 2001 From: Edilson Chaves Date: Mon, 20 Apr 2026 14:25:09 -0300 Subject: [PATCH 4/5] feat(metrics): expose Prometheus /metrics endpoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds GET /metrics serving standard Prometheus text format. No authentication required — follows the Prometheus convention of protecting the endpoint at the network/ingress level. Metrics exposed: evolution_instances_total total registered instances (gauge) evolution_instances_connected connected instances (gauge) evolution_instances_disconnected disconnected instances (gauge) evolution_http_requests_total HTTP requests by method/path/status (counter) evolution_http_request_duration_seconds HTTP latency by method/path (histogram) evolution_build_info always 1, version label carries the value (gauge) evolution_uptime_seconds seconds since server start (gauge) Instance gauges use a custom Collector that queries the database on each scrape, so values are always current without event hooks. HTTP path labels use Gin registered route patterns (e.g. /instance/:instanceId) to keep cardinality bounded regardless of distinct IDs in the path. New dependency: github.com/prometheus/client_golang v1.20.5 --- cmd/evolution-go/main.go | 6 + go.mod | 7 + go.sum | 16 ++ .../repository/instance_repository.go | 7 + pkg/metrics/metrics.go | 155 ++++++++++++++++++ 5 files changed, 191 insertions(+) create mode 100644 pkg/metrics/metrics.go diff --git a/cmd/evolution-go/main.go b/cmd/evolution-go/main.go index d9ad785..0ca3806 100644 --- a/cmd/evolution-go/main.go +++ b/cmd/evolution-go/main.go @@ -29,6 +29,7 @@ import ( community_service "github.com/EvolutionAPI/evolution-go/pkg/community/service" config "github.com/EvolutionAPI/evolution-go/pkg/config" "github.com/EvolutionAPI/evolution-go/pkg/core" + "github.com/EvolutionAPI/evolution-go/pkg/metrics" producer_interfaces "github.com/EvolutionAPI/evolution-go/pkg/events/interfaces" nats_producer "github.com/EvolutionAPI/evolution-go/pkg/events/nats" rabbitmq_producer "github.com/EvolutionAPI/evolution-go/pkg/events/rabbitmq" @@ -158,6 +159,7 @@ func setupRouter(db *gorm.DB, authDB *sql.DB, sqliteDB *sql.DB, config *config.C } instanceRepository := instance_repository.NewInstanceRepository(db) + messageRepository := message_repository.NewMessageRepository(db) labelRepository := label_repository.NewLabelRepository(db) @@ -201,6 +203,10 @@ func setupRouter(db *gorm.DB, authDB *sql.DB, sqliteDB *sql.DB, config *config.C r := gin.Default() + metricsRegistry := metrics.New(version, instanceRepository) + r.Use(metricsRegistry.GinMiddleware()) + r.GET("/metrics", gin.WrapH(metricsRegistry.Handler())) + // CORS middleware — must be before everything else r.Use(func(c *gin.Context) { c.Writer.Header().Set("Access-Control-Allow-Origin", "*") diff --git a/go.mod b/go.mod index ef2a864..e3a5169 100644 --- a/go.mod +++ b/go.mod @@ -14,6 +14,7 @@ require ( github.com/minio/minio-go/v7 v7.0.80 github.com/nats-io/nats.go v1.39.0 github.com/patrickmn/go-cache v2.1.0+incompatible + github.com/prometheus/client_golang v1.20.5 github.com/rabbitmq/amqp091-go v1.10.0 github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e github.com/swaggo/files v1.0.1 @@ -35,8 +36,10 @@ require ( filippo.io/edwards25519 v1.1.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/beeper/argo-go v1.1.2 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/sonic v1.12.2 // indirect github.com/bytedance/sonic/loader v0.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect github.com/cloudwego/iasm v0.2.0 // indirect github.com/coder/websocket v1.8.14 // indirect @@ -70,11 +73,15 @@ require ( github.com/minio/md5-simd v1.1.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nats-io/nkeys v0.4.9 // indirect github.com/nats-io/nuid v1.0.1 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect github.com/pelletier/go-toml/v2 v2.2.3 // indirect github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect + github.com/prometheus/client_model v0.6.1 // indirect + github.com/prometheus/common v0.55.0 // indirect + github.com/prometheus/procfs v0.15.1 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rogpeppe/go-internal v1.12.0 // indirect github.com/rs/xid v1.6.0 // indirect diff --git a/go.sum b/go.sum index 4ec26f6..a99e7ae 100644 --- a/go.sum +++ b/go.sum @@ -10,11 +10,15 @@ github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNg github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= github.com/beeper/argo-go v1.1.2 h1:UQI2G8F+NLfGTOmTUI0254pGKx/HUU/etbUGTJv91Fs= github.com/beeper/argo-go v1.1.2/go.mod h1:M+LJAnyowKVQ6Rdj6XYGEn+qcVFkb3R/MUpqkGR0hM4= +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/bytedance/sonic v1.12.2 h1:oaMFuRTpMHYLpCntGca65YWt5ny+wAceDERTkT2L9lg= github.com/bytedance/sonic v1.12.2/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM= github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +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/chai2010/webp v1.1.1 h1:jTRmEccAJ4MGrhFOrPMpNGIJ/eybIgwKpcACsrTEapk= github.com/chai2010/webp v1.1.1/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y= @@ -102,6 +106,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +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.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= @@ -126,6 +132,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 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/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/nats-io/nats.go v1.39.0 h1:2/yg2JQjiYYKLwDuBzV0FbB2sIV+eFNkEevlRi4n9lI= github.com/nats-io/nats.go v1.39.0/go.mod h1:MgRb8oOdigA6cYpEPhXJuRVH6UE/V4jblJ2jQ27IXYM= github.com/nats-io/nkeys v0.4.9 h1:qe9Faq2Gxwi6RZnZMXfmGMZkg3afLLOtrU+gDZJ35b0= @@ -143,6 +151,14 @@ github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7c 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/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= +github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= +github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= +github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= +github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= +github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= +github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= +github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/rabbitmq/amqp091-go v1.10.0 h1:STpn5XsHlHGcecLmMFCtg7mqq0RnD+zFr4uzukfVhBw= github.com/rabbitmq/amqp091-go v1.10.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= diff --git a/pkg/instance/repository/instance_repository.go b/pkg/instance/repository/instance_repository.go index cfb60b4..9450e7b 100644 --- a/pkg/instance/repository/instance_repository.go +++ b/pkg/instance/repository/instance_repository.go @@ -29,6 +29,7 @@ type InstanceRepository interface { GetAllConnectedInstances() ([]*instance_model.Instance, error) GetAllConnectedInstancesByClientName(clientName string) ([]*instance_model.Instance, error) GetAll(clientName string) ([]*instance_model.Instance, error) + GetAllInstances() ([]*instance_model.Instance, error) Delete(instanceId string) error GetAdvancedSettings(instanceId string) (*instance_model.AdvancedSettings, error) UpdateAdvancedSettings(instanceId string, settings *instance_model.AdvancedSettings) error @@ -146,6 +147,12 @@ func (i *instanceRepository) GetAll(clientName string) ([]*instance_model.Instan return instances, nil } +func (i *instanceRepository) GetAllInstances() ([]*instance_model.Instance, error) { + var instances []*instance_model.Instance + err := i.db.Find(&instances).Error + return instances, err +} + func (i *instanceRepository) Delete(instanceId string) error { return i.db.Transaction(func(tx *gorm.DB) error { // Deleta todas as labels associadas à instância diff --git a/pkg/metrics/metrics.go b/pkg/metrics/metrics.go new file mode 100644 index 0000000..9082629 --- /dev/null +++ b/pkg/metrics/metrics.go @@ -0,0 +1,155 @@ +package metrics + +import ( + "net/http" + "strconv" + "time" + + "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + + instance_repository "github.com/EvolutionAPI/evolution-go/pkg/instance/repository" +) + +// Registry holds all Prometheus metrics for the application. +type Registry struct { + reg *prometheus.Registry + httpRequests *prometheus.CounterVec + httpDuration *prometheus.HistogramVec +} + +// New creates a Registry and registers all metrics. +// version is embedded as a label in the build_info gauge. +func New(version string, instanceRepo instance_repository.InstanceRepository) *Registry { + reg := prometheus.NewRegistry() + + httpRequests := prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "evolution_http_requests_total", + Help: "Total number of HTTP requests partitioned by method, path and status code.", + }, []string{"method", "path", "status"}) + + httpDuration := prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Name: "evolution_http_request_duration_seconds", + Help: "HTTP request latency in seconds partitioned by method and path.", + Buckets: prometheus.DefBuckets, + }, []string{"method", "path"}) + + buildInfo := prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Name: "evolution_build_info", + Help: "Build information. Always 1; use the 'version' label to read the value.", + }, []string{"version"}) + buildInfo.WithLabelValues(version).Set(1) + + startTime := time.Now() + uptimeGauge := prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "evolution_uptime_seconds", + Help: "Number of seconds since the server started.", + }, func() float64 { + return time.Since(startTime).Seconds() + }) + + reg.MustRegister( + httpRequests, + httpDuration, + buildInfo, + uptimeGauge, + newInstanceCollector(instanceRepo), + ) + + return &Registry{ + reg: reg, + httpRequests: httpRequests, + httpDuration: httpDuration, + } +} + +// Handler returns an http.Handler that serves the Prometheus metrics page. +func (r *Registry) Handler() http.Handler { + return promhttp.HandlerFor(r.reg, promhttp.HandlerOpts{}) +} + +// GinMiddleware returns a Gin middleware that records HTTP request counts and latencies. +// The /metrics path itself is excluded to avoid self-referential noise. +func (r *Registry) GinMiddleware() gin.HandlerFunc { + return func(c *gin.Context) { + if c.Request.URL.Path == "/metrics" { + c.Next() + return + } + + start := time.Now() + c.Next() + + // c.FullPath() returns the registered route pattern (e.g. /instance/:instanceId) + // which keeps cardinality bounded regardless of how many distinct IDs are used. + path := c.FullPath() + if path == "" { + path = "unmatched" + } + + r.httpRequests.WithLabelValues( + c.Request.Method, + path, + strconv.Itoa(c.Writer.Status()), + ).Inc() + + r.httpDuration.WithLabelValues(c.Request.Method, path).Observe(time.Since(start).Seconds()) + } +} + +// instanceCollector is a custom prometheus.Collector that queries the database at +// scrape time so the gauge values are always current without requiring event hooks. +type instanceCollector struct { + repo instance_repository.InstanceRepository + descTotal *prometheus.Desc + descConnected *prometheus.Desc + descDisconnected *prometheus.Desc +} + +func newInstanceCollector(repo instance_repository.InstanceRepository) prometheus.Collector { + return &instanceCollector{ + repo: repo, + descTotal: prometheus.NewDesc( + "evolution_instances_total", + "Total number of registered instances.", + nil, nil, + ), + descConnected: prometheus.NewDesc( + "evolution_instances_connected", + "Number of instances currently connected to WhatsApp.", + nil, nil, + ), + descDisconnected: prometheus.NewDesc( + "evolution_instances_disconnected", + "Number of instances currently disconnected from WhatsApp.", + nil, nil, + ), + } +} + +func (c *instanceCollector) Describe(ch chan<- *prometheus.Desc) { + ch <- c.descTotal + ch <- c.descConnected + ch <- c.descDisconnected +} + +func (c *instanceCollector) Collect(ch chan<- prometheus.Metric) { + instances, err := c.repo.GetAllInstances() + if err != nil { + // Emit nothing on error rather than stale data. + return + } + + var connected float64 + for _, inst := range instances { + if inst.Connected { + connected++ + } + } + total := float64(len(instances)) + + ch <- prometheus.MustNewConstMetric(c.descTotal, prometheus.GaugeValue, total) + ch <- prometheus.MustNewConstMetric(c.descConnected, prometheus.GaugeValue, connected) + ch <- prometheus.MustNewConstMetric(c.descDisconnected, prometheus.GaugeValue, total-connected) +} From 8cd984884670cae2655b5ba51a87ae2f72958e2f Mon Sep 17 00:00:00 2001 From: Edilson Chaves Date: Mon, 20 Apr 2026 14:34:20 -0300 Subject: [PATCH 5/5] fix(instance): return 200 with disconnected status instead of 400 on #20 GET /instance/status was calling ensureClientConnected, which returns an error when the WhatsApp client exists but is not connected (e.g. after the user manually removes the device from their phone). This caused the endpoint to return HTTP 400 until the container was restarted, making it impossible for clients to detect the disconnected state without restarting the server. Status is a read-only query: it should report the current state, not require an active connection to do so. The fix reads clientPointer directly and returns Connected=false/LoggedIn=false when the client is nil or disconnected, without attempting reconnection. Fixes #20 --- pkg/instance/service/instance_service.go | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/pkg/instance/service/instance_service.go b/pkg/instance/service/instance_service.go index 25831fc..f3a18cc 100644 --- a/pkg/instance/service/instance_service.go +++ b/pkg/instance/service/instance_service.go @@ -376,9 +376,13 @@ func (i instances) Logout(instance *instance_model.Instance) (*instance_model.In } func (i instances) Status(instance *instance_model.Instance) (*StatusStruct, error) { - client, err := i.ensureClientConnected(instance.Id) - if err != nil { - return nil, err + client := i.clientPointer[instance.Id] + + if client == nil { + return &StatusStruct{ + Connected: false, + LoggedIn: false, + }, nil } isConnected := client.IsConnected() @@ -391,14 +395,12 @@ func (i instances) Status(instance *instance_model.Instance) (*StatusStruct, err name = client.Store.PushName } - status := &StatusStruct{ + return &StatusStruct{ Connected: isConnected, LoggedIn: isLoggedIn, myJid: myJid, Name: name, - } - - return status, nil + }, nil } func (i instances) GetQr(instance *instance_model.Instance) (*QrcodeStruct, error) {