package api import ( "context" "net/http" "time" "github.com/alexei/tinyforge/internal/proxy" ) // getHealth handles GET /api/health. // // Returns the connectivity state and (when connected) rich diagnostics for the // Docker daemon and the active proxy provider. This endpoint is polled by the // UI every 30 seconds — keep the calls cheap. The expensive NPM list calls // are only issued when the initial ping succeeds, so a down proxy never // amplifies latency. func (s *Server) getHealth(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 8*time.Second) defer cancel() now := time.Now().UTC().Format(time.RFC3339) result := map[string]any{ "checked_at": now, } // ── Database ───────────────────────────────────────────────────── if err := s.store.DB().PingContext(ctx); err != nil { result["database"] = map[string]any{"connected": false, "error": "database unreachable"} } else { result["database"] = map[string]any{"connected": true} } // ── Docker daemon ──────────────────────────────────────────────── result["docker"] = s.dockerHealth(ctx) // ── Proxy provider ─────────────────────────────────────────────── if s.proxyProvider != nil { result["proxy"] = s.proxyHealth(ctx) } respondJSON(w, http.StatusOK, result) } // dockerHealth probes the Docker daemon and, if reachable, attaches a full // DaemonInfo snapshot. The caller does not need to error-check the Info() // call — if it fails, the connected flag remains true (ping succeeded) but // the detail fields are simply omitted. func (s *Server) dockerHealth(ctx context.Context) map[string]any { if s.docker == nil { return map[string]any{ "connected": false, "error": "docker client not initialized", } } start := time.Now() if err := s.docker.Ping(ctx); err != nil { return map[string]any{ "connected": false, "error": err.Error(), "latency_ms": time.Since(start).Milliseconds(), } } out := map[string]any{ "connected": true, "latency_ms": time.Since(start).Milliseconds(), } // Info enriches the payload; failures are non-fatal. info, err := s.docker.Info(ctx) if err == nil { if info.Version != "" { out["version"] = info.Version } if info.APIVersion != "" { out["api_version"] = info.APIVersion } if info.OS != "" { out["os"] = info.OS } if info.Arch != "" { out["arch"] = info.Arch } if info.Kernel != "" { out["kernel"] = info.Kernel } if info.OperatingSystem != "" { out["operating_system"] = info.OperatingSystem } if info.StorageDriver != "" { out["storage_driver"] = info.StorageDriver } if info.RootDir != "" { out["root_dir"] = info.RootDir } if info.Name != "" { out["name"] = info.Name } if info.NCPU > 0 { out["ncpu"] = info.NCPU } if info.MemoryTotal > 0 { out["memory_total"] = info.MemoryTotal } out["containers"] = info.Containers out["running"] = info.Running out["paused"] = info.Paused out["stopped"] = info.Stopped out["images"] = info.Images } return out } // proxyHealth probes the configured proxy provider. For NPM, attaches // aggregate counts (proxy hosts, access lists, certificates) which the // dashboard surfaces alongside the connection indicator. func (s *Server) proxyHealth(ctx context.Context) map[string]any { providerName := s.proxyProvider.Name() start := time.Now() err := s.proxyProvider.Ping(ctx) latency := time.Since(start).Milliseconds() if err != nil { return map[string]any{ "provider": providerName, "connected": false, "error": providerName + " unreachable: " + err.Error(), "latency_ms": latency, } } out := map[string]any{ "provider": providerName, "connected": true, "latency_ms": latency, } // Attach configured URL from settings for both NPM and Traefik. if settings, serr := s.store.GetSettings(); serr == nil { switch providerName { case "npm": if settings.NpmURL != "" { out["url"] = settings.NpmURL } case "traefik": if settings.TraefikAPIURL != "" { out["url"] = settings.TraefikAPIURL } } } // NPM-specific aggregates — a quick glance at route/list/cert counts. // These calls require an authenticated NPM session, so we trigger the // provider's auth step first (it's cheap: cached JWT is reused for 1h). if providerName == "npm" && s.npm != nil { if np, ok := s.proxyProvider.(*proxy.NpmProvider); ok { if err := np.Authenticate(ctx); err == nil { if hosts, herr := s.npm.ListProxyHosts(ctx); herr == nil { out["proxy_hosts"] = len(hosts) } if lists, lerr := s.npm.ListAccessLists(ctx); lerr == nil { out["access_lists"] = len(lists) } if certs, cerr := s.npm.ListCertificates(ctx); cerr == nil { out["certificates"] = len(certs) } } } } // Managed-route count — how many of the proxy's routes were deployed // by Tinyforge itself, counting both Docker instances and static sites. // This works for every provider (NPM, Traefik, …) because it reads from // our own store, not the external proxy API. if managed, merr := s.managedRouteCount(); merr == nil { out["proxy_hosts_managed"] = managed } return out } // managedRouteCount returns the number of proxy routes Tinyforge manages // (Docker instances + static sites combined). The domain argument doesn't // affect the count so we pass an empty string to skip FQDN rendering. func (s *Server) managedRouteCount() (int, error) { instanceRoutes, err := s.store.ListProxyRoutes("") if err != nil { return 0, err } siteRoutes, err := s.store.ListStaticSiteProxyRoutes("") if err != nil { return 0, err } return len(instanceRoutes) + len(siteRoutes), nil }