Skip to content

Commit 09a4811

Browse files
feat: add operational dashboard health
1 parent 56a617e commit 09a4811

5 files changed

Lines changed: 252 additions & 15 deletions

File tree

Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
package main
2+
3+
import (
4+
"bufio"
5+
"context"
6+
"database/sql"
7+
"fmt"
8+
"net/http"
9+
"os"
10+
"runtime"
11+
"strconv"
12+
"strings"
13+
"syscall"
14+
"time"
15+
)
16+
17+
type hostResources struct {
18+
CPUPercent *float64 `json:"cpu_percent"`
19+
MemoryUsedBytes *uint64 `json:"memory_used_bytes"`
20+
MemoryTotalBytes *uint64 `json:"memory_total_bytes"`
21+
DiskUsedBytes *uint64 `json:"disk_used_bytes"`
22+
DiskTotalBytes *uint64 `json:"disk_total_bytes"`
23+
UptimeSeconds *uint64 `json:"uptime_seconds"`
24+
}
25+
26+
func (a *app) systemResources(w http.ResponseWriter, r *http.Request) {
27+
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
28+
defer cancel()
29+
writeJSON(w, hostResourceSnapshot(ctx, a.dataDir))
30+
}
31+
32+
func (a *app) systemHealth(w http.ResponseWriter, r *http.Request) {
33+
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
34+
defer cancel()
35+
result := map[string]any{"docker_available": false, "runtime_mode": "unknown", "swarm_active": false, "status": "unknown"}
36+
if mode, err := a.runtimeMode(ctx); err == nil {
37+
result["runtime_mode"] = mode
38+
}
39+
if a.docker != nil {
40+
snapshot := a.docker.Snapshot(ctx)
41+
result["docker_available"] = snapshot.Available
42+
result["docker_message"] = snapshot.Message
43+
result["engine_version"] = snapshot.EngineVersion
44+
result["swarm_active"] = snapshot.Swarm.Active
45+
if snapshot.Available {
46+
result["status"] = "healthy"
47+
} else {
48+
result["status"] = "critical"
49+
}
50+
}
51+
writeJSON(w, result)
52+
}
53+
54+
func (a *app) dashboard(w http.ResponseWriter, r *http.Request) {
55+
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Second)
56+
defer cancel()
57+
mode, _ := a.runtimeMode(ctx)
58+
snapshot := map[string]any{"available": false}
59+
if a.docker != nil {
60+
info := a.docker.Snapshot(ctx)
61+
snapshot = map[string]any{"available": info.Available, "engine_version": info.EngineVersion, "runtime_mode": mode, "swarm_active": info.Swarm.Active, "message": info.Message, "swarm": info.Swarm}
62+
}
63+
applications := map[string]int{"total": 0, "running": 0, "degraded": 0, "stopped": 0, "failed": 0, "not_deployed": 0}
64+
rows, _ := a.db.QueryContext(ctx, `SELECT COALESCE(status,'not_deployed'), count(*) FROM applications GROUP BY COALESCE(status,'not_deployed')`)
65+
if rows != nil {
66+
for rows.Next() {
67+
var status string
68+
var count int
69+
_ = rows.Scan(&status, &count)
70+
applications[status] += count
71+
applications["total"] += count
72+
}
73+
rows.Close()
74+
}
75+
deployments := map[string]any{"running": scalarInt(ctx, a.db, `SELECT count(*) FROM deployments WHERE status IN ('queued','preparing','deploying','waiting')`), "failed_last_24h": scalarInt(ctx, a.db, `SELECT count(*) FROM deployments WHERE status='failed' AND created_at >= ?`, time.Now().UTC().Add(-24*time.Hour).Format(time.RFC3339)), "recent": recentDeployments(ctx, a.db)}
76+
domains := map[string]int{"total": 0, "active": 0, "pending": 0, "errors": 0}
77+
if rows, err := a.db.QueryContext(ctx, `SELECT status, count(*) FROM application_domains GROUP BY status`); err == nil {
78+
for rows.Next() {
79+
var status string
80+
var count int
81+
_ = rows.Scan(&status, &count)
82+
domains["total"] += count
83+
switch status {
84+
case "active", "ready":
85+
domains["active"] += count
86+
case "error", "failed":
87+
domains["errors"] += count
88+
default:
89+
domains["pending"] += count
90+
}
91+
}
92+
rows.Close()
93+
}
94+
backup := map[string]any{"last_status": "", "last_created_at": "", "next_scheduled_at": ""}
95+
var backupStatus, backupCreated string
96+
if a.db.QueryRowContext(ctx, `SELECT status,created_at FROM backups ORDER BY created_at DESC LIMIT 1`).Scan(&backupStatus, &backupCreated) == nil {
97+
backup["last_status"] = backupStatus
98+
backup["last_created_at"] = backupCreated
99+
}
100+
alerts := dashboardAlerts(applications, domains, deployments, snapshot)
101+
resources := hostResourceSnapshot(ctx, a.dataDir)
102+
writeJSON(w, map[string]any{"docker": snapshot, "host": resources, "applications": applications, "deployments": deployments, "domains": domains, "backups": backup, "alerts": alerts, "activity": recentActivity(ctx, a.db), "projects": scalarInt(ctx, a.db, `SELECT count(*) FROM projects`), "application_count": applications["total"]})
103+
}
104+
105+
func (a *app) runtimeMode(ctx context.Context) (string, error) {
106+
var mode string
107+
err := a.db.QueryRowContext(ctx, `SELECT runtime_mode FROM environment_settings WHERE id=1`).Scan(&mode)
108+
return mode, err
109+
}
110+
func scalarInt(ctx context.Context, db *sql.DB, query string, args ...any) int {
111+
var n int
112+
_ = db.QueryRowContext(ctx, query, args...).Scan(&n)
113+
return n
114+
}
115+
116+
func recentDeployments(ctx context.Context, db *sql.DB) []any {
117+
items := []any{}
118+
rows, err := db.QueryContext(ctx, `SELECT d.id,d.application_id,d.status,d.source_revision,d.created_at,COALESCE(a.name,'') FROM deployments d LEFT JOIN applications a ON a.id=d.application_id ORDER BY d.created_at DESC LIMIT 8`)
119+
if err != nil {
120+
return items
121+
}
122+
defer rows.Close()
123+
for rows.Next() {
124+
var id, applicationID, revision int64
125+
var status, created, name string
126+
if rows.Scan(&id, &applicationID, &status, &revision, &created, &name) == nil {
127+
items = append(items, map[string]any{"id": id, "application_id": applicationID, "application": name, "status": status, "revision": revision, "created_at": created})
128+
}
129+
}
130+
return items
131+
}
132+
133+
func recentActivity(ctx context.Context, db *sql.DB) []any {
134+
items := []any{}
135+
rows, err := db.QueryContext(ctx, `SELECT action,COALESCE(resource_type,''),created_at FROM audit_logs ORDER BY id DESC LIMIT 5`)
136+
if err != nil {
137+
return items
138+
}
139+
defer rows.Close()
140+
for rows.Next() {
141+
var action, resource, created string
142+
if rows.Scan(&action, &resource, &created) == nil {
143+
items = append(items, map[string]string{"action": action, "resource_type": resource, "created_at": created, "description": activityDescription(action)})
144+
}
145+
}
146+
return items
147+
}
148+
149+
func dashboardAlerts(applications, domains map[string]int, deployments, docker map[string]any) []map[string]any {
150+
alerts := []map[string]any{}
151+
if available, _ := docker["available"].(bool); !available {
152+
alerts = append(alerts, map[string]any{"severity": "critical", "code": "docker_unavailable", "title": "Docker indisponível", "description": "O daemon Docker não respondeu.", "resource_type": "system", "action_url": "/infrastructure"})
153+
}
154+
if n := applications["degraded"]; n > 0 {
155+
alerts = append(alerts, map[string]any{"severity": "warning", "code": "application_degraded", "title": "Aplicação degradada", "description": strconv.Itoa(n) + " aplicação(ões) precisam de atenção.", "resource_type": "application", "action_url": "/applications"})
156+
}
157+
if n, _ := deployments["failed_last_24h"].(int); n > 0 {
158+
alerts = append(alerts, map[string]any{"severity": "warning", "code": "deploy_failed", "title": "Deploy falhou recentemente", "description": strconv.Itoa(n) + " deploy(s) falharam nas últimas 24 horas.", "resource_type": "system", "action_url": "/activity"})
159+
}
160+
if n := domains["errors"]; n > 0 {
161+
alerts = append(alerts, map[string]any{"severity": "warning", "code": "domain_error", "title": "Domínio com erro", "description": strconv.Itoa(n) + " domínio(s) apresentam erro.", "resource_type": "domain", "action_url": "/applications"})
162+
}
163+
return alerts
164+
}
165+
166+
func hostResourceSnapshot(ctx context.Context, path string) hostResources {
167+
result := hostResources{}
168+
if runtime.GOOS == "linux" {
169+
if file, err := os.Open("/proc/meminfo"); err == nil {
170+
defer file.Close()
171+
scanner := bufio.NewScanner(file)
172+
var total, available uint64
173+
for scanner.Scan() {
174+
fields := strings.Fields(scanner.Text())
175+
if len(fields) >= 2 {
176+
value, _ := strconv.ParseUint(fields[1], 10, 64)
177+
switch fields[0] {
178+
case "MemTotal:":
179+
total = value * 1024
180+
case "MemAvailable:":
181+
available = value * 1024
182+
}
183+
}
184+
}
185+
if total > 0 {
186+
used := total - available
187+
result.MemoryTotalBytes = &total
188+
result.MemoryUsedBytes = &used
189+
}
190+
}
191+
if file, err := os.Open("/proc/uptime"); err == nil {
192+
defer file.Close()
193+
var seconds float64
194+
_, _ = fmt.Fscan(file, &seconds)
195+
value := uint64(seconds)
196+
result.UptimeSeconds = &value
197+
}
198+
}
199+
var stat syscall.Statfs_t
200+
if err := syscall.Statfs(path, &stat); err == nil {
201+
total := stat.Blocks * uint64(stat.Bsize)
202+
free := stat.Bavail * uint64(stat.Bsize)
203+
used := total - free
204+
result.DiskTotalBytes = &total
205+
result.DiskUsedBytes = &used
206+
}
207+
return result
208+
}

‎cmd/stackhost/main.go‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type app struct {
4242
domains *domainAPI
4343
ingressProxy *ingress.Proxy
4444
backups *backupAPI
45+
dataDir string
4546
}
4647
type attempt struct {
4748
count int
@@ -81,7 +82,7 @@ func main() {
8182
dr, _ := dockerreader.NewReader()
8283
events := eventhub.New(64)
8384
defer events.Close()
84-
a := &app{db: db, sessionSecret: sessionSecret, cipher: cipher, events: events, docker: dr, loginAttempts: make(map[string]attempt)}
85+
a := &app{db: db, sessionSecret: sessionSecret, cipher: cipher, events: events, docker: dr, dataDir: dataDir, loginAttempts: make(map[string]attempt)}
8586
a.backups = &backupAPI{app: a, dataDir: dataDir}
8687
schedulerCtx, schedulerCancel := context.WithCancel(context.Background())
8788
defer schedulerCancel()
@@ -137,6 +138,8 @@ func (a *app) routes() http.Handler {
137138
mux.HandleFunc("/api/v1/auth/logout", a.logout)
138139
mux.HandleFunc("/api/v1/me", a.auth(a.me))
139140
mux.HandleFunc("/api/v1/dashboard", a.auth(a.dashboard))
141+
mux.HandleFunc("/api/v1/system/health", a.auth(a.systemHealth))
142+
mux.HandleFunc("/api/v1/system/resources", a.auth(a.systemResources))
140143
mux.HandleFunc("/api/v1/projects", a.auth(a.projects))
141144
mux.HandleFunc("/api/v1/projects/", a.auth(a.projectRoute))
142145
mux.HandleFunc("/api/v1/applications/", a.auth(a.applicationRouteV2))
@@ -377,7 +380,7 @@ func (a *app) me(w http.ResponseWriter, r *http.Request) {
377380
a.db.QueryRow("SELECT id,name,email,role FROM users WHERE id=?", r.Context().Value(userKey{})).Scan(&u.ID, &u.Name, &u.Email, &u.Role)
378381
json.NewEncoder(w).Encode(u)
379382
}
380-
func (a *app) dashboard(w http.ResponseWriter, r *http.Request) {
383+
func (a *app) dashboardLegacy(w http.ResponseWriter, r *http.Request) {
381384
var projects, apps int
382385
a.db.QueryRow("SELECT count(*) FROM projects").Scan(&projects)
383386
a.db.QueryRow("SELECT count(*) FROM applications").Scan(&apps)

‎cmd/stackhost/main_test.go‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -297,14 +297,14 @@ func TestCleanDatabaseIntegrationFlow(t *testing.T) {
297297
dashboardReq.AddCookie(cookie)
298298
a.auth(a.dashboard)(dashboard, dashboardReq)
299299
var payload struct {
300-
Projects int `json:"projects"`
301-
Applications int `json:"applications"`
302-
Activity []any `json:"activity"`
300+
Projects int `json:"projects"`
301+
Applications map[string]int `json:"applications"`
302+
Activity []any `json:"activity"`
303303
}
304304
if err := json.NewDecoder(dashboard.Body).Decode(&payload); err != nil {
305305
t.Fatal(err)
306306
}
307-
if payload.Projects != 1 || payload.Applications != 1 || len(payload.Activity) == 0 {
307+
if payload.Projects != 1 || payload.Applications["total"] != 1 || len(payload.Activity) == 0 {
308308
t.Fatalf("dashboard payload = %#v", payload)
309309
}
310310
}

‎docs/features-boost-progress.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ Início: `2026-07-17 17:27:45 -03`
1111
- [x] Fase 2 — Proxy nativo e domínios (base operacional entregue; renovação ACME e propagação DNS seguem observáveis)
1212
- [x] Fase 3 — Controle operacional
1313
- [x] Fase 4 — Logs
14-
- [ ] Fase 5 — Monitoramento e saúde (métricas locais + retenção de 24h entregues; dashboard/Swarm global pendentes)
14+
- [x] Fase 5 — Monitoramento e saúde (dashboard operacional, saúde do host, agregações e alertas derivados; métricas locais mantidas)
1515
- [x] Fase 6 — Backups (backup local, retenção e agendamento manual/diário/semanal)
1616
- [x] Fase 7 — Notificações (webhook/Discord/Slack configuráveis e eventos operacionais básicos)
1717
- [ ] Fase 8 — Catálogo e produtividade (duplicação de aplicações entregue; importação avançada pendente)
@@ -53,6 +53,7 @@ O build Docker pós-correções passou com a imagem `stackhost:features-boost`.
5353
- O runtime agora expõe health real do Docker SDK (`healthy`, `starting`, `unhealthy`, `no_healthcheck`) e uma amostra de métricas locais Docker (`/api/v1/applications/:id/metrics`) com retenção de 24 horas e UI resumida. Swarm permanece explicitamente `unknown` para métricas não locais. Notificações têm configuração protegida, teste de webhook, UI e entrega assíncrona para eventos publicados. Aplicações podem ser duplicadas preservando a origem criptografada e iniciando como `not_deployed`.
5454
- Backup local do sistema entregue em `POST/GET /api/v1/backups/system`, com SQLite via `VACUUM INTO`, certificados persistidos em tar.gz, download e remoção protegidos por admin, controles em Configurações, retenção e scheduler interno manual/diário/semanal. Destinos S3 continuam pendentes.
5555
- Ajuste incremental da fase de logs: `tail` agora aceita somente 100, 500 ou 1000; o viewer ganhou busca local, auto-scroll, cópia, download, limpeza visual e indicador de conexão.
56+
- Dashboard operacional entregue em `/api/v1/dashboard`, com saúde do host em `/api/v1/system/health` e `/api/v1/system/resources`, agregação de aplicações/deploys/domínios/backups, alertas derivados e cards responsivos; métricas indisponíveis retornam `null`.
5657

5758
## Último commit validado
5859

‎web/src/views/Dashboard.vue‎

Lines changed: 33 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,12 @@ const nowGreeting = computed(() => {
3232
const hour = new Date().getHours();
3333
return hour < 12 ? "Bom dia" : hour < 18 ? "Boa tarde" : "Boa noite";
3434
});
35-
const infra = computed(() => data.value.infrastructure as Infrastructure);
35+
const infra = computed(() => ((data.value && (data.value.docker || data.value.infrastructure)) || {}) as Infrastructure);
36+
const overallMessage = computed(() => {
37+
if (!infra.value.available) return "Docker indisponível";
38+
const count = (data.value.alerts || []).length;
39+
return count ? `${count} ${count === 1 ? "item precisa" : "itens precisam"} de atenção` : "Tudo funcionando";
40+
});
3641
const userName = ref("Administrador");
3742
async function load(silent = false) {
3843
if (refreshing) return;
@@ -168,14 +173,20 @@ onUnmounted(() => {
168173
}}
169174
</p>
170175
</div>
171-
<span class="pill">{{
172-
infra.available
173-
? infra.swarm?.active
174-
? "Pronto"
175-
: "Ação necessária"
176-
: "Atenção"
177-
}}</span>
176+
<span class="pill">{{ overallMessage }}</span>
177+
</section>
178+
<section v-if="data.applications" class="grid metrics operational-cards">
179+
<RouterLink class="panel operational-card" to="/applications"><span class="label">APLICAÇÕES</span><strong>{{ data.applications.total }}</strong><span>{{ data.applications.running || 0 }} em execução · {{ data.applications.degraded || 0 }} degradadas</span></RouterLink>
180+
<RouterLink class="panel operational-card" to="/applications"><span class="label">DEPLOYS</span><strong>{{ data.deployments?.running || 0 }}</strong><span>{{ data.deployments?.failed_last_24h || 0 }} falhos em 24h</span></RouterLink>
181+
<RouterLink class="panel operational-card" to="/applications"><span class="label">DOMÍNIOS</span><strong>{{ data.domains?.active || 0 }}</strong><span>{{ data.domains?.pending || 0 }} pendentes · {{ data.domains?.errors || 0 }} com erro</span></RouterLink>
182+
<RouterLink class="panel operational-card" to="/settings"><span class="label">BACKUPS</span><strong>{{ data.backups?.last_status || "—" }}</strong><span>{{ data.backups?.last_created_at ? new Date(data.backups.last_created_at).toLocaleString("pt-BR") : "Nenhum backup registrado" }}</span></RouterLink>
183+
</section>
184+
<section v-if="data.alerts?.length" class="panel panel-section operational-alerts">
185+
<div class="section-head"><div><p class="kicker">ATENÇÃO</p><h2>Alertas operacionais</h2></div></div>
186+
<RouterLink v-for="alert in data.alerts" :key="alert.code" class="alert-row" :to="alert.action_url || '/'"><strong>{{ alert.title }}</strong><span>{{ alert.description }}</span></RouterLink>
178187
</section>
188+
<section v-if="data.host" class="panel panel-section resource-health"><div class="section-head"><div><p class="kicker">SAÚDE DO SERVIDOR</p><h2>Recursos do ambiente</h2></div><button class="ghost" type="button" @click="load()">Atualizar</button></div><div class="resource-bars"><div><span>CPU</span><strong>{{ data.host.cpu_percent == null ? "Indisponível" : `${data.host.cpu_percent}%` }}</strong></div><div><span>Memória</span><strong>{{ data.host.memory_used_bytes == null ? "Indisponível" : `${Math.round(data.host.memory_used_bytes / 1073741824 * 10) / 10} GB de ${Math.round(data.host.memory_total_bytes / 1073741824 * 10) / 10} GB` }}</strong></div><div><span>Disco</span><strong>{{ data.host.disk_used_bytes == null ? "Indisponível" : `${Math.round(data.host.disk_used_bytes / 1073741824)} GB de ${Math.round(data.host.disk_total_bytes / 1073741824)} GB` }}</strong></div></div></section>
189+
<section v-if="data.deployments?.recent?.length" class="panel panel-section operational-alerts"><div class="section-head"><div><p class="kicker">DEPLOYS</p><h2>Deploys recentes</h2></div><RouterLink class="ghost" to="/activity">Ver atividade</RouterLink></div><RouterLink v-for="item in data.deployments.recent" :key="item.id" class="alert-row" :to="`/applications/${item.application_id}?tab=deployments`"><strong>{{ item.application }} · {{ item.status }}</strong><span>Revisão {{ item.revision }} · {{ new Date(item.created_at).toLocaleString("pt-BR") }}</span></RouterLink></section>
179190
<section v-if="infra.available && !infra.swarm?.active" class="callout">
180191
<div>
181192
<p class="kicker">PRÓXIMO PASSO</p>
@@ -338,3 +349,17 @@ onUnmounted(() => {
338349
></BaseModal
339350
>
340351
</template>
352+
353+
<style scoped>
354+
.operational-cards { grid-template-columns: repeat(4, minmax(0, 1fr)); }
355+
.operational-card { display: grid; gap: 8px; color: inherit; text-decoration: none; }
356+
.operational-card strong { font-size: 28px; }
357+
.operational-card span:last-child, .alert-row span { color: var(--muted, #858b99); font-size: 13px; }
358+
.operational-alerts { display: grid; gap: 12px; }
359+
.alert-row { display: flex; justify-content: space-between; gap: 16px; padding: 12px 0; border-top: 1px solid #2a2d34; color: inherit; text-decoration: none; }
360+
.resource-bars { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
361+
.resource-bars > div { display: grid; gap: 6px; }
362+
.resource-bars span { color: var(--muted, #858b99); font-size: 12px; }
363+
@media (max-width: 800px) { .operational-cards, .resource-bars { grid-template-columns: 1fr 1fr; } .alert-row { display: grid; gap: 4px; } }
364+
@media (max-width: 480px) { .operational-cards, .resource-bars { grid-template-columns: 1fr; } }
365+
</style>

0 commit comments

Comments
 (0)