aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorzwlucas <lucas.fariamo08@gmail.com>2026-05-29 20:41:33 +0000
committerzwlucas <lucas.fariamo08@gmail.com>2026-05-29 20:41:33 +0000
commit9218d22954d61296a32c49373ab0f17f0be59cfe (patch)
tree595b94ce7c2b5eed9760f79e7781653d6b554d18
parentd186a989b6808ff39fcd43a9f0b478aebc4aa346 (diff)
downloadyaum-9218d22954d61296a32c49373ab0f17f0be59cfe.tar.gz
yaum-9218d22954d61296a32c49373ab0f17f0be59cfe.zip
feat: add SSL info tracking and server metrics collection
Signed-off-by: zwlucas <lucas.fariamo08@gmail.com>
-rw-r--r--backend/cmd/server/main.go6
-rw-r--r--backend/go.mod9
-rw-r--r--backend/go.sum22
-rw-r--r--backend/internal/api/handler.go40
-rw-r--r--backend/internal/api/router.go3
-rw-r--r--backend/internal/model/types.go8
-rw-r--r--backend/internal/monitor/checker.go26
-rw-r--r--backend/internal/monitor/worker.go9
-rw-r--r--backend/internal/servermetrics/collector.go84
-rw-r--r--backend/internal/store/memory.go19
-rw-r--r--backend/internal/store/postgres.go28
-rw-r--r--backend/internal/store/store.go3
-rw-r--r--frontend/src/routes/(admin)/admin/+page.svelte67
-rw-r--r--frontend/src/routes/status/[id]/+page.svelte44
-rw-r--r--frontend/src/routes/status/[id]/+page.ts9
15 files changed, 365 insertions, 12 deletions
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index f734885..0093596 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -16,6 +16,7 @@ import (
"yaum/internal/api"
"yaum/internal/config"
"yaum/internal/monitor"
+ "yaum/internal/servermetrics"
"yaum/internal/sse"
"yaum/internal/store"
)
@@ -77,7 +78,10 @@ func main() {
}
worker.WithSMTP(smtpCfg)
}
- handler := api.NewHandler(st, int(cfg.CheckTimeout().Seconds()))
+ metricsCollector := servermetrics.New(".")
+ go metricsCollector.Start(ctx)
+
+ handler := api.NewHandler(st, int(cfg.CheckTimeout().Seconds()), metricsCollector)
streamHandler := api.NewStreamHandler(broadcaster)
authHandler := api.NewAuthHandler(
cfg.AdminUsername(),
diff --git a/backend/go.mod b/backend/go.mod
index 68f40b1..8bda52f 100644
--- a/backend/go.mod
+++ b/backend/go.mod
@@ -5,12 +5,21 @@ go 1.25.0
require github.com/jackc/pgx/v5 v5.9.2
require (
+ github.com/ebitengine/purego v0.10.0 // indirect
+ github.com/go-ole/go-ole v1.2.6 // indirect
github.com/golang-jwt/jwt/v5 v5.2.1 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/joho/godotenv v1.5.1 // indirect
+ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
+ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
+ github.com/shirou/gopsutil/v4 v4.26.4 // indirect
+ github.com/tklauser/go-sysconf v0.3.16 // indirect
+ github.com/tklauser/numcpus v0.11.0 // indirect
+ github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/crypto v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
)
diff --git a/backend/go.sum b/backend/go.sum
index 68f0bf7..ab65f74 100644
--- a/backend/go.sum
+++ b/backend/go.sum
@@ -1,8 +1,13 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
+github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
+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/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk=
github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -13,23 +18,40 @@ 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/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
+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/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/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbprAOWY=
+github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
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.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
+github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
+github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
+github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ=
+github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
+github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
diff --git a/backend/internal/api/handler.go b/backend/internal/api/handler.go
index 5f934da..8da4290 100644
--- a/backend/internal/api/handler.go
+++ b/backend/internal/api/handler.go
@@ -10,6 +10,7 @@ import (
"yaum/internal/monitor"
"yaum/internal/model"
+ "yaum/internal/servermetrics"
"yaum/internal/store"
)
@@ -21,10 +22,11 @@ type ServiceResponse struct {
type Handler struct {
store store.Store
timeout int
+ metrics *servermetrics.Collector
}
-func NewHandler(s store.Store, timeoutSec int) *Handler {
- return &Handler{store: s, timeout: timeoutSec}
+func NewHandler(s store.Store, timeoutSec int, metrics *servermetrics.Collector) *Handler {
+ return &Handler{store: s, timeout: timeoutSec, metrics: metrics}
}
func (h *Handler) ListServices(w http.ResponseWriter, r *http.Request) {
@@ -91,9 +93,14 @@ func (h *Handler) TestService(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(h.timeout)*time.Second)
defer cancel()
- hb := monitor.CheckHTTP(ctx, svc.URL, svc.KeywordToFind)
+ hb, ssl := monitor.CheckHTTP(ctx, svc.URL, svc.KeywordToFind)
hb.ServiceID = svc.ID
+ if ssl != nil {
+ ssl.ServiceID = svc.ID
+ _ = h.store.SaveSSLInfo(*ssl)
+ }
+
if saverr := h.store.AddHeartbeat(hb); saverr != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": saverr.Error()})
return
@@ -323,6 +330,33 @@ func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, stats)
}
+func (h *Handler) ServerStats(w http.ResponseWriter, r *http.Request) {
+ if h.metrics == nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "server metrics nao disponivel"})
+ return
+ }
+ stats := h.metrics.Get()
+ writeJSON(w, http.StatusOK, stats)
+}
+
+func (h *Handler) GetSSLInfo(w http.ResponseWriter, r *http.Request) {
+ id, err := strconv.Atoi(r.PathValue("id"))
+ if err != nil {
+ writeJSON(w, http.StatusBadRequest, map[string]string{"error": "id invalido"})
+ return
+ }
+ info, err := h.store.GetSSLInfo(id)
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
+ return
+ }
+ if info == nil {
+ writeJSON(w, http.StatusNotFound, map[string]string{"error": "ssl info nao disponivel"})
+ return
+ }
+ writeJSON(w, http.StatusOK, info)
+}
+
func writeJSON(w http.ResponseWriter, status int, data any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go
index 8188b25..d28da71 100644
--- a/backend/internal/api/router.go
+++ b/backend/internal/api/router.go
@@ -19,6 +19,7 @@ func NewRouter(h *Handler, stream *StreamHandler, auth *AuthHandler) http.Handle
mux.HandleFunc("DELETE /api/services/{id}", h.DeleteService)
mux.HandleFunc("GET /api/services/{id}/history", h.GetHistory)
mux.HandleFunc("GET /api/services/{id}/stats", h.GetStats)
+ mux.HandleFunc("GET /api/services/{id}/ssl", h.GetSSLInfo)
mux.HandleFunc("GET /api/services/{id}/badge.svg", h.BadgeSVG)
mux.HandleFunc("GET /api/active-maintenance", h.GetActiveMaintenance)
@@ -28,6 +29,8 @@ func NewRouter(h *Handler, stream *StreamHandler, auth *AuthHandler) http.Handle
mux.HandleFunc("PUT /api/maintenances/{id}", h.UpdateMaintenance)
mux.HandleFunc("DELETE /api/maintenances/{id}", h.DeleteMaintenance)
+ mux.HandleFunc("GET /api/admin/server-stats", h.ServerStats)
+
mux.Handle("GET /api/stream", stream)
return corsMiddleware(auth.Middleware(mux))
diff --git a/backend/internal/model/types.go b/backend/internal/model/types.go
index 4e439b8..40dc006 100644
--- a/backend/internal/model/types.go
+++ b/backend/internal/model/types.go
@@ -50,3 +50,11 @@ type ServiceWithHeartbeat struct {
Service
LastHeartbeat *Heartbeat `json:"last_heartbeat,omitempty"`
}
+
+type SSLInfo struct {
+ ServiceID int `json:"service_id"`
+ Issuer string `json:"issuer"`
+ ExpiresAt time.Time `json:"expires_at"`
+ DaysRemaining int `json:"days_remaining"`
+ CheckedAt time.Time `json:"checked_at"`
+}
diff --git a/backend/internal/monitor/checker.go b/backend/internal/monitor/checker.go
index 07f807e..df386e6 100644
--- a/backend/internal/monitor/checker.go
+++ b/backend/internal/monitor/checker.go
@@ -12,14 +12,16 @@ import (
var httpClient = &http.Client{
Timeout: 10 * time.Second,
+ // do NOT set InsecureSkipVerify — we want proper TLS
}
-func CheckHTTP(ctx context.Context, url string, keyword string) model.Heartbeat {
+func CheckHTTP(ctx context.Context, url string, keyword string) (model.Heartbeat, *model.SSLInfo) {
start := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
- return heartbeatFromError(start, err)
+ hb := heartbeatFromError(start, err)
+ return hb, nil
}
resp, err := httpClient.Do(req)
@@ -32,7 +34,7 @@ func CheckHTTP(ctx context.Context, url string, keyword string) model.Heartbeat
IsUp: false,
ErrorMessage: err.Error(),
TestedAt: time.Now(),
- }
+ }, nil
}
defer resp.Body.Close()
@@ -66,13 +68,29 @@ func CheckHTTP(ctx context.Context, url string, keyword string) model.Heartbeat
}
}
- return model.Heartbeat{
+ hb := model.Heartbeat{
StatusCode: resp.StatusCode,
ResponseTimeMs: elapsed,
IsUp: isUp,
ErrorMessage: errMsg,
TestedAt: time.Now(),
}
+
+ // Extract TLS certificate info
+ var ssl *model.SSLInfo
+ if resp.TLS != nil && len(resp.TLS.PeerCertificates) > 0 {
+ cert := resp.TLS.PeerCertificates[0]
+ days := int(time.Until(cert.NotAfter).Hours() / 24)
+ ssl = &model.SSLInfo{
+ ServiceID: 0, // filled in by caller
+ Issuer: cert.Issuer.CommonName,
+ ExpiresAt: cert.NotAfter,
+ DaysRemaining: days,
+ CheckedAt: time.Now(),
+ }
+ }
+
+ return hb, ssl
}
func heartbeatFromError(start time.Time, err error) model.Heartbeat {
diff --git a/backend/internal/monitor/worker.go b/backend/internal/monitor/worker.go
index 70855a2..34a5cba 100644
--- a/backend/internal/monitor/worker.go
+++ b/backend/internal/monitor/worker.go
@@ -166,9 +166,16 @@ func (w *Worker) checkService(ctx context.Context, svc model.Service) {
checkCtx, cancel := context.WithTimeout(ctx, w.timeout)
defer cancel()
- hb := CheckHTTP(checkCtx, svc.URL, svc.KeywordToFind)
+ hb, ssl := CheckHTTP(checkCtx, svc.URL, svc.KeywordToFind)
hb.ServiceID = svc.ID
+ if ssl != nil {
+ ssl.ServiceID = svc.ID
+ if err := w.store.SaveSSLInfo(*ssl); err != nil {
+ log.Printf("[worker] erro ao salvar ssl info: %v", err)
+ }
+ }
+
w.results <- checkResult{Heartbeat: hb, Service: svc}
}
diff --git a/backend/internal/servermetrics/collector.go b/backend/internal/servermetrics/collector.go
new file mode 100644
index 0000000..6c85582
--- /dev/null
+++ b/backend/internal/servermetrics/collector.go
@@ -0,0 +1,84 @@
+package servermetrics
+
+import (
+ "context"
+ "log"
+ "sync"
+ "time"
+
+ "github.com/shirou/gopsutil/v4/cpu"
+ "github.com/shirou/gopsutil/v4/disk"
+ "github.com/shirou/gopsutil/v4/mem"
+)
+
+type Stats struct {
+ CPUPercent float64 `json:"cpu_percent"`
+ MemoryUsed uint64 `json:"memory_used"`
+ MemoryTotal uint64 `json:"memory_total"`
+ MemoryPct float64 `json:"memory_percent"`
+ DiskUsed uint64 `json:"disk_used"`
+ DiskTotal uint64 `json:"disk_total"`
+ DiskPct float64 `json:"disk_percent"`
+}
+
+type Collector struct {
+ mu sync.RWMutex
+ last Stats
+ diskPath string
+}
+
+func New(diskPath string) *Collector {
+ return &Collector{diskPath: diskPath}
+}
+
+func (c *Collector) Start(ctx context.Context) {
+ c.collectOnce(ctx, 500*time.Millisecond)
+ ticker := time.NewTicker(30 * time.Second)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ c.collectOnce(ctx, 0)
+ case <-ctx.Done():
+ return
+ }
+ }
+}
+
+func (c *Collector) collectOnce(ctx context.Context, cpuInterval time.Duration) {
+ cpuPct, err := cpu.PercentWithContext(ctx, cpuInterval, false)
+ if err != nil {
+ log.Printf("[servermetrics] erro cpu: %v", err)
+ return
+ }
+
+ memStat, err := mem.VirtualMemoryWithContext(ctx)
+ if err != nil {
+ log.Printf("[servermetrics] erro mem: %v", err)
+ return
+ }
+
+ diskStat, err := disk.UsageWithContext(ctx, c.diskPath)
+ if err != nil {
+ log.Printf("[servermetrics] erro disk: %v", err)
+ return
+ }
+
+ c.mu.Lock()
+ c.last = Stats{
+ CPUPercent: cpuPct[0],
+ MemoryUsed: memStat.Used,
+ MemoryTotal: memStat.Total,
+ MemoryPct: memStat.UsedPercent,
+ DiskUsed: diskStat.Used,
+ DiskTotal: diskStat.Total,
+ DiskPct: diskStat.UsedPercent,
+ }
+ c.mu.Unlock()
+}
+
+func (c *Collector) Get() Stats {
+ c.mu.RLock()
+ defer c.mu.RUnlock()
+ return c.last
+}
diff --git a/backend/internal/store/memory.go b/backend/internal/store/memory.go
index 1ee2e05..78defcd 100644
--- a/backend/internal/store/memory.go
+++ b/backend/internal/store/memory.go
@@ -14,6 +14,7 @@ type MemoryStore struct {
services map[int]*model.Service
heartbeats map[int][]*model.Heartbeat
maintenances []model.Maintenance
+ sslInfo map[int]model.SSLInfo
nextSvcID int
nextHbID int
nextMtnID int
@@ -24,6 +25,7 @@ func NewMemoryStore() *MemoryStore {
services: make(map[int]*model.Service),
heartbeats: make(map[int][]*model.Heartbeat),
maintenances: make([]model.Maintenance, 0),
+ sslInfo: make(map[int]model.SSLInfo),
nextSvcID: 1,
nextHbID: 1,
nextMtnID: 1,
@@ -346,3 +348,20 @@ func (s *MemoryStore) GetServiceStats(serviceID int) (*model.ServiceStats, error
TotalChecks30d: t30,
}, nil
}
+
+func (s *MemoryStore) GetSSLInfo(serviceID int) (*model.SSLInfo, error) {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ info, ok := s.sslInfo[serviceID]
+ if !ok {
+ return nil, nil
+ }
+ return &info, nil
+}
+
+func (s *MemoryStore) SaveSSLInfo(info model.SSLInfo) error {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ s.sslInfo[info.ServiceID] = info
+ return nil
+}
diff --git a/backend/internal/store/postgres.go b/backend/internal/store/postgres.go
index 57fa258..fb07bdc 100644
--- a/backend/internal/store/postgres.go
+++ b/backend/internal/store/postgres.go
@@ -393,3 +393,31 @@ func (s *PostgresStore) DeleteMaintenance(id int) error {
}
return nil
}
+
+func (s *PostgresStore) GetSSLInfo(serviceID int) (*model.SSLInfo, error) {
+ var info model.SSLInfo
+ err := s.pool.QueryRow(context.Background(),
+ `SELECT service_id, issuer, expires_at, days_remaining, checked_at FROM ssl_info WHERE service_id = $1`,
+ serviceID,
+ ).Scan(&info.ServiceID, &info.Issuer, &info.ExpiresAt, &info.DaysRemaining, &info.CheckedAt)
+ if err != nil {
+ if err == pgx.ErrNoRows {
+ return nil, nil
+ }
+ return nil, fmt.Errorf("get ssl info: %w", err)
+ }
+ return &info, nil
+}
+
+func (s *PostgresStore) SaveSSLInfo(info model.SSLInfo) error {
+ _, err := s.pool.Exec(context.Background(),
+ `INSERT INTO ssl_info (service_id, issuer, expires_at, days_remaining, checked_at)
+ VALUES ($1, $2, $3, $4, $5)
+ ON CONFLICT (service_id) DO UPDATE SET issuer=$2, expires_at=$3, days_remaining=$4, checked_at=$5`,
+ info.ServiceID, info.Issuer, info.ExpiresAt, info.DaysRemaining, info.CheckedAt,
+ )
+ if err != nil {
+ return fmt.Errorf("save ssl info: %w", err)
+ }
+ return nil
+}
diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go
index 2c34b1c..3fc348d 100644
--- a/backend/internal/store/store.go
+++ b/backend/internal/store/store.go
@@ -25,4 +25,7 @@ type Store interface {
AddMaintenance(model.Maintenance) (*model.Maintenance, error)
UpdateMaintenance(int, model.Maintenance) (*model.Maintenance, error)
DeleteMaintenance(int) error
+
+ GetSSLInfo(int) (*model.SSLInfo, error)
+ SaveSSLInfo(model.SSLInfo) error
}
diff --git a/frontend/src/routes/(admin)/admin/+page.svelte b/frontend/src/routes/(admin)/admin/+page.svelte
index 5be9154..7fa361d 100644
--- a/frontend/src/routes/(admin)/admin/+page.svelte
+++ b/frontend/src/routes/(admin)/admin/+page.svelte
@@ -14,6 +14,7 @@
services = data.services ?? [];
initialized = true;
fetchMaintenances();
+ fetchServerStats();
}
});
let editingSvc: any = $state(null);
@@ -29,6 +30,26 @@
let mtnSaving = $state(false);
let mtnFormError = $state('');
+ // server health state
+ let serverStats: { cpu_percent: number; memory_percent: number; disk_percent: number } | null = $state(null);
+ let serverStatsLoading = $state(true);
+ let serverStatsError = $state('');
+
+ async function fetchServerStats() {
+ serverStatsLoading = true;
+ serverStatsError = '';
+ try {
+ const res = await fetch(`${API}/admin/server-stats`, { headers: { Authorization: `Bearer ${token}` } });
+ if (await handleUnauthorized(res)) return;
+ if (!res.ok) throw new Error('Erro ao carregar');
+ serverStats = await res.json();
+ } catch {
+ serverStatsError = 'Falha ao carregar métricas do servidor';
+ } finally {
+ serverStatsLoading = false;
+ }
+ }
+
function apiHeaders() {
return { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` };
}
@@ -265,6 +286,52 @@
</a>
</div>
+<!-- Saúde do Sistema -->
+<div class="mb-8">
+ <h2 class="mb-4 text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Saúde do Sistema
+ </h2>
+ {#if serverStatsLoading}
+ <div class="grid grid-cols-3 gap-4">
+ {#each ['CPU', 'RAM', 'Disco'] as label}
+ <div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-4">
+ <p class="text-[10px] font-medium uppercase tracking-wider text-[var(--text-muted)]">{label}</p>
+ <div class="mt-3 h-2 rounded-full bg-[var(--border-color)]"><div class="h-full w-1/3 rounded-full bg-[var(--border-color)]"></div></div>
+ <p class="mt-2 text-right font-mono text-xs text-[var(--text-muted)]">…</p>
+ </div>
+ {/each}
+ </div>
+ {:else if serverStats}
+ <div class="grid grid-cols-3 gap-4">
+ {#each [
+ { label: 'CPU', pct: serverStats.cpu_percent },
+ { label: 'RAM', pct: serverStats.memory_percent },
+ { label: 'Disco', pct: serverStats.disk_percent },
+ ] as item}
+ {@const color = item.pct >= 90 ? 'var(--red)' : item.pct >= 70 ? 'var(--amber)' : 'var(--green)'}
+ <div class="rounded-xl border border-[var(--border-color)] bg-[var(--bg-card)] p-4">
+ <div class="flex items-center justify-between">
+ <p class="text-[10px] font-medium uppercase tracking-wider text-[var(--text-muted)]">{item.label}</p>
+ <span class="font-mono text-lg font-bold tabular-nums" style="color: {color}">
+ {item.pct.toFixed(1)}<span class="text-xs font-normal text-[var(--text-muted)]">%</span>
+ </span>
+ </div>
+ <div class="mt-3 h-2 overflow-hidden rounded-full bg-[var(--border-color)]">
+ <div
+ class="h-full rounded-full transition-all duration-500"
+ style="width: {Math.min(item.pct, 100)}%; background-color: {color};"
+ ></div>
+ </div>
+ </div>
+ {/each}
+ </div>
+ {:else if serverStatsError}
+ <div class="rounded-xl border border-[var(--red)]/20 bg-[var(--red)]/5 p-4 text-center">
+ <p class="text-xs text-[var(--text-muted)]">{serverStatsError}</p>
+ </div>
+ {/if}
+</div>
+
<div class="mb-8">
<button
onclick={openCreate}
diff --git a/frontend/src/routes/status/[id]/+page.svelte b/frontend/src/routes/status/[id]/+page.svelte
index 0701eeb..d47797d 100644
--- a/frontend/src/routes/status/[id]/+page.svelte
+++ b/frontend/src/routes/status/[id]/+page.svelte
@@ -126,6 +126,50 @@
{/if}
</div>
+ <!-- SSL Card -->
+ {#if data.sslInfo}
+ {@const ssl = data.sslInfo}
+ {@const isExpiring = ssl.days_remaining < 7}
+ <div
+ class="mb-6 overflow-hidden rounded-xl border p-5 transition-all"
+ style="border-color: {isExpiring ? 'rgba(250,204,21,0.3)' : 'rgba(34,240,106,0.2)'}; background-color: {isExpiring ? 'rgba(250,204,21,0.04)' : 'rgba(34,240,106,0.03)'}"
+ >
+ <div class="flex items-start gap-4">
+ <div
+ class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg"
+ style="background-color: {isExpiring ? 'rgba(250,204,21,0.12)' : 'rgba(34,240,106,0.1)'}"
+ >
+ <svg class="h-5 w-5" style="color: {isExpiring ? '#facc15' : 'var(--green)'}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
+ <path stroke-linecap="round" stroke-linejoin="round" d="M9 12.75L11.25 15 15 9.75m-3-7.036A11.959 11.959 0 013.598 6 11.99 11.99 0 003 9.749c0 5.592 3.824 10.29 9 11.623 5.176-1.332 9-6.03 9-11.622 0-1.31-.21-2.571-.598-3.751h-.152c-3.196 0-6.1-1.248-8.25-3.285z" />
+ </svg>
+ </div>
+ <div class="min-w-0 flex-1">
+ <div class="flex items-center justify-between gap-2">
+ <h2 class="text-xs font-semibold uppercase tracking-wider text-[var(--text-secondary)]">
+ Segurança SSL/TLS
+ </h2>
+ <span
+ class="inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-[10px] font-semibold"
+ style="background-color: {isExpiring ? 'rgba(250,204,21,0.12)' : 'rgba(34,240,106,0.1)'}; color: {isExpiring ? '#facc15' : 'var(--green)'}"
+ >
+ {ssl.days_remaining >= 0 ? 'Válido' : 'Expirado'}
+ </span>
+ </div>
+ <p class="mt-2 text-sm font-medium text-white">
+ {ssl.issuer}
+ </p>
+ <p class="mt-0.5 text-xs" style="color: {isExpiring ? '#facc15' : 'var(--text-muted)'}">
+ {ssl.days_remaining >= 0
+ ? isExpiring
+ ? '⚠ Certificado expira em ' + ssl.days_remaining + ' dia' + (ssl.days_remaining === 1 ? '' : 's')
+ : 'Certificado válido por mais ' + ssl.days_remaining + ' dias'
+ : '⚠ Certificado expirado há ' + Math.abs(ssl.days_remaining) + ' dias'}
+ </p>
+ </div>
+ </div>
+ </div>
+ {/if}
+
<div class="grid gap-6 lg:grid-cols-5">
<!-- SLA Breakdown -->
{#if stats}
diff --git a/frontend/src/routes/status/[id]/+page.ts b/frontend/src/routes/status/[id]/+page.ts
index 1b91c67..13a72b1 100644
--- a/frontend/src/routes/status/[id]/+page.ts
+++ b/frontend/src/routes/status/[id]/+page.ts
@@ -3,10 +3,11 @@ import type { PageLoad } from './$types';
export const load: PageLoad = async ({ params, fetch }) => {
const id = params.id;
- const [servicesRes, historyRes, statsRes] = await Promise.all([
+ const [servicesRes, historyRes, statsRes, sslRes] = await Promise.all([
fetch(`http://localhost:8080/api/services`).catch(() => null),
fetch(`http://localhost:8080/api/services/${id}/history`).catch(() => null),
- fetch(`http://localhost:8080/api/services/${id}/stats`).catch(() => null)
+ fetch(`http://localhost:8080/api/services/${id}/stats`).catch(() => null),
+ fetch(`http://localhost:8080/api/services/${id}/ssl`).catch(() => null)
]);
const services = servicesRes?.ok ? await servicesRes.json() : [];
@@ -14,10 +15,12 @@ export const load: PageLoad = async ({ params, fetch }) => {
const historyJson = historyRes?.ok ? await historyRes.json() : [];
const history = Array.isArray(historyJson) ? historyJson : (historyJson.data ?? []);
const stats = statsRes?.ok ? await statsRes.json() : null;
+ const sslInfo = sslRes?.ok ? await sslRes.json() : null;
return {
service: svc ?? null,
history,
- stats
+ stats,
+ sslInfo
};
};