From e502334a58ff1e9f4bfd2cb4e860ffde2412d5fb Mon Sep 17 00:00:00 2001 From: Rafael Matias Date: Tue, 10 Mar 2026 11:05:02 +0100 Subject: [PATCH] feat(api,ui): add admin endpoint to reload templates without restart Add POST /api/v1/templates/reload endpoint that re-reads the config file (picking up updated template files and re-fetching URLs), syncs refreshed templates to the database, and updates the in-memory config. Includes audit logging, mutex protection for concurrent config access, and a "Reload Templates" button in the UI Templates tab for admins. --- cmd/dispatchoor/server.go | 2 +- pkg/api/api.go | 103 +++++++- pkg/api/api_test.go | 483 +++++++++++++++++++++++++++++++++++++ pkg/api/docs/docs.go | 71 ++++++ pkg/api/docs/swagger.json | 71 ++++++ pkg/api/docs/swagger.yaml | 47 ++++ ui/src/api/client.ts | 6 + ui/src/pages/GroupPage.tsx | 36 ++- ui/src/types/index.ts | 11 + 9 files changed, 823 insertions(+), 7 deletions(-) create mode 100644 pkg/api/api_test.go diff --git a/cmd/dispatchoor/server.go b/cmd/dispatchoor/server.go index 37bdd45..42fce9a 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, runnersClient, dispatchClient, m) + srv := api.NewServer(log, cfg, configPath, 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 a2be01c..56bed94 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -7,6 +7,7 @@ import ( "net/http" "strconv" "strings" + "sync" "time" "github.com/ethpandaops/dispatchoor/pkg/api/docs" @@ -18,6 +19,7 @@ import ( "github.com/ethpandaops/dispatchoor/pkg/store" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" + "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/sirupsen/logrus" ) @@ -33,6 +35,8 @@ type Server interface { type server struct { log logrus.FieldLogger cfg *config.Config + cfgMu sync.RWMutex + configPath string store store.Store queue queue.Service auth auth.Service @@ -53,12 +57,13 @@ type server struct { 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, runnersClient, dispatchClient github.Client, m *metrics.Metrics) Server { +func NewServer(log logrus.FieldLogger, cfg *config.Config, configPath string, 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, + configPath: configPath, store: st, queue: q, auth: authSvc, @@ -129,8 +134,12 @@ func (s *server) Stop() error { // BroadcastRunnerChange broadcasts a runner status change to all matching groups. func (s *server) BroadcastRunnerChange(runner *store.Runner) { + s.cfgMu.RLock() + groups := s.cfg.Groups.GitHub + s.cfgMu.RUnlock() + // Find all groups whose labels the runner matches. - for _, groupCfg := range s.cfg.Groups.GitHub { + for _, groupCfg := range groups { if runnerMatchesLabels(runner.Labels, groupCfg.RunnerLabels) { s.hub.BroadcastRunnerStatus(runner, groupCfg.ID) } @@ -267,6 +276,9 @@ func (s *server) setupRouter() { // Runner refresh (admin). r.Post("/runners/refresh", s.handleRefreshRunners) + + // Template reload (admin). + r.Post("/templates/reload", s.handleReloadTemplates) }) }) }) @@ -2211,3 +2223,90 @@ func SyncGroupsFromConfig(ctx context.Context, log logrus.FieldLogger, st store. return nil } + +// ReloadTemplatesResponse is the response for the template reload endpoint. +type ReloadTemplatesResponse struct { + Message string `json:"message" example:"Templates reloaded successfully"` + Groups []ReloadTemplatesGroupStats `json:"groups"` +} + +// ReloadTemplatesGroupStats contains template change counts for a group. +type ReloadTemplatesGroupStats struct { + GroupID string `json:"group_id" example:"my-group"` + Templates int `json:"templates" example:"5"` +} + +// handleReloadTemplates godoc +// +// @Summary Reload templates +// @Description Re-reads the config file to reload templates from files and URLs, then syncs to the database (requires admin) +// @Tags templates +// @Security BearerAuth +// @Produce json +// @Success 200 {object} ReloadTemplatesResponse +// @Failure 401 {object} ErrorResponse +// @Failure 403 {object} ErrorResponse +// @Failure 500 {object} ErrorResponse +// @Router /templates/reload [post] +func (s *server) handleReloadTemplates(w http.ResponseWriter, r *http.Request) { + s.log.Info("Reloading templates from config") + + // Re-read config file to pick up updated template files and URLs. + newCfg, err := config.Load(s.configPath) + if err != nil { + s.log.WithError(err).Error("Failed to reload config") + s.writeError(w, http.StatusInternalServerError, + fmt.Sprintf("Failed to reload config: %v", err)) + + return + } + + // Sync the refreshed templates to the database. + if err := SyncGroupsFromConfig(r.Context(), s.log, s.store, newCfg); err != nil { + s.log.WithError(err).Error("Failed to sync templates") + s.writeError(w, http.StatusInternalServerError, + fmt.Sprintf("Failed to sync templates: %v", err)) + + return + } + + // Update the in-memory config's groups so the poller picks up changes. + s.cfgMu.Lock() + s.cfg.Groups = newCfg.Groups + s.cfgMu.Unlock() + + // Build response with per-group template counts. + groups := make([]ReloadTemplatesGroupStats, 0, len(newCfg.Groups.GitHub)) + for _, g := range newCfg.Groups.GitHub { + groups = append(groups, ReloadTemplatesGroupStats{ + GroupID: g.ID, + Templates: len(g.WorkflowDispatchTemplates), + }) + } + + // Create audit log entry. + actor := "anonymous" + if user := auth.UserFromContext(r.Context()); user != nil { + actor = user.Username + } + + auditEntry := &store.AuditEntry{ + ID: uuid.New().String(), + Action: store.AuditActionConfigReload, + EntityType: store.AuditEntitySystem, + EntityID: "templates", + Actor: actor, + Details: fmt.Sprintf("Reloaded templates for %d groups", len(groups)), + CreatedAt: time.Now(), + } + + if err := s.store.CreateAuditEntry(r.Context(), auditEntry); err != nil { + s.log.WithError(err).Warn("Failed to create audit entry for template reload") + } + + s.log.WithField("groups", len(groups)).Info("Templates reloaded successfully") + s.writeJSON(w, http.StatusOK, ReloadTemplatesResponse{ + Message: "Templates reloaded successfully", + Groups: groups, + }) +} diff --git a/pkg/api/api_test.go b/pkg/api/api_test.go new file mode 100644 index 0000000..8e85aba --- /dev/null +++ b/pkg/api/api_test.go @@ -0,0 +1,483 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + "time" + + "github.com/ethpandaops/dispatchoor/pkg/auth" + "github.com/ethpandaops/dispatchoor/pkg/config" + "github.com/ethpandaops/dispatchoor/pkg/github" + "github.com/ethpandaops/dispatchoor/pkg/metrics" + "github.com/ethpandaops/dispatchoor/pkg/queue" + "github.com/ethpandaops/dispatchoor/pkg/store" + "github.com/sirupsen/logrus" +) + +// stubQueue implements queue.Service with no-op methods for testing. +type stubQueue struct{} + +func (q *stubQueue) Start(context.Context) error { return nil } +func (q *stubQueue) Stop() error { return nil } +func (q *stubQueue) SetJobChangeCallback(queue.JobChangeCallback) {} +func (q *stubQueue) Enqueue(context.Context, string, string, string, map[string]string, *queue.EnqueueOptions) (*store.Job, error) { + return nil, nil +} +func (q *stubQueue) Dequeue(context.Context, string) (*store.Job, error) { return nil, nil } +func (q *stubQueue) Peek(context.Context, string) (*store.Job, error) { return nil, nil } +func (q *stubQueue) Remove(context.Context, string) error { return nil } +func (q *stubQueue) Reorder(context.Context, string, []string) error { return nil } +func (q *stubQueue) GetJob(context.Context, string) (*store.Job, error) { return nil, nil } +func (q *stubQueue) ListPending(context.Context, string) ([]*store.Job, error) { + return nil, nil +} +func (q *stubQueue) ListByStatus(context.Context, string, ...store.JobStatus) ([]*store.Job, error) { + return nil, nil +} +func (q *stubQueue) ListHistory(context.Context, string, int) ([]*store.Job, error) { + return nil, nil +} +func (q *stubQueue) ListHistoryPaginated(context.Context, store.HistoryQueryOpts) (*store.HistoryResult, error) { + return nil, nil +} +func (q *stubQueue) MarkTriggered(context.Context, string, int64, string) error { return nil } +func (q *stubQueue) MarkRunning(context.Context, string, int64, string) error { return nil } +func (q *stubQueue) MarkCompleted(context.Context, string) error { return nil } +func (q *stubQueue) MarkFailed(context.Context, string, string) error { return nil } +func (q *stubQueue) MarkCancelled(context.Context, string) error { return nil } +func (q *stubQueue) Pause(context.Context, string) (*store.Job, error) { return nil, nil } +func (q *stubQueue) Unpause(context.Context, string) (*store.Job, error) { return nil, nil } +func (q *stubQueue) UpdateInputs(context.Context, string, map[string]string) error { + return nil +} +func (q *stubQueue) UpdateJob(context.Context, string, *queue.UpdateJobOptions) error { + return nil +} +func (q *stubQueue) DisableAutoRequeue(context.Context, string) (*store.Job, error) { + return nil, nil +} +func (q *stubQueue) UpdateAutoRequeue(context.Context, string, bool, *int) (*store.Job, error) { + return nil, nil +} + +// stubAuth implements auth.Service for testing. +type stubAuth struct{} + +func (a *stubAuth) Start(context.Context) error { return nil } +func (a *stubAuth) Stop() error { return nil } +func (a *stubAuth) AuthenticateBasic(context.Context, string, string) (*store.User, string, error) { + return nil, "", nil +} +func (a *stubAuth) AuthenticateGitHub(context.Context, string) (*store.User, string, error) { + return nil, "", nil +} +func (a *stubAuth) ValidateSession(_ context.Context, _ string) (*store.User, error) { + return &store.User{ + ID: "test-user-id", + Username: "testadmin", + Role: store.RoleAdmin, + }, nil +} +func (a *stubAuth) Logout(context.Context, string) error { return nil } +func (a *stubAuth) HasRole(_ *store.User, _ store.Role) bool { return true } +func (a *stubAuth) IsAdmin(_ *store.User) bool { return true } +func (a *stubAuth) GetGitHubAuthURL(string) string { return "" } +func (a *stubAuth) CreateOAuthState(context.Context) (string, error) { return "", nil } +func (a *stubAuth) ValidateOAuthState(context.Context, string) error { return nil } +func (a *stubAuth) CreateAuthCode(context.Context, string) (string, error) { + return "", nil +} +func (a *stubAuth) ExchangeAuthCode(context.Context, string) (*store.User, string, error) { + return nil, "", nil +} + +// stubGitHubClient implements github.Client for testing. +type stubGitHubClient struct{} + +func (c *stubGitHubClient) Start(context.Context) error { return nil } +func (c *stubGitHubClient) Stop() error { return nil } +func (c *stubGitHubClient) IsConnected() bool { return false } +func (c *stubGitHubClient) ConnectionError() string { return "" } +func (c *stubGitHubClient) ListOrgRunners(context.Context, string) ([]*github.Runner, error) { + return nil, nil +} +func (c *stubGitHubClient) ListRepoRunners(context.Context, string, string) ([]*github.Runner, error) { + return nil, nil +} +func (c *stubGitHubClient) TriggerWorkflowDispatch(context.Context, string, string, string, string, map[string]string) error { + return nil +} +func (c *stubGitHubClient) GetWorkflowRun(context.Context, string, string, int64) (*github.WorkflowRun, error) { + return nil, nil +} +func (c *stubGitHubClient) ListWorkflowRuns(context.Context, string, string, string, github.ListWorkflowRunsOpts) ([]*github.WorkflowRun, error) { + return nil, nil +} +func (c *stubGitHubClient) ListWorkflowRunJobs(context.Context, string, string, int64) ([]*github.WorkflowJob, error) { + return nil, nil +} +func (c *stubGitHubClient) CancelWorkflowRun(context.Context, string, string, int64) error { + return nil +} +func (c *stubGitHubClient) RateLimitRemaining() int { return 0 } +func (c *stubGitHubClient) RateLimitReset() time.Time { return time.Time{} } + +// Verify interface compliance. +var ( + _ queue.Service = (*stubQueue)(nil) + _ auth.Service = (*stubAuth)(nil) + _ github.Client = (*stubGitHubClient)(nil) +) + +// testMetrics is a shared metrics instance to avoid duplicate prometheus registration. +var testMetrics = metrics.New() + +// writeTestConfig writes a minimal valid config YAML file and returns the path. +func writeTestConfig(t *testing.T, dir, dbPath string, templates []map[string]any) string { + t.Helper() + + cfg := map[string]any{ + "server": map[string]any{ + "listen": ":0", + }, + "database": map[string]any{ + "driver": "sqlite", + "sqlite": map[string]any{ + "path": dbPath, + }, + }, + "auth": map[string]any{ + "basic": map[string]any{ + "enabled": true, + "users": []map[string]any{ + {"username": "admin", "password": "pass", "role": "admin"}, + }, + }, + }, + "groups": map[string]any{ + "github": []map[string]any{ + { + "id": "test-group", + "name": "Test Group", + "runner_labels": []string{"self-hosted"}, + "workflow_dispatch_templates": templates, + }, + }, + }, + } + + data, err := json.Marshal(cfg) + if err != nil { + t.Fatal(err) + } + + // Write as YAML-compatible JSON (JSON is valid YAML). + cfgPath := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(cfgPath, data, 0o644); err != nil { + t.Fatal(err) + } + + return cfgPath +} + +func TestHandleReloadTemplates(t *testing.T) { + ctx := context.Background() + log := logrus.New() + log.SetOutput(os.Stderr) + + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + // Create initial config with one template. + initialTemplates := []map[string]any{ + { + "id": "tmpl-1", + "name": "Template 1", + "owner": "org", + "repo": "repo", + "workflow_id": "build.yml", + "ref": "main", + }, + } + cfgPath := writeTestConfig(t, tmpDir, dbPath, initialTemplates) + + // Load config and set up store. + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + st := store.NewSQLiteStore(log, dbPath) + if err := st.Start(ctx); err != nil { + t.Fatalf("Failed to start store: %v", err) + } + defer func() { _ = st.Stop() }() + + if err := st.Migrate(ctx); err != nil { + t.Fatalf("Failed to migrate: %v", err) + } + + if err := SyncGroupsFromConfig(ctx, log, st, cfg); err != nil { + t.Fatalf("Failed to sync groups: %v", err) + } + + srv := NewServer(log, cfg, cfgPath, st, &stubQueue{}, &stubAuth{}, + &stubGitHubClient{}, &stubGitHubClient{}, testMetrics) + + s := srv.(*server) + + // Verify initial state: one template. + templates, err := st.ListJobTemplatesByGroup(ctx, "test-group") + if err != nil { + t.Fatalf("Failed to list templates: %v", err) + } + + if len(templates) != 1 { + t.Fatalf("Expected 1 template, got %d", len(templates)) + } + + // Update config file: add a second template. + updatedTemplates := []map[string]any{ + { + "id": "tmpl-1", + "name": "Template 1 Updated", + "owner": "org", + "repo": "repo", + "workflow_id": "build.yml", + "ref": "main", + }, + { + "id": "tmpl-2", + "name": "Template 2", + "owner": "org", + "repo": "repo", + "workflow_id": "deploy.yml", + "ref": "main", + }, + } + writeTestConfig(t, tmpDir, dbPath, updatedTemplates) + + // Call the reload endpoint. + req := httptest.NewRequest(http.MethodPost, "/api/v1/templates/reload", nil) + req.Header.Set("Authorization", "Bearer test-token") + + // Inject admin user into context (simulating auth middleware). + adminUser := &store.User{ + ID: "test-user-id", + Username: "testadmin", + Role: store.RoleAdmin, + } + req = req.WithContext(auth.ContextWithUser(req.Context(), adminUser)) + + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("Expected status 200, got %d: %s", w.Code, w.Body.String()) + } + + var resp ReloadTemplatesResponse + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("Failed to decode response: %v", err) + } + + if resp.Message != "Templates reloaded successfully" { + t.Errorf("Unexpected message: %s", resp.Message) + } + + if len(resp.Groups) != 1 { + t.Fatalf("Expected 1 group in response, got %d", len(resp.Groups)) + } + + if resp.Groups[0].GroupID != "test-group" { + t.Errorf("Expected group_id 'test-group', got '%s'", resp.Groups[0].GroupID) + } + + if resp.Groups[0].Templates != 2 { + t.Errorf("Expected 2 templates, got %d", resp.Groups[0].Templates) + } + + // Verify database was updated. + templates, err = st.ListJobTemplatesByGroup(ctx, "test-group") + if err != nil { + t.Fatalf("Failed to list templates after reload: %v", err) + } + + if len(templates) != 2 { + t.Fatalf("Expected 2 templates in DB after reload, got %d", len(templates)) + } + + // Verify template name was updated. + tmpl1, err := st.GetJobTemplate(ctx, "tmpl-1") + if err != nil { + t.Fatalf("Failed to get template: %v", err) + } + + if tmpl1.Name != "Template 1 Updated" { + t.Errorf("Expected template name 'Template 1 Updated', got '%s'", tmpl1.Name) + } + + // Verify in-memory config was updated. + s.cfgMu.RLock() + groupsCfg := s.cfg.Groups + s.cfgMu.RUnlock() + + if len(groupsCfg.GitHub) != 1 { + t.Fatalf("Expected 1 group in config, got %d", len(groupsCfg.GitHub)) + } + + if len(groupsCfg.GitHub[0].WorkflowDispatchTemplates) != 2 { + t.Errorf("Expected 2 templates in config, got %d", + len(groupsCfg.GitHub[0].WorkflowDispatchTemplates)) + } + + // Verify audit entry was created. + entries, _, err := st.ListAuditEntries(ctx, store.AuditQueryOpts{ + Action: ptr(store.AuditActionConfigReload), + Limit: 10, + }) + if err != nil { + t.Fatalf("Failed to list audit entries: %v", err) + } + + if len(entries) != 1 { + t.Fatalf("Expected 1 audit entry, got %d", len(entries)) + } + + if entries[0].Actor != "testadmin" { + t.Errorf("Expected audit actor 'testadmin', got '%s'", entries[0].Actor) + } +} + +func TestHandleReloadTemplates_Unauthorized(t *testing.T) { + log := logrus.New() + log.SetOutput(os.Stderr) + + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + templates := []map[string]any{ + { + "id": "tmpl-1", + "name": "Template 1", + "owner": "org", + "repo": "repo", + "workflow_id": "build.yml", + "ref": "main", + }, + } + cfgPath := writeTestConfig(t, tmpDir, dbPath, templates) + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + st := store.NewSQLiteStore(log, dbPath) + if err := st.Start(context.Background()); err != nil { + t.Fatalf("Failed to start store: %v", err) + } + defer func() { _ = st.Stop() }() + + if err := st.Migrate(context.Background()); err != nil { + t.Fatalf("Failed to migrate: %v", err) + } + + srv := NewServer(log, cfg, cfgPath, st, &stubQueue{}, &stubAuth{}, + &stubGitHubClient{}, &stubGitHubClient{}, testMetrics) + + s := srv.(*server) + + // Request without auth token should fail. + req := httptest.NewRequest(http.MethodPost, "/api/v1/templates/reload", nil) + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("Expected status 401, got %d: %s", w.Code, w.Body.String()) + } +} + +func TestHandleReloadTemplates_InvalidConfig(t *testing.T) { + ctx := context.Background() + log := logrus.New() + log.SetOutput(os.Stderr) + + tmpDir := t.TempDir() + dbPath := filepath.Join(tmpDir, "test.db") + + // Create a valid initial config. + templates := []map[string]any{ + { + "id": "tmpl-1", + "name": "Template 1", + "owner": "org", + "repo": "repo", + "workflow_id": "build.yml", + "ref": "main", + }, + } + cfgPath := writeTestConfig(t, tmpDir, dbPath, templates) + + cfg, err := config.Load(cfgPath) + if err != nil { + t.Fatalf("Failed to load config: %v", err) + } + + st := store.NewSQLiteStore(log, dbPath) + if err := st.Start(ctx); err != nil { + t.Fatalf("Failed to start store: %v", err) + } + defer func() { _ = st.Stop() }() + + if err := st.Migrate(ctx); err != nil { + t.Fatalf("Failed to migrate: %v", err) + } + + if err := SyncGroupsFromConfig(ctx, log, st, cfg); err != nil { + t.Fatalf("Failed to sync groups: %v", err) + } + + // Point to non-existent config path so reload fails. + srv := NewServer(log, cfg, filepath.Join(tmpDir, "nonexistent.yaml"), + st, &stubQueue{}, &stubAuth{}, + &stubGitHubClient{}, &stubGitHubClient{}, testMetrics) + + s := srv.(*server) + + req := httptest.NewRequest(http.MethodPost, "/api/v1/templates/reload", nil) + req.Header.Set("Authorization", "Bearer test-token") + + adminUser := &store.User{ + ID: "test-user-id", + Username: "testadmin", + Role: store.RoleAdmin, + } + req = req.WithContext(auth.ContextWithUser(req.Context(), adminUser)) + + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusInternalServerError { + t.Errorf("Expected status 500, got %d: %s", w.Code, w.Body.String()) + } + + // Verify original templates are still intact. + dbTemplates, err := st.ListJobTemplatesByGroup(ctx, "test-group") + if err != nil { + t.Fatalf("Failed to list templates: %v", err) + } + + if len(dbTemplates) != 1 { + t.Errorf("Expected 1 template still in DB, got %d", len(dbTemplates)) + } +} + +func ptr[T any](v T) *T { + return &v +} diff --git a/pkg/api/docs/docs.go b/pkg/api/docs/docs.go index 601021d..92d6b57 100644 --- a/pkg/api/docs/docs.go +++ b/pkg/api/docs/docs.go @@ -1524,6 +1524,49 @@ const docTemplate = `{ } } }, + "/templates/reload": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Re-reads the config file to reload templates from files and URLs, then syncs to the database (requires admin)", + "produces": [ + "application/json" + ], + "tags": [ + "templates" + ], + "summary": "Reload templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/pkg_api.ReloadTemplatesResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/pkg_api.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/pkg_api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/pkg_api.ErrorResponse" + } + } + } + } + }, "/templates/{id}": { "get": { "security": [ @@ -2251,6 +2294,34 @@ const docTemplate = `{ } } }, + "pkg_api.ReloadTemplatesGroupStats": { + "type": "object", + "properties": { + "group_id": { + "type": "string", + "example": "my-group" + }, + "templates": { + "type": "integer", + "example": 5 + } + } + }, + "pkg_api.ReloadTemplatesResponse": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/pkg_api.ReloadTemplatesGroupStats" + } + }, + "message": { + "type": "string", + "example": "Templates reloaded successfully" + } + } + }, "pkg_api.ReorderQueueRequest": { "type": "object", "properties": { diff --git a/pkg/api/docs/swagger.json b/pkg/api/docs/swagger.json index c359bc6..29a8357 100644 --- a/pkg/api/docs/swagger.json +++ b/pkg/api/docs/swagger.json @@ -1518,6 +1518,49 @@ } } }, + "/templates/reload": { + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Re-reads the config file to reload templates from files and URLs, then syncs to the database (requires admin)", + "produces": [ + "application/json" + ], + "tags": [ + "templates" + ], + "summary": "Reload templates", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/pkg_api.ReloadTemplatesResponse" + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "$ref": "#/definitions/pkg_api.ErrorResponse" + } + }, + "403": { + "description": "Forbidden", + "schema": { + "$ref": "#/definitions/pkg_api.ErrorResponse" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/pkg_api.ErrorResponse" + } + } + } + } + }, "/templates/{id}": { "get": { "security": [ @@ -2245,6 +2288,34 @@ } } }, + "pkg_api.ReloadTemplatesGroupStats": { + "type": "object", + "properties": { + "group_id": { + "type": "string", + "example": "my-group" + }, + "templates": { + "type": "integer", + "example": 5 + } + } + }, + "pkg_api.ReloadTemplatesResponse": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/pkg_api.ReloadTemplatesGroupStats" + } + }, + "message": { + "type": "string", + "example": "Templates reloaded successfully" + } + } + }, "pkg_api.ReorderQueueRequest": { "type": "object", "properties": { diff --git a/pkg/api/docs/swagger.yaml b/pkg/api/docs/swagger.yaml index 8492ab7..ca5ac85 100644 --- a/pkg/api/docs/swagger.yaml +++ b/pkg/api/docs/swagger.yaml @@ -442,6 +442,25 @@ definitions: example: rate limit exceeded type: string type: object + pkg_api.ReloadTemplatesGroupStats: + properties: + group_id: + example: my-group + type: string + templates: + example: 5 + type: integer + type: object + pkg_api.ReloadTemplatesResponse: + properties: + groups: + items: + $ref: '#/definitions/pkg_api.ReloadTemplatesGroupStats' + type: array + message: + example: Templates reloaded successfully + type: string + type: object pkg_api.ReorderQueueRequest: properties: job_ids: @@ -1542,6 +1561,34 @@ paths: summary: Get job template tags: - templates + /templates/reload: + post: + description: Re-reads the config file to reload templates from files and URLs, + then syncs to the database (requires admin) + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/pkg_api.ReloadTemplatesResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/pkg_api.ErrorResponse' + "403": + description: Forbidden + schema: + $ref: '#/definitions/pkg_api.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/pkg_api.ErrorResponse' + security: + - BearerAuth: [] + summary: Reload templates + tags: + - templates /ws: get: description: Establishes a WebSocket connection for real-time job and runner diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 1fa3c21..7ae2589 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -11,6 +11,7 @@ import type { HistoryStatsResponse, HistoryStatsTimeRange, HealthResponse, + ReloadTemplatesResponse, } from '../types'; import { getConfig } from '../config'; @@ -287,6 +288,11 @@ class ApiClient { await this.request('/runners/refresh', { method: 'POST' }); } + // Templates + async reloadTemplates(): Promise { + return this.request('/templates/reload', { method: 'POST' }); + } + // System async getStatus(): Promise { return this.request('/status'); diff --git a/ui/src/pages/GroupPage.tsx b/ui/src/pages/GroupPage.tsx index 7a119c4..12ce4af 100644 --- a/ui/src/pages/GroupPage.tsx +++ b/ui/src/pages/GroupPage.tsx @@ -416,6 +416,14 @@ export function GroupPage() { }, }); + const reloadTemplatesMutation = useMutation({ + mutationFn: () => api.reloadTemplates(), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['templates', id] }); + queryClient.invalidateQueries({ queryKey: ['groups'] }); + }, + }); + const getTemplateForJob = useCallback((job: Job) => templates.find((t) => t.id === job.template_id), [templates]); // Extract unique labels from all templates @@ -1366,10 +1374,30 @@ export function GroupPage() { ) : (
- {/* Description */} -

- Predefined job templates provided via configuration. Select templates to add them to the queue. -

+ {/* Description and reload button */} +
+

+ Predefined job templates provided via configuration. Select templates to add them to the queue. +

+ {isAdmin && ( + + )} +
{/* Label filters */} {(availableLabels.length > 0 || unlabeledCount > 0) && ( diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 3fac851..216fb16 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -232,6 +232,17 @@ export interface WSSystemStatus { timestamp: string; } +// Template reload response +export interface ReloadTemplatesGroupStats { + group_id: string; + templates: number; +} + +export interface ReloadTemplatesResponse { + message: string; + groups: ReloadTemplatesGroupStats[]; +} + // Health endpoint types export interface HealthAuthConfig { basic: boolean;