From 5a9ef2077807af8dfe14525e97afa8c57356ca4f Mon Sep 17 00:00:00 2001 From: Rajan Bhattarai Date: Fri, 31 Jul 2026 08:45:35 +0545 Subject: [PATCH 01/10] feat(web): real-time monitoring Dashboard + logo links home MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Dashboard page to the control panel β€” a live server-monitoring view above Resources in the sidebar (below the logo). - GET /dashboard renders charts in the shared shell (both sidebars), Page="metrics"; GET /api/metrics is the real-time JSON feed the page polls every 5s: aggregate CPU/memory/network, running/total, per-app metrics, host (RAM/disk/uptime/cores), docker disk+cache, edge/tunnel, incidents (open/resolved + 14-day buckets), and an accumulated time-series ring (metricSample, capped, in-memory like the sparkline trend). Read-only, so unguarded like GET /api/app. - Charts are hand-rolled inline SVG (no external libs, CSP-safe): CPU / memory / network line charts over time, memory-by-app bars, utilization gauges (uptime / memory / disk), storage & cache tiles, a 14-day incidents bar chart, per-app uptime, and edge/host facts β€” plus a stat tile row. The page self-polls; refresh() early-returns on it so the whole-page swap can't wipe the charts. - Sidebar gains a Dashboard nav item; "All apps" is active only on home. - The logo is now an (display:block) so clicking it goes home. Co-Authored-By: Claude Opus 4.8 --- internal/web/server.go | 332 +++++++++++++++++++++++++++++++++++- internal/web/server_test.go | 114 +++++++++++++ 2 files changed, 443 insertions(+), 3 deletions(-) diff --git a/internal/web/server.go b/internal/web/server.go index ca21130..080dbc1 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -336,6 +336,12 @@ type Server struct { trendMu sync.Mutex trend map[string][]float64 + // hist is the aggregate metrics time-series the Dashboard charts read + // (most recent last, capped). Sampled on each /api/metrics poll. In-memory + // only β€” it lives for the panel process's lifetime, like trend. + histMu sync.Mutex + hist []metricSample + // events is a rolling in-memory audit log of panel actions (most recent // first, capped), rendered as the activity timeline. Guarded by mu. events []event @@ -735,6 +741,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /deploy", s.guard(s.handleDeploy)) mux.HandleFunc("POST /remove", s.guard(s.handleRemove)) mux.HandleFunc("POST /test-alert", s.guard(s.handleTestAlert)) + mux.HandleFunc("GET /dashboard", s.handleDashboardPage) + mux.HandleFunc("GET /api/metrics", s.handleMetricsAPI) mux.HandleFunc("GET /incidents", s.handleIncidentsPage) mux.HandleFunc("POST /incidents/read", s.guard(s.handleMarkRead)) mux.HandleFunc("GET /settings", s.handleSettingsPage) @@ -1428,6 +1436,180 @@ func buildAlerts(apps []runner.AppStatus) []Alert { return alerts } +// metricsCap bounds the aggregate time-series ring the Dashboard charts read. +// 240 samples at the 5s poll cadence is ~20 minutes of live history. +const metricsCap = 240 + +// metricSample is one point in the Dashboard's aggregate time-series. +type metricSample struct { + T time.Time + CPU float64 // summed CPU% across running apps + MemUsed float64 // summed bytes in use + MemCap float64 // summed memory caps + NetRx float64 // summed network read bytes + NetTx float64 // summed network write bytes + Running int + Total int +} + +// round1 rounds to one decimal without importing math. +func round1(f float64) float64 { return float64(int(f*10+0.5)) / 10 } + +// recordSample appends an aggregate sample to the ring, capping its length. +func (s *Server) recordSample(m metricSample) { + s.histMu.Lock() + defer s.histMu.Unlock() + s.hist = append(s.hist, m) + if len(s.hist) > metricsCap { + s.hist = s.hist[len(s.hist)-metricsCap:] + } +} + +// historyJSON renders the aggregate ring for the /api/metrics response. +func (s *Server) historyJSON() []map[string]any { + s.histMu.Lock() + defer s.histMu.Unlock() + out := make([]map[string]any, 0, len(s.hist)) + for _, m := range s.hist { + pct := 0 + if m.MemCap > 0 { + pct = int(m.MemUsed/m.MemCap*100 + 0.5) + } + out = append(out, map[string]any{ + "t": m.T.Format("15:04:05"), "cpu": round1(m.CPU), + "mem": m.MemUsed, "memPct": pct, "netRx": m.NetRx, "netTx": m.NetTx, + "running": m.Running, "total": m.Total, + }) + } + return out +} + +// handleDashboardPage renders the monitoring dashboard (charts) in the shared +// shell, mirroring the incidents page. +func (s *Server) handleDashboardPage(w http.ResponseWriter, _ *http.Request) { + s.renderPage(w, "metrics") +} + +// handleMetricsAPI is the Dashboard's real-time data feed: a JSON snapshot of +// aggregate + per-app metrics, host/system/edge facts, incidents, and the +// accumulated time-series. Read-only (no mutation), so it is unguarded like +// GET /api/app; the panel itself is loopback/Access-gated. +func (s *Server) handleMetricsAPI(w http.ResponseWriter, _ *http.Request) { + data := s.dashData() + now := time.Now() + payload := map[string]any{"ts": now.Format("15:04:05")} + + if data.statusErr != nil { + payload["dockerOK"] = false + payload["error"] = data.statusErr.Error() + } else { + var cpu, memU, memC, rx, tx float64 + running := 0 + apps := make([]map[string]any, 0, len(data.apps)) + for _, a := range data.apps { + c := parseCPU(a.CPU) + var mu, mc float64 + if u, cp, ok := parseMem(a.Memory); ok { + mu, mc = u, cp + } + var arx, atx float64 + if r, t, ok := parseMem(a.Net); ok { + arx, atx = r, t + } + if a.State == "running" { + running++ + cpu += c + memU += mu + memC += mc + rx += arx + tx += atx + } + apps = append(apps, map[string]any{ + "name": a.Name, "state": a.State, "health": a.Health, + "cpu": a.CPU, "cpuPct": c, "mem": a.Memory, "memUsed": mu, + "memPct": memPct(a.Memory), "net": a.Net, "up": a.Up, + "url": a.URL, "reachable": a.Reachable, "category": a.Category, + }) + } + total := len(data.apps) + s.recordSample(metricSample{T: now, CPU: cpu, MemUsed: memU, MemCap: memC, + NetRx: rx, NetTx: tx, Running: running, Total: total}) + memPctAgg := 0 + if memC > 0 { + memPctAgg = int(memU/memC*100 + 0.5) + } + payload["dockerOK"] = true + payload["aggregate"] = map[string]any{ + "cpuPct": round1(cpu), "memUsed": memU, "memCap": memC, + "memUsedH": humanBytes(memU), "memCapH": humanBytes(memC), "memPct": memPctAgg, + "netRx": rx, "netTx": tx, "netRxH": humanBytes(rx), "netTxH": humanBytes(tx), + "running": running, "total": total, + } + payload["apps"] = apps + } + + payload["server"] = map[string]any{ + "host": data.server.Host, "os": data.server.OS, "uptime": data.server.Uptime, + "cores": data.server.Cores, "ram": data.server.RAM, + "diskUsed": data.server.DiskUsed, "diskCap": data.server.DiskCap, "diskPct": data.server.DiskPct, + } + payload["system"] = map[string]any{ + "images": data.system.Images, "imagesSize": data.system.ImagesSize, + "containers": data.system.Containers, "volumes": data.system.Volumes, + "volumesSize": data.system.VolumesSize, "buildCache": data.system.BuildCache, + "reclaimable": data.system.Reclaimable, + } + payload["edge"] = map[string]any{ + "tunnelName": data.edge.TunnelName, "tunnelState": data.edge.TunnelState, + "protected": data.edge.Protected, "hosts": data.edge.Hosts, + } + payload["incidents"] = s.incidentsMetrics(now) + payload["history"] = s.historyJSON() + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(payload) +} + +// incidentsMetrics summarizes incidents for the Dashboard: open/resolved counts, +// the recent list, and a 14-day opened-per-day bucket for the incidents chart. +func (s *Server) incidentsMetrics(now time.Time) map[string]any { + s.mu.Lock() + defer s.mu.Unlock() + open, resolved := 0, 0 + recent := make([]map[string]any, 0, len(s.incidents)) + const days = 14 + buckets := make([]int, days) + today := now.Truncate(24 * time.Hour) + for _, in := range s.incidents { + label := "Control plane" + if in.App != "" { + label = humanize(in.App) + } + isOpen := in.Resolved.IsZero() + if isOpen { + open++ + } else { + resolved++ + } + ago := "resolved after " + compactDur(in.Resolved.Sub(in.Since)) + if isOpen { + ago = "down " + compactDur(now.Sub(in.Since)) + } + recent = append(recent, map[string]any{ + "label": label, "detail": in.Detail, "kind": in.Kind, "open": isOpen, "ago": ago, + }) + if d := int(today.Sub(in.Since.Truncate(24*time.Hour)) / (24 * time.Hour)); d >= 0 && d < days { + buckets[days-1-d]++ + } + } + series := make([]map[string]any, days) + for i := 0; i < days; i++ { + day := today.AddDate(0, 0, -(days - 1 - i)) + series[i] = map[string]any{"day": day.Format("Jan 2"), "count": buckets[i]} + } + return map[string]any{"open": open, "resolved": resolved, "recent": recent, "days": series} +} + // recordAndRenderTrends appends each app's current CPU sample to the ring and // returns a per-app inline sparkline SVG keyed by app name. func (s *Server) recordAndRenderTrends(apps []runner.AppStatus) map[string]template.HTML { @@ -1574,7 +1756,7 @@ var statusTmpl = template.Must(template.New("status").Funcs(template.FuncMap{ /* sidebar β€” fixed column, scrolls on its own */ .side{background:var(--panel);border-right:1px solid var(--line);display:flex;flex-direction:column;padding:16px 12px;overflow:hidden} .brand{display:flex;align-items:center;gap:11px;padding:6px 8px 14px;flex:none} - .logo{width:36px;height:36px;border-radius:10px;box-shadow:var(--shadow);flex:none;overflow:hidden} + .logo{display:block;width:36px;height:36px;border-radius:10px;box-shadow:var(--shadow);flex:none;overflow:hidden} .logo svg{width:100%;height:100%;display:block} .brand .bt{font-size:15.5px;font-weight:700;letter-spacing:-.2px} .brand .bs{font-size:12px;color:var(--faint)} @@ -2047,12 +2229,13 @@ var statusTmpl = template.Must(template.New("status").Funcs(template.FuncMap{