package api import ( "context" "log/slog" "net/http" "time" "github.com/gin-gonic/gin" ) // HealthzHandler 是 K8s liveness / readiness 用的最小健康檢查。 // // 不檢查任何依賴(remote-proxy、DB),只代表 process 還活著。 // readiness 想檢查依賴的話應該用 /api/system/health。 func HealthzHandler() gin.HandlerFunc { return func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"status": "ok"}) } } // registerSystemRoutes 註冊 /api/system/* 的 routes。 // // MVP 範圍(B4 + B5): // - GET /api/system/health → 回 api-server 自己 + tunnel 連線狀態 // - GET /api/system/info → 回版本資訊(雛形 hard-coded) // - GET /api/system/deps → 走 tunnel proxy 查 local agent 的依賴狀態(B5) func registerSystemRoutes(g *gin.RouterGroup, deps Deps) { g.GET("/system/health", systemHealthHandler(deps)) g.GET("/system/info", systemInfoHandler()) // /api/system/deps 透過 tunnel proxy 到 local agent 的同路徑。 g.GET("/system/deps", newProxyHandler(deps, proxyOptions{})) } // SystemHealthResponse 是 GET /api/system/health 的 data payload。 // // 對齊 api-spec.md §7: // // { // "api_server": "ok", // "tunnel_connected": true, // "agent_last_seen_at": "..." // } type SystemHealthResponse struct { APIServer string `json:"api_server"` TunnelConnected bool `json:"tunnel_connected"` AgentLastSeenAt *time.Time `json:"agent_last_seen_at,omitempty"` AgentSessionCount int `json:"agent_session_count"` } // systemHealthHandler 回報 api-server + tunnel 狀態。 // // 「tunnel_connected」的判定方式:呼叫 SessionStore.List。若有任一 session // 在線就視為 connected。雛形是單一 user 場景,所以這個語義足以呈現「我這邊 // 有沒有 agent 連著」;多 user / 多 device 階段會改成 per-user 查詢(B5)。 func systemHealthHandler(deps Deps) gin.HandlerFunc { return func(c *gin.Context) { resp := SystemHealthResponse{ APIServer: "ok", } if deps.SessionStore != nil { // 給一個短 timeout — health 檢查不該卡住整個 request ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second) defer cancel() summaries, err := deps.SessionStore.List(ctx) if err != nil { // SessionStore 失敗不致命;只回 tunnel_connected=false 加 warning logOrDefault(deps.Logger).Warn("system/health: list sessions failed", "error", err, "request_id", RequestIDFrom(c)) } else { resp.AgentSessionCount = len(summaries) if len(summaries) > 0 { resp.TunnelConnected = true // 取最新一個 LastHeartbeat 作為 agent_last_seen_at var latest time.Time for _, s := range summaries { if s.LastHeartbeat.After(latest) { latest = s.LastHeartbeat } } if !latest.IsZero() { resp.AgentLastSeenAt = &latest } } } } WriteSuccess(c, http.StatusOK, resp) } } // SystemInfoResponse 是 GET /api/system/info 的 data payload。 type SystemInfoResponse struct { Service string `json:"service"` Version string `json:"version"` Phase string `json:"phase"` } // logOrDefault 是 nil-safe slog 取用 helper;給 handler 共用。 // // Deps.validate 已會把 nil logger fallback 到 slog.Default,但測試直接呼叫 // register*Routes 時可能跳過 validate;這個 helper 讓 handler 不必每處都 nil 檢查。 func logOrDefault(l *slog.Logger) *slog.Logger { if l == nil { return slog.Default() } return l } // systemInfoHandler 回報版本與環境階段。 // // 雛形版本字串 hard-coded;B6(CI/CD)會改用 build flag 注入 git commit hash。 func systemInfoHandler() gin.HandlerFunc { return func(c *gin.Context) { WriteSuccess(c, http.StatusOK, SystemInfoResponse{ Service: "visiona-api-server", Version: "0.0.0-phase0", Phase: "phase-0-prototype", }) } }