From 27af55c4c37e18331f4e0aa5467732f3cd580ba2 Mon Sep 17 00:00:00 2001 From: Rafael Matias Date: Mon, 12 Jan 2026 12:03:35 +0100 Subject: [PATCH 1/2] feat(config): add optional runners_token for listing self-hosted runners Add support for a separate GitHub token specifically for listing self-hosted runners. This allows using different tokens with different scopes for runner polling vs workflow dispatch. - Add `runners_token` field to GitHubConfig (optional) - If set, runner polling uses `runners_token` - If not set, falls back to main `token` (backward compatible) - Dispatcher always uses main `token` for workflow dispatch - Split GitHub clients in server.go for separation of concerns --- cmd/dispatchoor/server.go | 61 +++++++++++++++++++++++++++------------ config.example.yaml | 2 ++ pkg/config/config.go | 16 ++++++++++ 3 files changed, 61 insertions(+), 18 deletions(-) diff --git a/cmd/dispatchoor/server.go b/cmd/dispatchoor/server.go index 840b8d3..889a380 100644 --- a/cmd/dispatchoor/server.go +++ b/cmd/dispatchoor/server.go @@ -80,27 +80,33 @@ func runServer(ctx context.Context, log *logrus.Logger, configPath string) error m := metrics.New() m.SetBuildInfo(Version, GitCommit, BuildDate) - // Create GitHub client (may operate in disconnected mode if no token or invalid token). - var ghClient github.Client + // Create GitHub clients. + // - runnersClient: used for polling runner status (uses runners_token if set, else token) + // - dispatchClient: used for dispatching workflows (uses token) + var runnersClient github.Client + + var dispatchClient github.Client var poller github.Poller - if cfg.HasGitHubToken() { - ghClient = github.NewClient(log, cfg.GitHub.Token) + // Create runners client for polling (uses runners_token if configured, else falls back to token). + if cfg.HasRunnersToken() { + runnersToken := cfg.GetRunnersToken() + runnersClient = github.NewClient(log.WithField("client", "runners"), runnersToken) - if err := ghClient.Start(ctx); err != nil { + if err := runnersClient.Start(ctx); err != nil { return err } defer func() { - if err := ghClient.Stop(); err != nil { - log.WithError(err).Warn("Failed to stop GitHub client") + if err := runnersClient.Stop(); err != nil { + log.WithError(err).Warn("Failed to stop runners GitHub client") } }() - // Only start poller if GitHub client is connected. - if ghClient.IsConnected() { - poller = github.NewPoller(log, cfg, ghClient, st, m) + // Only start poller if runners client is connected. + if runnersClient.IsConnected() { + poller = github.NewPoller(log, cfg, runnersClient, st, m) if err := poller.Start(ctx); err != nil { return err @@ -112,10 +118,31 @@ func runServer(ctx context.Context, log *logrus.Logger, configPath string) error } }() } else { - log.Warn("GitHub client not connected - runner polling disabled") + log.Warn("Runners GitHub client not connected - runner polling disabled") + } + } else { + log.Warn("No GitHub token configured for runners - runner polling disabled") + } + + // Create dispatch client for workflow dispatching (uses main token). + if cfg.HasGitHubToken() { + dispatchClient = github.NewClient(log.WithField("client", "dispatch"), cfg.GitHub.Token) + + if err := dispatchClient.Start(ctx); err != nil { + return err + } + + defer func() { + if err := dispatchClient.Stop(); err != nil { + log.WithError(err).Warn("Failed to stop dispatch GitHub client") + } + }() + + if !dispatchClient.IsConnected() { + log.Warn("Dispatch GitHub client not connected - workflow dispatch disabled") } } else { - log.Warn("No GitHub token configured - GitHub integration disabled") + log.Warn("No GitHub token configured for dispatch - workflow dispatch disabled") } // Create queue service. @@ -127,11 +154,11 @@ func runServer(ctx context.Context, log *logrus.Logger, configPath string) error defer queueSvc.Stop() - // Create and start dispatcher (only if GitHub client is connected). + // Create and start dispatcher (only if dispatch client is connected). var disp dispatcher.Dispatcher - if ghClient != nil && ghClient.IsConnected() { - disp = dispatcher.NewDispatcher(log, cfg, st, queueSvc, ghClient) + if dispatchClient != nil && dispatchClient.IsConnected() { + disp = dispatcher.NewDispatcher(log, cfg, st, queueSvc, dispatchClient) if err := disp.Start(ctx); err != nil { return err @@ -142,8 +169,6 @@ func runServer(ctx context.Context, log *logrus.Logger, configPath string) error log.WithError(err).Warn("Failed to stop dispatcher") } }() - } else { - log.Warn("Dispatcher disabled - GitHub client not connected") } // Create and start auth service. @@ -156,7 +181,7 @@ func runServer(ctx context.Context, log *logrus.Logger, configPath string) error defer authSvc.Stop() // Create and start API server. - srv := api.NewServer(log, cfg, st, queueSvc, authSvc, ghClient, m) + srv := api.NewServer(log, cfg, st, queueSvc, authSvc, dispatchClient, m) // Set up runner change callbacks to broadcast via WebSocket. if poller != nil { diff --git a/config.example.yaml b/config.example.yaml index 530149c..319493b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -22,6 +22,8 @@ database: github: token: ${GITHUB_TOKEN} + # Optional: separate token for listing runners (falls back to token if not set) + # runners_token: ${GITHUB_RUNNERS_TOKEN} poll_interval: 60s rate_limit_buffer: 100 diff --git a/pkg/config/config.go b/pkg/config/config.go index 38dece2..73ee6f6 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -55,6 +55,7 @@ type PostgresConfig struct { // GitHubConfig contains GitHub API settings. type GitHubConfig struct { Token string `yaml:"token"` + RunnersToken string `yaml:"runners_token"` PollInterval time.Duration `yaml:"poll_interval"` RateLimitBuffer int `yaml:"rate_limit_buffer"` } @@ -475,6 +476,21 @@ func (c *Config) HasGitHubToken() bool { return c.GitHub.Token != "" } +// GetRunnersToken returns the token to use for listing runners. +// Returns RunnersToken if configured, otherwise falls back to Token. +func (c *Config) GetRunnersToken() string { + if c.GitHub.RunnersToken != "" { + return c.GitHub.RunnersToken + } + + return c.GitHub.Token +} + +// HasRunnersToken returns true if a token for listing runners is available. +func (c *Config) HasRunnersToken() bool { + return c.GetRunnersToken() != "" +} + // String returns a sanitized string representation of the config (no secrets). func (c *Config) String() string { var sb strings.Builder From 0c2df93ace921afb2e3c14b9d659c41386b74c44 Mon Sep 17 00:00:00 2001 From: Rafael Matias Date: Mon, 12 Jan 2026 12:17:44 +0100 Subject: [PATCH 2/2] feat(api): expose rate limits for both runners and dispatch clients Update the status endpoint and UI to display rate limits for both GitHub clients separately when runners_token is configured. - Add runnersClient to API server struct - Create GitHubClientStatus and GitHubClientsStatus response types - Update handleStatus() to return status for both clients - Update TypeScript types for new response structure - Update StatusIndicator to show runners and dispatch rate limits --- cmd/dispatchoor/server.go | 2 +- pkg/api/api.go | 134 ++++++++++--------- ui/src/components/layout/StatusIndicator.tsx | 60 +++++++-- ui/src/types/index.ts | 11 +- 4 files changed, 130 insertions(+), 77 deletions(-) diff --git a/cmd/dispatchoor/server.go b/cmd/dispatchoor/server.go index 889a380..37bdd45 100644 --- a/cmd/dispatchoor/server.go +++ b/cmd/dispatchoor/server.go @@ -181,7 +181,7 @@ func runServer(ctx context.Context, log *logrus.Logger, configPath string) error defer authSvc.Stop() // Create and start API server. - srv := api.NewServer(log, cfg, st, queueSvc, authSvc, dispatchClient, m) + srv := api.NewServer(log, cfg, st, queueSvc, authSvc, runnersClient, dispatchClient, m) // Set up runner change callbacks to broadcast via WebSocket. if poller != nil { diff --git a/pkg/api/api.go b/pkg/api/api.go index 66cc038..7c6ea98 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -31,34 +31,36 @@ type Server interface { // server implements Server. type server struct { - log logrus.FieldLogger - cfg *config.Config - store store.Store - queue queue.Service - auth auth.Service - ghClient github.Client - metrics *metrics.Metrics - hub *Hub - srv *http.Server - router chi.Router + log logrus.FieldLogger + cfg *config.Config + store store.Store + queue queue.Service + auth auth.Service + runnersClient github.Client + dispatchClient github.Client + metrics *metrics.Metrics + hub *Hub + srv *http.Server + router chi.Router } // Ensure server implements Server. var _ Server = (*server)(nil) // NewServer creates a new API server. -func NewServer(log logrus.FieldLogger, cfg *config.Config, st store.Store, q queue.Service, authSvc auth.Service, ghClient github.Client, m *metrics.Metrics) Server { +func NewServer(log logrus.FieldLogger, cfg *config.Config, st store.Store, q queue.Service, authSvc auth.Service, runnersClient, dispatchClient github.Client, m *metrics.Metrics) Server { hub := NewHub(log) s := &server{ - log: log.WithField("component", "api"), - cfg: cfg, - store: st, - queue: q, - auth: authSvc, - ghClient: ghClient, - metrics: m, - hub: hub, + log: log.WithField("component", "api"), + cfg: cfg, + store: st, + queue: q, + auth: authSvc, + runnersClient: runnersClient, + dispatchClient: dispatchClient, + metrics: m, + hub: hub, } // Set up callback to broadcast job state changes via WebSocket. @@ -367,42 +369,37 @@ func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) { } } - // GitHub connection and rate limit info. - if s.ghClient == nil { - resp.GitHub = GitHubStatus{ - Status: ComponentStatusUnhealthy, - Connected: false, - Error: "GitHub token not configured", - } + // GitHub connection and rate limit info for both clients. + resp.GitHub = GitHubClientsStatus{} - if resp.Status == ComponentStatusHealthy { - resp.Status = ComponentStatusDegraded - } - } else if !s.ghClient.IsConnected() { - resp.GitHub = GitHubStatus{ - Status: ComponentStatusUnhealthy, - Connected: false, - Error: s.ghClient.ConnectionError(), + // Helper function to get status for a single client. + getClientStatus := func(client github.Client, name string) *GitHubClientStatus { + if client == nil { + return &GitHubClientStatus{ + Status: ComponentStatusUnhealthy, + Connected: false, + Error: name + " token not configured", + } } - if resp.Status == ComponentStatusHealthy { - resp.Status = ComponentStatusDegraded + if !client.IsConnected() { + return &GitHubClientStatus{ + Status: ComponentStatusUnhealthy, + Connected: false, + Error: client.ConnectionError(), + } } - } else { - remaining := s.ghClient.RateLimitRemaining() - resetTime := s.ghClient.RateLimitReset() - githubStatus := ComponentStatusHealthy + remaining := client.RateLimitRemaining() + resetTime := client.RateLimitReset() + + clientStatus := ComponentStatusHealthy if remaining < 100 { - githubStatus = ComponentStatusDegraded + clientStatus = ComponentStatusDegraded } if remaining < 10 { - githubStatus = ComponentStatusUnhealthy - - if resp.Status == ComponentStatusHealthy { - resp.Status = ComponentStatusDegraded - } + clientStatus = ComponentStatusUnhealthy } resetIn := time.Until(resetTime) @@ -410,8 +407,8 @@ func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) { resetIn = 0 } - resp.GitHub = GitHubStatus{ - Status: githubStatus, + return &GitHubClientStatus{ + Status: clientStatus, Connected: true, RateLimitRemaining: remaining, RateLimitReset: resetTime.UTC().Format(time.RFC3339), @@ -419,6 +416,17 @@ func (s *server) handleStatus(w http.ResponseWriter, r *http.Request) { } } + resp.GitHub.Runners = getClientStatus(s.runnersClient, "Runners") + resp.GitHub.Dispatch = getClientStatus(s.dispatchClient, "Dispatch") + + // Update overall status based on GitHub clients. + if (resp.GitHub.Runners != nil && resp.GitHub.Runners.Status == ComponentStatusUnhealthy) || + (resp.GitHub.Dispatch != nil && resp.GitHub.Dispatch.Status == ComponentStatusUnhealthy) { + if resp.Status == ComponentStatusHealthy { + resp.Status = ComponentStatusDegraded + } + } + // Queue statistics. pendingJobs, _ := s.store.ListJobsByStatus(ctx, store.JobStatusPending) triggeredJobs, _ := s.store.ListJobsByStatus(ctx, store.JobStatusTriggered) @@ -1127,21 +1135,21 @@ func (s *server) handleCancelJob(w http.ResponseWriter, r *http.Request) { return } - // Check if GitHub client is available. - if s.ghClient == nil || !s.ghClient.IsConnected() { + // Check if dispatch client is available. + if s.dispatchClient == nil || !s.dispatchClient.IsConnected() { s.writeError(w, http.StatusServiceUnavailable, "GitHub integration is not available") return } // Cancel the workflow run on GitHub. - if err := s.ghClient.CancelWorkflowRun(r.Context(), owner, repo, *job.RunID); err != nil { + if err := s.dispatchClient.CancelWorkflowRun(r.Context(), owner, repo, *job.RunID); err != nil { s.log.WithError(err).Warn("Cancel request returned error, checking actual run status") // Check if the run was actually cancelled despite the error. // GitHub can return transient errors like "job scheduled on GitHub side" // even when the cancellation succeeds. - run, getErr := s.ghClient.GetWorkflowRun(r.Context(), owner, repo, *job.RunID) + run, getErr := s.dispatchClient.GetWorkflowRun(r.Context(), owner, repo, *job.RunID) if getErr != nil { s.log.WithError(getErr).Error("Failed to verify workflow run status after cancel error") s.writeError(w, http.StatusInternalServerError, "Failed to cancel workflow run on GitHub") @@ -1320,8 +1328,8 @@ type DatabaseStatus struct { Error string `json:"error,omitempty"` } -// GitHubStatus contains GitHub API rate limit information. -type GitHubStatus struct { +// GitHubClientStatus contains status and rate limit information for a single GitHub client. +type GitHubClientStatus struct { Status ComponentStatus `json:"status"` Connected bool `json:"connected"` Error string `json:"error,omitempty"` @@ -1330,6 +1338,12 @@ type GitHubStatus struct { ResetIn string `json:"reset_in,omitempty"` } +// GitHubClientsStatus contains status for both GitHub clients. +type GitHubClientsStatus struct { + Runners *GitHubClientStatus `json:"runners,omitempty"` + Dispatch *GitHubClientStatus `json:"dispatch,omitempty"` +} + // QueueStats contains queue statistics. type QueueStats struct { PendingJobs int `json:"pending_jobs"` @@ -1346,12 +1360,12 @@ type VersionInfo struct { // SystemStatusResponse is the comprehensive status response. type SystemStatusResponse struct { - Status ComponentStatus `json:"status"` - Timestamp string `json:"timestamp"` - Database DatabaseStatus `json:"database"` - GitHub GitHubStatus `json:"github"` - Queue QueueStats `json:"queue"` - Version VersionInfo `json:"version"` + Status ComponentStatus `json:"status"` + Timestamp string `json:"timestamp"` + Database DatabaseStatus `json:"database"` + GitHub GitHubClientsStatus `json:"github"` + Queue QueueStats `json:"queue"` + Version VersionInfo `json:"version"` } // HistoryResponse wraps the paginated history response. diff --git a/ui/src/components/layout/StatusIndicator.tsx b/ui/src/components/layout/StatusIndicator.tsx index 93e202b..d32cb33 100644 --- a/ui/src/components/layout/StatusIndicator.tsx +++ b/ui/src/components/layout/StatusIndicator.tsx @@ -124,23 +124,55 @@ export function StatusIndicator() { {/* GitHub API Status */}
-
+
GitHub API - {systemStatus?.github ? ( -
-
- - {systemStatus.github.rate_limit_remaining} remaining - -
- ) : ( - - )}
- {systemStatus?.github && ( -
- Resets {systemStatus.github.reset_in || 'soon'} + {systemStatus?.github ? ( +
+ {/* Runners Client */} + {systemStatus.github.runners && ( +
+ Runners +
+
+ + {systemStatus.github.runners.connected + ? `${systemStatus.github.runners.rate_limit_remaining} remaining` + : 'Not configured'} + +
+
+ )} + {systemStatus.github.runners?.connected && systemStatus.github.runners.reset_in && ( +
+ Resets {systemStatus.github.runners.reset_in} +
+ )} + {/* Dispatch Client */} + {systemStatus.github.dispatch && ( +
+ Dispatch +
+
+ + {systemStatus.github.dispatch.connected + ? `${systemStatus.github.dispatch.rate_limit_remaining} remaining` + : 'Not configured'} + +
+
+ )} + {systemStatus.github.dispatch?.connected && systemStatus.github.dispatch.reset_in && ( +
+ Resets {systemStatus.github.dispatch.reset_in} +
+ )} + {!systemStatus.github.runners && !systemStatus.github.dispatch && ( + No clients configured + )}
+ ) : ( + )}
diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 3883d40..1079807 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -146,13 +146,20 @@ export interface DatabaseStatus { error?: string; } -export interface GitHubStatus { +export interface GitHubClientStatus { status: ComponentStatus; + connected: boolean; + error?: string; rate_limit_remaining: number; rate_limit_reset: string; reset_in?: string; } +export interface GitHubClientsStatus { + runners?: GitHubClientStatus; + dispatch?: GitHubClientStatus; +} + export interface QueueStats { pending_jobs: number; triggered_jobs: number; @@ -169,7 +176,7 @@ export interface SystemStatus { status: ComponentStatus; timestamp: string; database: DatabaseStatus; - github: GitHubStatus; + github: GitHubClientsStatus; queue: QueueStats; version: VersionInfo; }