From 8b5681bee27ea1b1a296683e099a49812bd8d4b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Mon, 2 Mar 2026 09:37:55 +0000 Subject: [PATCH] feat: startup health status (#344) --- apps/backend/cmd/kandev/helpers.go | 16 +++ .../settings/controller/agent_discovery.go | 14 +++ apps/backend/internal/github/gh_client.go | 47 ++++++-- apps/backend/internal/github/models.go | 14 ++- apps/backend/internal/github/provider.go | 2 +- apps/backend/internal/github/service.go | 69 ++++++++++- apps/backend/internal/health/checks.go | 83 ++++++++++++++ apps/backend/internal/health/checks_test.go | 108 ++++++++++++++++++ apps/backend/internal/health/handler.go | 22 ++++ apps/backend/internal/health/service.go | 37 ++++++ apps/backend/internal/health/service_test.go | 100 ++++++++++++++++ apps/backend/internal/health/types.go | 34 ++++++ apps/web/components/github/github-status.tsx | 51 +++++++-- .../kanban/kanban-header-mobile.tsx | 10 ++ apps/web/components/kanban/kanban-header.tsx | 51 +++++---- .../system-health/health-indicator.tsx | 88 ++++++++++++++ .../hooks/domains/github/use-github-status.ts | 21 +++- .../domains/settings/use-system-health.ts | 31 +++++ apps/web/hooks/use-system-health-indicator.ts | 27 +++++ apps/web/lib/api/domains/health-api.ts | 6 + apps/web/lib/state/slices/index.ts | 1 + apps/web/lib/state/slices/ui/types.ts | 13 +++ apps/web/lib/state/slices/ui/ui-slice.ts | 15 +++ apps/web/lib/state/store.ts | 8 ++ apps/web/lib/types/github.ts | 7 ++ apps/web/lib/types/health.ts | 16 +++ 26 files changed, 835 insertions(+), 56 deletions(-) create mode 100644 apps/backend/internal/health/checks.go create mode 100644 apps/backend/internal/health/checks_test.go create mode 100644 apps/backend/internal/health/handler.go create mode 100644 apps/backend/internal/health/service.go create mode 100644 apps/backend/internal/health/service_test.go create mode 100644 apps/backend/internal/health/types.go create mode 100644 apps/web/components/system-health/health-indicator.tsx create mode 100644 apps/web/hooks/domains/settings/use-system-health.ts create mode 100644 apps/web/hooks/use-system-health-indicator.ts create mode 100644 apps/web/lib/api/domains/health-api.ts create mode 100644 apps/web/lib/types/health.ts diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index d3b53e3265..a8a2f1b5fa 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -24,6 +24,7 @@ import ( "github.com/kandev/kandev/internal/events/bus" gateways "github.com/kandev/kandev/internal/gateway/websocket" "github.com/kandev/kandev/internal/github" + "github.com/kandev/kandev/internal/health" mcphandlers "github.com/kandev/kandev/internal/mcp/handlers" notificationcontroller "github.com/kandev/kandev/internal/notifications/controller" notificationhandlers "github.com/kandev/kandev/internal/notifications/handlers" @@ -379,11 +380,26 @@ func registerSecondaryRoutes( docker.RegisterDockerRoutes(p.router, p.lifecycleMgr.DockerClientProvider(), p.log) p.log.Debug("Registered Docker management handlers (HTTP)") + registerHealthRoutes(p) + registerMCPAndDebugRoutes(p, workflowCtrl, clarificationStore, planService) registerE2EResetRoutes(p.router, p.taskRepo, p.log) } +// registerHealthRoutes sets up the system health endpoint with all health checkers. +func registerHealthRoutes(p routeParams) { + var githubProvider health.GitHubStatusProvider + if p.services.GitHub != nil { + githubProvider = p.services.GitHub + } + healthSvc := health.NewService(p.log, + health.NewGitHubChecker(githubProvider), + health.NewAgentChecker(p.agentSettingsController), + ) + health.RegisterRoutes(p.router, healthSvc, p.log) +} + // registerMCPAndDebugRoutes registers MCP and debug routes and wires the MCP handler. func registerMCPAndDebugRoutes( p routeParams, diff --git a/apps/backend/internal/agent/settings/controller/agent_discovery.go b/apps/backend/internal/agent/settings/controller/agent_discovery.go index 2956871840..21a3fd3771 100644 --- a/apps/backend/internal/agent/settings/controller/agent_discovery.go +++ b/apps/backend/internal/agent/settings/controller/agent_discovery.go @@ -56,6 +56,20 @@ func (c *Controller) ListAvailableAgents(ctx context.Context) (*dto.ListAvailabl return &dto.ListAvailableAgentsResponse{Agents: payload, Total: len(payload)}, nil } +// HasAvailableAgents returns true if at least one agent is detected as installed. +func (c *Controller) HasAvailableAgents(ctx context.Context) (bool, error) { + results, err := c.detectAgents(ctx) + if err != nil { + return false, err + } + for _, r := range results { + if r.Available { + return true, nil + } + } + return false, nil +} + func (c *Controller) InvalidateDiscoveryCache() { if c.discovery != nil { c.discovery.InvalidateCache() diff --git a/apps/backend/internal/github/gh_client.go b/apps/backend/internal/github/gh_client.go index f6163d32d6..6c41e517f2 100644 --- a/apps/backend/internal/github/gh_client.go +++ b/apps/backend/internal/github/gh_client.go @@ -26,18 +26,45 @@ func GHAvailable() bool { } func (c *GHClient) IsAuthenticated(ctx context.Context) (bool, error) { + // Treat any non-zero exit as "not authenticated". This avoids parsing + // locale-dependent error messages and handles multi-account scenarios + // where a secondary account has an invalid token. GHAvailable() already + // guards binary existence before this method is called. _, err := c.run(ctx, "auth", "status", "--hostname", "github.com") - if err != nil { - // gh auth status exits non-zero when not authenticated. - // The error message includes stderr from the gh CLI. - errMsg := err.Error() - if strings.Contains(errMsg, "not logged") || strings.Contains(errMsg, "no accounts") { - return false, nil - } - // Unexpected error (binary not found, network issue, etc.) - return false, err + if err == nil { + return true, nil + } + // Propagate context errors so callers can distinguish cancellation/timeout + // from a genuine "not authenticated" result. + if ctx.Err() != nil { + return false, ctx.Err() + } + return false, nil +} + +// RunAuthDiagnostics executes gh auth status and captures the raw output for troubleshooting. +func (c *GHClient) RunAuthDiagnostics(ctx context.Context) *AuthDiagnostics { + cmd := exec.CommandContext(ctx, "gh", "auth", "status", "--hostname", "github.com") + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + exitCode := 0 + if err := cmd.Run(); err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } else { + exitCode = -1 + } + } + output := stderr.String() + if output == "" { + output = stdout.String() + } + return &AuthDiagnostics{ + Command: "gh auth status --hostname github.com", + Output: output, + ExitCode: exitCode, } - return true, nil } func (c *GHClient) GetAuthenticatedUser(ctx context.Context) (string, error) { diff --git a/apps/backend/internal/github/models.go b/apps/backend/internal/github/models.go index 1ea432abbb..85ecb35cd9 100644 --- a/apps/backend/internal/github/models.go +++ b/apps/backend/internal/github/models.go @@ -190,9 +190,17 @@ type GitHubRepo struct { // GitHubStatus represents GitHub connection status. type GitHubStatus struct { - Authenticated bool `json:"authenticated"` - Username string `json:"username"` - AuthMethod string `json:"auth_method"` // "gh_cli", "pat", "none" + Authenticated bool `json:"authenticated"` + Username string `json:"username"` + AuthMethod string `json:"auth_method"` // "gh_cli", "pat", "none" + Diagnostics *AuthDiagnostics `json:"diagnostics,omitempty"` +} + +// AuthDiagnostics captures the output of gh auth status for troubleshooting. +type AuthDiagnostics struct { + Command string `json:"command"` + Output string `json:"output"` + ExitCode int `json:"exit_code"` } // CreateReviewWatchRequest is the request body for creating a review watch. diff --git a/apps/backend/internal/github/provider.go b/apps/backend/internal/github/provider.go index d19541cff3..fcb04f9c1c 100644 --- a/apps/backend/internal/github/provider.go +++ b/apps/backend/internal/github/provider.go @@ -29,7 +29,7 @@ func Provide( log.Warn("GitHub client not available: " + err.Error()) } - svc := NewService(client, authMethod, store, eventBus, log) + svc := NewService(client, authMethod, secrets, store, eventBus, log) cleanup := func() error { return nil } return svc, cleanup, nil diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go index 57812303ed..6b76739672 100644 --- a/apps/backend/internal/github/service.go +++ b/apps/backend/internal/github/service.go @@ -5,6 +5,7 @@ import ( "fmt" "strconv" "strings" + "sync" "time" "go.uber.org/zap" @@ -16,18 +17,21 @@ import ( // Service coordinates GitHub integration operations. type Service struct { + mu sync.Mutex client Client authMethod string + secrets SecretProvider store *Store eventBus bus.EventBus logger *logger.Logger } // NewService creates a new GitHub service. -func NewService(client Client, authMethod string, store *Store, eventBus bus.EventBus, log *logger.Logger) *Service { +func NewService(client Client, authMethod string, secrets SecretProvider, store *Store, eventBus bus.EventBus, log *logger.Logger) *Service { return &Service{ client: client, authMethod: authMethod, + secrets: secrets, store: store, eventBus: eventBus, logger: log, @@ -39,28 +43,81 @@ func (s *Service) Client() Client { return s.client } +// IsAuthenticated returns whether the service has a working GitHub client. +func (s *Service) IsAuthenticated() bool { + return s.client != nil +} + +// AuthMethod returns the authentication method ("gh_cli", "pat", or "none"). +func (s *Service) AuthMethod() string { + return s.authMethod +} + // GetStatus returns the current GitHub connection status. +// If no client exists, it retries client creation to pick up auth changes made after startup. func (s *Service) GetStatus(ctx context.Context) (*GitHubStatus, error) { - status := &GitHubStatus{ - AuthMethod: s.authMethod, - } if s.client == nil { + s.retryClientCreation(ctx) + } + + s.mu.Lock() + client := s.client + authMethod := s.authMethod + s.mu.Unlock() + + status := &GitHubStatus{ + AuthMethod: authMethod, + } + if client == nil { + status.Diagnostics = runGHDiagnostics(ctx) return status, nil } - ok, err := s.client.IsAuthenticated(ctx) + ok, err := client.IsAuthenticated(ctx) if err != nil { return status, nil } status.Authenticated = ok if ok { - user, err := s.client.GetAuthenticatedUser(ctx) + user, err := client.GetAuthenticatedUser(ctx) if err == nil { status.Username = user } + } else { + status.Diagnostics = runGHDiagnostics(ctx) } return status, nil } +// retryClientCreation attempts to create a GitHub client when none exists. +func (s *Service) retryClientCreation(ctx context.Context) { + s.mu.Lock() + defer s.mu.Unlock() + if s.client != nil { + return // another goroutine already recovered + } + client, authMethod, err := NewClient(ctx, s.secrets, s.logger) + if err != nil { + s.logger.Debug("GitHub client retry failed", zap.Error(err)) + return + } + s.client = client + s.authMethod = authMethod + s.logger.Info("GitHub client recovered after retry", + zap.String("auth_method", authMethod)) +} + +// runGHDiagnostics runs gh auth status if the gh CLI is available. +func runGHDiagnostics(ctx context.Context) *AuthDiagnostics { + if !GHAvailable() { + return &AuthDiagnostics{ + Command: "gh auth status", + Output: "gh CLI is not installed. Install it from https://cli.github.com", + ExitCode: -1, + } + } + return NewGHClient().RunAuthDiagnostics(ctx) +} + // SubmitReview submits a review on a pull request. func (s *Service) SubmitReview(ctx context.Context, owner, repo string, number int, event, body string) error { if s.client == nil { diff --git a/apps/backend/internal/health/checks.go b/apps/backend/internal/health/checks.go new file mode 100644 index 0000000000..8b58452158 --- /dev/null +++ b/apps/backend/internal/health/checks.go @@ -0,0 +1,83 @@ +package health + +import "context" + +// GitHubStatusProvider abstracts the GitHub service status check. +type GitHubStatusProvider interface { + IsAuthenticated() bool + AuthMethod() string +} + +// GitHubChecker checks GitHub CLI/auth availability. +type GitHubChecker struct { + provider GitHubStatusProvider +} + +// NewGitHubChecker creates a checker for GitHub integration status. +// The provider may be nil if the GitHub service was not initialized. +func NewGitHubChecker(provider GitHubStatusProvider) *GitHubChecker { + return &GitHubChecker{provider: provider} +} + +func (c *GitHubChecker) Check(_ context.Context) []Issue { + if c.provider == nil { + return []Issue{{ + ID: "github_unavailable", + Category: "github", + Title: "GitHub integration unavailable", + Message: "Install the gh CLI and run 'gh auth login', or add a GITHUB_TOKEN secret.", + Severity: SeverityWarning, + FixURL: "/settings/workspace/{workspaceId}/github", + FixLabel: "Configure GitHub", + }} + } + if !c.provider.IsAuthenticated() { + return []Issue{{ + ID: "github_not_authenticated", + Category: "github", + Title: "GitHub not authenticated", + Message: "Run 'gh auth login' or add a GITHUB_TOKEN secret.", + Severity: SeverityWarning, + FixURL: "/settings/workspace/{workspaceId}/github", + FixLabel: "Configure GitHub", + }} + } + return nil +} + +// AgentDiscoveryProvider abstracts the agent discovery check. +type AgentDiscoveryProvider interface { + HasAvailableAgents(ctx context.Context) (bool, error) +} + +// AgentChecker checks whether any AI agents are detected. +type AgentChecker struct { + provider AgentDiscoveryProvider +} + +// NewAgentChecker creates a checker for agent availability. +func NewAgentChecker(provider AgentDiscoveryProvider) *AgentChecker { + return &AgentChecker{provider: provider} +} + +func (c *AgentChecker) Check(ctx context.Context) []Issue { + if c.provider == nil { + return nil + } + available, err := c.provider.HasAvailableAgents(ctx) + if err != nil { + return nil + } + if !available { + return []Issue{{ + ID: "no_agents", + Category: "agents", + Title: "No AI agents detected", + Message: "Install an AI coding agent (e.g. Claude Code, Codex) to start using KanDev.", + Severity: SeverityWarning, + FixURL: "/settings/agents", + FixLabel: "Configure Agents", + }} + } + return nil +} diff --git a/apps/backend/internal/health/checks_test.go b/apps/backend/internal/health/checks_test.go new file mode 100644 index 0000000000..f92518668d --- /dev/null +++ b/apps/backend/internal/health/checks_test.go @@ -0,0 +1,108 @@ +package health + +import ( + "context" + "fmt" + "testing" +) + +// --- GitHubChecker tests --- + +type mockGitHubProvider struct { + authenticated bool + authMethod string +} + +func (m *mockGitHubProvider) IsAuthenticated() bool { return m.authenticated } +func (m *mockGitHubProvider) AuthMethod() string { return m.authMethod } + +func TestGitHubChecker_NilProvider(t *testing.T) { + checker := NewGitHubChecker(nil) + issues := checker.Check(context.Background()) + if len(issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(issues)) + } + if issues[0].ID != "github_unavailable" { + t.Errorf("issue ID = %q, want %q", issues[0].ID, "github_unavailable") + } + if issues[0].Severity != SeverityWarning { + t.Errorf("severity = %q, want %q", issues[0].Severity, SeverityWarning) + } + if issues[0].Category != "github" { + t.Errorf("category = %q, want %q", issues[0].Category, "github") + } +} + +func TestGitHubChecker_NotAuthenticated(t *testing.T) { + checker := NewGitHubChecker(&mockGitHubProvider{authenticated: false, authMethod: "gh_cli"}) + issues := checker.Check(context.Background()) + if len(issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(issues)) + } + if issues[0].ID != "github_not_authenticated" { + t.Errorf("issue ID = %q, want %q", issues[0].ID, "github_not_authenticated") + } + if issues[0].Severity != SeverityWarning { + t.Errorf("severity = %q, want %q", issues[0].Severity, SeverityWarning) + } +} + +func TestGitHubChecker_Authenticated(t *testing.T) { + checker := NewGitHubChecker(&mockGitHubProvider{authenticated: true, authMethod: "gh_cli"}) + issues := checker.Check(context.Background()) + if len(issues) != 0 { + t.Errorf("expected 0 issues, got %d", len(issues)) + } +} + +// --- AgentChecker tests --- + +type mockAgentProvider struct { + available bool + err error +} + +func (m *mockAgentProvider) HasAvailableAgents(_ context.Context) (bool, error) { + return m.available, m.err +} + +func TestAgentChecker_Available(t *testing.T) { + checker := NewAgentChecker(&mockAgentProvider{available: true}) + issues := checker.Check(context.Background()) + if len(issues) != 0 { + t.Errorf("expected 0 issues, got %d", len(issues)) + } +} + +func TestAgentChecker_NotAvailable(t *testing.T) { + checker := NewAgentChecker(&mockAgentProvider{available: false}) + issues := checker.Check(context.Background()) + if len(issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(issues)) + } + if issues[0].ID != "no_agents" { + t.Errorf("issue ID = %q, want %q", issues[0].ID, "no_agents") + } + if issues[0].Category != "agents" { + t.Errorf("category = %q, want %q", issues[0].Category, "agents") + } + if issues[0].Severity != SeverityWarning { + t.Errorf("severity = %q, want %q", issues[0].Severity, SeverityWarning) + } +} + +func TestAgentChecker_Error(t *testing.T) { + checker := NewAgentChecker(&mockAgentProvider{err: fmt.Errorf("discovery failed")}) + issues := checker.Check(context.Background()) + if len(issues) != 0 { + t.Errorf("expected 0 issues on error, got %d", len(issues)) + } +} + +func TestAgentChecker_NilProvider(t *testing.T) { + checker := NewAgentChecker(nil) + issues := checker.Check(context.Background()) + if len(issues) != 0 { + t.Errorf("expected 0 issues for nil provider, got %d", len(issues)) + } +} diff --git a/apps/backend/internal/health/handler.go b/apps/backend/internal/health/handler.go new file mode 100644 index 0000000000..b53aabe248 --- /dev/null +++ b/apps/backend/internal/health/handler.go @@ -0,0 +1,22 @@ +package health + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/common/logger" +) + +// RegisterRoutes registers the system health endpoint. +func RegisterRoutes(router *gin.Engine, svc *Service, log *logger.Logger) { + api := router.Group("/api/v1/system") + api.GET("/health", handleGetHealth(svc)) + log.Debug("Registered System Health handlers (HTTP)") +} + +func handleGetHealth(svc *Service) gin.HandlerFunc { + return func(c *gin.Context) { + result := svc.RunChecks(c.Request.Context()) + c.JSON(http.StatusOK, result) + } +} diff --git a/apps/backend/internal/health/service.go b/apps/backend/internal/health/service.go new file mode 100644 index 0000000000..8790cf7d76 --- /dev/null +++ b/apps/backend/internal/health/service.go @@ -0,0 +1,37 @@ +package health + +import ( + "context" + + "github.com/kandev/kandev/internal/common/logger" +) + +// Service runs all registered health checks. +type Service struct { + checkers []Checker + logger *logger.Logger +} + +// NewService creates a health service with the given checkers. +func NewService(log *logger.Logger, checkers ...Checker) *Service { + return &Service{checkers: checkers, logger: log} +} + +// RunChecks executes all checkers and returns the aggregated result. +func (s *Service) RunChecks(ctx context.Context) *Response { + var issues []Issue + for _, c := range s.checkers { + issues = append(issues, c.Check(ctx)...) + } + healthy := true + for _, issue := range issues { + if issue.Severity == SeverityError || issue.Severity == SeverityWarning { + healthy = false + break + } + } + if issues == nil { + issues = []Issue{} + } + return &Response{Healthy: healthy, Issues: issues} +} diff --git a/apps/backend/internal/health/service_test.go b/apps/backend/internal/health/service_test.go new file mode 100644 index 0000000000..31008a036b --- /dev/null +++ b/apps/backend/internal/health/service_test.go @@ -0,0 +1,100 @@ +package health + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/common/logger" +) + +func newTestLogger() *logger.Logger { + log, _ := logger.NewLogger(logger.LoggingConfig{ + Level: "error", + Format: "json", + OutputPath: "stderr", + }) + return log +} + +func TestRunChecks_NoCheckers(t *testing.T) { + svc := NewService(newTestLogger()) + result := svc.RunChecks(context.Background()) + if !result.Healthy { + t.Error("expected healthy = true with no checkers") + } + if len(result.Issues) != 0 { + t.Errorf("expected 0 issues, got %d", len(result.Issues)) + } +} + +func TestRunChecks_AllHealthy(t *testing.T) { + svc := NewService(newTestLogger(), + NewGitHubChecker(&mockGitHubProvider{authenticated: true}), + NewAgentChecker(&mockAgentProvider{available: true}), + ) + result := svc.RunChecks(context.Background()) + if !result.Healthy { + t.Error("expected healthy = true") + } + if len(result.Issues) != 0 { + t.Errorf("expected 0 issues, got %d", len(result.Issues)) + } +} + +func TestRunChecks_WithWarnings(t *testing.T) { + svc := NewService(newTestLogger(), + NewGitHubChecker(nil), // will produce a warning + NewAgentChecker(&mockAgentProvider{available: true}), + ) + result := svc.RunChecks(context.Background()) + if result.Healthy { + t.Error("expected healthy = false with warnings") + } + if len(result.Issues) != 1 { + t.Fatalf("expected 1 issue, got %d", len(result.Issues)) + } + if result.Issues[0].ID != "github_unavailable" { + t.Errorf("issue ID = %q, want %q", result.Issues[0].ID, "github_unavailable") + } +} + +func TestRunChecks_MultipleIssues(t *testing.T) { + svc := NewService(newTestLogger(), + NewGitHubChecker(nil), // warning + NewAgentChecker(&mockAgentProvider{available: false}), // warning + ) + result := svc.RunChecks(context.Background()) + if result.Healthy { + t.Error("expected healthy = false") + } + if len(result.Issues) != 2 { + t.Fatalf("expected 2 issues, got %d", len(result.Issues)) + } +} + +func TestRunChecks_InfoOnlyIsHealthy(t *testing.T) { + // A checker that returns only info-severity issues + infoChecker := &staticChecker{issues: []Issue{{ + ID: "info_only", + Category: "system", + Title: "Info message", + Severity: SeverityInfo, + }}} + svc := NewService(newTestLogger(), infoChecker) + result := svc.RunChecks(context.Background()) + if !result.Healthy { + t.Error("expected healthy = true with info-only issues") + } + if len(result.Issues) != 1 { + t.Errorf("expected 1 issue, got %d", len(result.Issues)) + } +} + +// staticChecker is a test helper that returns fixed issues. +type staticChecker struct { + issues []Issue +} + +func (c *staticChecker) Check(_ context.Context) []Issue { + return c.issues +} diff --git a/apps/backend/internal/health/types.go b/apps/backend/internal/health/types.go new file mode 100644 index 0000000000..b2c99f7473 --- /dev/null +++ b/apps/backend/internal/health/types.go @@ -0,0 +1,34 @@ +package health + +import "context" + +// Severity indicates the importance of a health issue. +type Severity string + +const ( + SeverityWarning Severity = "warning" + SeverityError Severity = "error" + SeverityInfo Severity = "info" +) + +// Issue represents a single system health issue. +type Issue struct { + ID string `json:"id"` + Category string `json:"category"` + Title string `json:"title"` + Message string `json:"message"` + Severity Severity `json:"severity"` + FixURL string `json:"fix_url"` + FixLabel string `json:"fix_label"` +} + +// Response is the API response from /api/v1/system/health. +type Response struct { + Healthy bool `json:"healthy"` + Issues []Issue `json:"issues"` +} + +// Checker is the interface each health check implements. +type Checker interface { + Check(ctx context.Context) []Issue +} diff --git a/apps/web/components/github/github-status.tsx b/apps/web/components/github/github-status.tsx index 1c94f3bced..55d371c553 100644 --- a/apps/web/components/github/github-status.tsx +++ b/apps/web/components/github/github-status.tsx @@ -1,12 +1,38 @@ "use client"; -import { IconCheck, IconX } from "@tabler/icons-react"; +import { IconCheck, IconX, IconRefresh } from "@tabler/icons-react"; import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; import { Spinner } from "@kandev/ui/spinner"; import { useGitHubStatus } from "@/hooks/domains/github/use-github-status"; +import type { AuthDiagnostics } from "@/lib/types/github"; + +function DiagnosticsOutput({ diagnostics }: { diagnostics: AuthDiagnostics }) { + return ( +
+
+ {diagnostics.command} + + exit code: {diagnostics.exit_code} + +
+ {diagnostics.exit_code !== 0 && ( +

+ A non-zero exit code means the command failed. Review the output below for details. +

+ )} +
+        {diagnostics.output.trim()}
+      
+
+ ); +} export function GitHubStatusCard() { - const { status, loaded, loading } = useGitHubStatus(); + const { status, loaded, loading, refresh } = useGitHubStatus(); if (loading || !loaded) { return ( @@ -19,13 +45,20 @@ export function GitHubStatusCard() { if (!status || !status.authenticated) { return ( -
- - Not connected to GitHub - - Run gh auth login or add a GITHUB_TOKEN - secret - +
+
+ + Not connected to GitHub + + Run gh auth login or add a GITHUB_TOKEN + secret + + +
+ {status?.diagnostics && }
); } diff --git a/apps/web/components/kanban/kanban-header-mobile.tsx b/apps/web/components/kanban/kanban-header-mobile.tsx index a202e29342..a0b6c9be2a 100644 --- a/apps/web/components/kanban/kanban-header-mobile.tsx +++ b/apps/web/components/kanban/kanban-header-mobile.tsx @@ -5,6 +5,7 @@ import { Button } from "@kandev/ui/button"; import { TooltipProvider } from "@kandev/ui/tooltip"; import { IconMenu2 } from "@tabler/icons-react"; import { ReleaseNotesButton } from "../release-notes/release-notes-button"; +import { HealthIndicatorButton } from "../system-health/health-indicator"; import { MobileMenuSheet } from "./mobile-menu-sheet"; import { useAppStore } from "@/components/state-provider"; @@ -16,6 +17,8 @@ type KanbanHeaderMobileProps = { isSearchLoading?: boolean; showReleaseNotesButton: boolean; onOpenReleaseNotes: () => void; + showHealthIndicator: boolean; + onOpenHealthDialog: () => void; }; export function KanbanHeaderMobile({ @@ -26,6 +29,8 @@ export function KanbanHeaderMobile({ isSearchLoading = false, showReleaseNotesButton, onOpenReleaseNotes, + showHealthIndicator, + onOpenHealthDialog, }: KanbanHeaderMobileProps) { const isMenuOpen = useAppStore((state) => state.mobileKanban.isMenuOpen); const setMenuOpen = useAppStore((state) => state.setMobileKanbanMenuOpen); @@ -42,6 +47,11 @@ export function KanbanHeaderMobile({ )} + {showHealthIndicator && ( + + + + )} + + Setup Issues + + ); +} + +type HealthIssuesDialogProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + issues: HealthIssue[]; +}; + +export function HealthIssuesDialog({ open, onOpenChange, issues }: HealthIssuesDialogProps) { + const router = useRouter(); + const workspaceId = useAppStore((state) => state.workspaces.activeId); + + const resolveUrl = (url: string) => url.replace("{workspaceId}", workspaceId ?? ""); + + return ( + + + + + + Setup Issues + + + {issues.length === 1 + ? "1 issue needs your attention" + : `${issues.length} issues need your attention`} + + +
+ {issues.map((issue) => ( +
+
{issue.title}
+
{issue.message}
+ +
+ ))} +
+
+
+ ); +} diff --git a/apps/web/hooks/domains/github/use-github-status.ts b/apps/web/hooks/domains/github/use-github-status.ts index c353d3bd44..db03867b92 100644 --- a/apps/web/hooks/domains/github/use-github-status.ts +++ b/apps/web/hooks/domains/github/use-github-status.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useCallback } from "react"; import { fetchGitHubStatus } from "@/lib/api/domains/github-api"; import { useAppStore } from "@/components/state-provider"; @@ -10,9 +10,9 @@ export function useGitHubStatus() { const loading = useAppStore((state) => state.githubStatus.loading); const setGitHubStatus = useAppStore((state) => state.setGitHubStatus); const setGitHubStatusLoading = useAppStore((state) => state.setGitHubStatusLoading); + const invalidateSystemHealth = useAppStore((state) => state.invalidateSystemHealth); - useEffect(() => { - if (loaded || loading) return; + const doFetch = useCallback(() => { setGitHubStatusLoading(true); fetchGitHubStatus({ cache: "no-store" }) .then((response) => { @@ -24,7 +24,18 @@ export function useGitHubStatus() { .finally(() => { setGitHubStatusLoading(false); }); - }, [loaded, loading, setGitHubStatus, setGitHubStatusLoading]); + }, [setGitHubStatus, setGitHubStatusLoading]); - return { status, loaded, loading }; + useEffect(() => { + if (loaded || loading) return; + doFetch(); + }, [loaded, loading, doFetch]); + + const refresh = useCallback(() => { + // Also invalidate system health so the header indicator refetches + invalidateSystemHealth(); + doFetch(); + }, [doFetch, invalidateSystemHealth]); + + return { status, loaded, loading, refresh }; } diff --git a/apps/web/hooks/domains/settings/use-system-health.ts b/apps/web/hooks/domains/settings/use-system-health.ts new file mode 100644 index 0000000000..3012a9e05f --- /dev/null +++ b/apps/web/hooks/domains/settings/use-system-health.ts @@ -0,0 +1,31 @@ +"use client"; + +import { useEffect } from "react"; +import { fetchSystemHealth } from "@/lib/api/domains/health-api"; +import { useAppStore } from "@/components/state-provider"; + +export function useSystemHealth() { + const issues = useAppStore((state) => state.systemHealth.issues); + const healthy = useAppStore((state) => state.systemHealth.healthy); + const loaded = useAppStore((state) => state.systemHealth.loaded); + const loading = useAppStore((state) => state.systemHealth.loading); + const setSystemHealth = useAppStore((state) => state.setSystemHealth); + const setSystemHealthLoading = useAppStore((state) => state.setSystemHealthLoading); + + useEffect(() => { + if (loaded || loading) return; + setSystemHealthLoading(true); + fetchSystemHealth({ cache: "no-store" }) + .then((response) => { + setSystemHealth(response ?? { healthy: true, issues: [] }); + }) + .catch(() => { + setSystemHealth({ healthy: true, issues: [] }); + }) + .finally(() => { + setSystemHealthLoading(false); + }); + }, [loaded, loading, setSystemHealth, setSystemHealthLoading]); + + return { issues, healthy, loaded, loading }; +} diff --git a/apps/web/hooks/use-system-health-indicator.ts b/apps/web/hooks/use-system-health-indicator.ts new file mode 100644 index 0000000000..ac54fb6f68 --- /dev/null +++ b/apps/web/hooks/use-system-health-indicator.ts @@ -0,0 +1,27 @@ +"use client"; + +import { useState, useCallback } from "react"; +import { useSystemHealth } from "@/hooks/domains/settings/use-system-health"; + +export function useSystemHealthIndicator() { + const { issues, healthy, loaded } = useSystemHealth(); + const [dialogOpen, setDialogOpen] = useState(false); + + const hasIssues = loaded && !healthy && issues.length > 0; + + const openDialog = useCallback(() => { + setDialogOpen(true); + }, []); + + const closeDialog = useCallback(() => { + setDialogOpen(false); + }, []); + + return { + hasIssues, + issues, + dialogOpen, + openDialog, + closeDialog, + }; +} diff --git a/apps/web/lib/api/domains/health-api.ts b/apps/web/lib/api/domains/health-api.ts new file mode 100644 index 0000000000..5d4243f03c --- /dev/null +++ b/apps/web/lib/api/domains/health-api.ts @@ -0,0 +1,6 @@ +import { fetchJson, type ApiRequestOptions } from "../client"; +import type { SystemHealthResponse } from "@/lib/types/health"; + +export async function fetchSystemHealth(options?: ApiRequestOptions) { + return fetchJson("/api/v1/system/health", options); +} diff --git a/apps/web/lib/state/slices/index.ts b/apps/web/lib/state/slices/index.ts index 227a10e00c..3039e1d4f6 100644 --- a/apps/web/lib/state/slices/index.ts +++ b/apps/web/lib/state/slices/index.ts @@ -106,6 +106,7 @@ export type { MobileSessionState, ActiveDocument, DocumentPanelState, + SystemHealthState, } from "./ui/types"; export type { GitHubStatusState, diff --git a/apps/web/lib/state/slices/ui/types.ts b/apps/web/lib/state/slices/ui/types.ts index 2b8a4200ad..d289b737b1 100644 --- a/apps/web/lib/state/slices/ui/types.ts +++ b/apps/web/lib/state/slices/ui/types.ts @@ -1,3 +1,5 @@ +import type { HealthIssue, SystemHealthResponse } from "@/lib/types/health"; + export type PreviewStage = "closed" | "logs" | "preview"; export type PreviewViewMode = "preview" | "output"; export type PreviewDevicePreset = "desktop" | "tablet" | "mobile"; @@ -48,6 +50,13 @@ export type DocumentPanelState = { activeDocumentBySessionId: Record; }; +export type SystemHealthState = { + issues: HealthIssue[]; + healthy: boolean; + loaded: boolean; + loading: boolean; +}; + export type UISliceState = { previewPanel: PreviewPanelState; rightPanel: RightPanelState; @@ -57,6 +66,7 @@ export type UISliceState = { mobileSession: MobileSessionState; chatInput: ChatInputState; documentPanel: DocumentPanelState; + systemHealth: SystemHealthState; }; export type UISliceActions = { @@ -75,6 +85,9 @@ export type UISliceActions = { setMobileSessionTaskSwitcherOpen: (open: boolean) => void; setPlanMode: (sessionId: string, enabled: boolean) => void; setActiveDocument: (sessionId: string, doc: ActiveDocument | null) => void; + setSystemHealth: (response: SystemHealthResponse) => void; + setSystemHealthLoading: (loading: boolean) => void; + invalidateSystemHealth: () => void; }; export type UISlice = UISliceState & UISliceActions; diff --git a/apps/web/lib/state/slices/ui/ui-slice.ts b/apps/web/lib/state/slices/ui/ui-slice.ts index 7fab317d64..522dab5699 100644 --- a/apps/web/lib/state/slices/ui/ui-slice.ts +++ b/apps/web/lib/state/slices/ui/ui-slice.ts @@ -18,6 +18,7 @@ export const defaultUIState: UISliceState = { mobileSession: { activePanelBySessionId: {}, isTaskSwitcherOpen: false }, chatInput: { planModeBySessionId: {} }, documentPanel: { activeDocumentBySessionId: {} }, + systemHealth: { issues: [], healthy: true, loaded: false, loading: false }, }; export const createUISlice: StateCreator = ( @@ -94,4 +95,18 @@ export const createUISlice: StateCreator + set((draft) => { + draft.systemHealth.issues = response.issues; + draft.systemHealth.healthy = response.healthy; + draft.systemHealth.loaded = true; + }), + setSystemHealthLoading: (loading) => + set((draft) => { + draft.systemHealth.loading = loading; + }), + invalidateSystemHealth: () => + set((draft) => { + draft.systemHealth.loaded = false; + }), }); diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 49715ff129..391cdb4cb0 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -15,6 +15,7 @@ import type { PRWatch, ReviewWatch as GitHubReviewWatch, } from "@/lib/types/github"; +import type { SystemHealthResponse } from "@/lib/types/health"; import { createKanbanSlice, createWorkspaceSlice, @@ -189,6 +190,7 @@ export type AppState = { mobileSession: (typeof defaultUIState)["mobileSession"]; chatInput: (typeof defaultUIState)["chatInput"]; documentPanel: (typeof defaultUIState)["documentPanel"]; + systemHealth: (typeof defaultUIState)["systemHealth"]; // GitHub actions setGitHubStatus: (status: GitHubStatus | null) => void; @@ -287,6 +289,9 @@ export type AppState = { sessionId: string, doc: import("./slices/ui/types").ActiveDocument | null, ) => void; + setSystemHealth: (response: SystemHealthResponse) => void; + setSystemHealthLoading: (loading: boolean) => void; + invalidateSystemHealth: () => void; setMessages: ( sessionId: string, messages: Message[], @@ -416,6 +421,7 @@ const defaultState = { mobileKanban: defaultUIState.mobileKanban, mobileSession: defaultUIState.mobileSession, chatInput: defaultUIState.chatInput, + systemHealth: defaultUIState.systemHealth, }; function mergeInitialState(initialState?: Partial): typeof defaultState { @@ -483,6 +489,7 @@ function mergeInitialState(initialState?: Partial): typeof defaultStat mobileKanban: { ...defaultState.mobileKanban, ...initialState.mobileKanban }, mobileSession: { ...defaultState.mobileSession, ...initialState.mobileSession }, chatInput: { ...defaultState.chatInput, ...initialState.chatInput }, + systemHealth: { ...defaultState.systemHealth, ...initialState.systemHealth }, }; } @@ -557,6 +564,7 @@ export function createAppStore(initialState?: Partial) { mobileKanban: merged.mobileKanban, mobileSession: merged.mobileSession, chatInput: merged.chatInput, + systemHealth: merged.systemHealth, // Add hydrate method // eslint-disable-next-line @typescript-eslint/no-explicit-any hydrate: (state, options) => set((draft) => hydrateState(draft as any, state, options)), diff --git a/apps/web/lib/types/github.ts b/apps/web/lib/types/github.ts index b391b29535..8a31cd8864 100644 --- a/apps/web/lib/types/github.ts +++ b/apps/web/lib/types/github.ts @@ -2,10 +2,17 @@ export type GitHubAuthMethod = "gh_cli" | "pat" | "none"; +export type AuthDiagnostics = { + command: string; + output: string; + exit_code: number; +}; + export type GitHubStatus = { authenticated: boolean; username: string; auth_method: GitHubAuthMethod; + diagnostics?: AuthDiagnostics; }; export type GitHubPR = { diff --git a/apps/web/lib/types/health.ts b/apps/web/lib/types/health.ts new file mode 100644 index 0000000000..dfa88bbafa --- /dev/null +++ b/apps/web/lib/types/health.ts @@ -0,0 +1,16 @@ +export type HealthSeverity = "warning" | "error" | "info"; + +export type HealthIssue = { + id: string; + category: string; + title: string; + message: string; + severity: HealthSeverity; + fix_url: string; + fix_label: string; +}; + +export type SystemHealthResponse = { + healthy: boolean; + issues: HealthIssue[]; +};