feat: move jobs to action based

Signed-off-by: allanice001 <allanice001@gmail.com>
This commit is contained in:
allanice001
2025-12-26 00:30:46 +00:00
parent dd0cefc08a
commit dac28d3ea5
23 changed files with 2128 additions and 204 deletions

View File

@@ -116,46 +116,48 @@ var serveCmd = &cobra.Command{
log.Printf("failed to enqueue bootstrap_bastion: %v", err) log.Printf("failed to enqueue bootstrap_bastion: %v", err)
} }
_, err = jobs.Enqueue( /*
context.Background(), _, err = jobs.Enqueue(
uuid.NewString(), context.Background(),
"prepare_cluster", uuid.NewString(),
bg.ClusterPrepareArgs{IntervalS: 120}, "prepare_cluster",
archer.WithMaxRetries(3), bg.ClusterPrepareArgs{IntervalS: 120},
archer.WithScheduleTime(time.Now().Add(60*time.Second)), archer.WithMaxRetries(3),
) archer.WithScheduleTime(time.Now().Add(60*time.Second)),
if err != nil { )
log.Printf("failed to enqueue prepare_cluster: %v", err) if err != nil {
} log.Printf("failed to enqueue prepare_cluster: %v", err)
}
_, err = jobs.Enqueue( _, err = jobs.Enqueue(
context.Background(), context.Background(),
uuid.NewString(), uuid.NewString(),
"cluster_setup", "cluster_setup",
bg.ClusterSetupArgs{ bg.ClusterSetupArgs{
IntervalS: 120, IntervalS: 120,
}, },
archer.WithMaxRetries(3), archer.WithMaxRetries(3),
archer.WithScheduleTime(time.Now().Add(60*time.Second)), archer.WithScheduleTime(time.Now().Add(60*time.Second)),
) )
if err != nil { if err != nil {
log.Printf("failed to enqueue cluster setup: %v", err) log.Printf("failed to enqueue cluster setup: %v", err)
} }
_, err = jobs.Enqueue( _, err = jobs.Enqueue(
context.Background(), context.Background(),
uuid.NewString(), uuid.NewString(),
"cluster_bootstrap", "cluster_bootstrap",
bg.ClusterBootstrapArgs{ bg.ClusterBootstrapArgs{
IntervalS: 120, IntervalS: 120,
}, },
archer.WithMaxRetries(3), archer.WithMaxRetries(3),
archer.WithScheduleTime(time.Now().Add(60*time.Second)), archer.WithScheduleTime(time.Now().Add(60*time.Second)),
) )
if err != nil { if err != nil {
log.Printf("failed to enqueue cluster bootstrap: %v", err) log.Printf("failed to enqueue cluster bootstrap: %v", err)
} }
*/
_, err = jobs.Enqueue( _, err = jobs.Enqueue(
context.Background(), context.Background(),

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,5 +1,23 @@
components: components:
schemas: schemas:
dto.ActionResponse:
properties:
created_at:
format: date-time
type: string
description:
type: string
id:
format: uuid
type: string
label:
type: string
make_target:
type: string
updated_at:
format: date-time
type: string
type: object
dto.AnnotationResponse: dto.AnnotationResponse:
properties: properties:
created_at: created_at:
@@ -128,6 +146,42 @@ components:
updated_at: updated_at:
type: string type: string
type: object type: object
dto.ClusterRunResponse:
properties:
action:
type: string
cluster_id:
format: uuid
type: string
created_at:
format: date-time
type: string
error:
type: string
finished_at:
format: date-time
type: string
id:
format: uuid
type: string
organization_id:
format: uuid
type: string
status:
type: string
updated_at:
format: date-time
type: string
type: object
dto.CreateActionRequest:
properties:
description:
type: string
label:
type: string
make_target:
type: string
type: object
dto.CreateAnnotationRequest: dto.CreateAnnotationRequest:
properties: properties:
key: key:
@@ -716,6 +770,15 @@ components:
example: Bearer example: Bearer
type: string type: string
type: object type: object
dto.UpdateActionRequest:
properties:
description:
type: string
label:
type: string
make_target:
type: string
type: object
dto.UpdateAnnotationRequest: dto.UpdateAnnotationRequest:
properties: properties:
key: key:
@@ -1202,6 +1265,222 @@ paths:
summary: Get JWKS summary: Get JWKS
tags: tags:
- Auth - Auth
/admin/actions:
get:
description: Returns all admin-configured actions.
operationId: ListActions
responses:
"200":
content:
application/json:
schema:
items:
$ref: '#/components/schemas/dto.ActionResponse'
type: array
description: OK
"401":
content:
application/json:
schema:
type: string
description: Unauthorized
"500":
content:
application/json:
schema:
type: string
description: db error
security:
- BearerAuth: []
summary: List available actions
tags:
- Actions
post:
description: Creates a new admin-configured action.
operationId: CreateAction
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/dto.CreateActionRequest'
description: payload
required: true
responses:
"201":
content:
application/json:
schema:
$ref: '#/components/schemas/dto.ActionResponse'
description: Created
"400":
content:
application/json:
schema:
type: string
description: bad request
"401":
content:
application/json:
schema:
type: string
description: Unauthorized
"500":
content:
application/json:
schema:
type: string
description: db error
security:
- BearerAuth: []
summary: Create an action
tags:
- Actions
/admin/actions/{actionID}:
delete:
description: Deletes an action.
operationId: DeleteAction
parameters:
- description: Action ID
in: path
name: actionID
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: deleted
"400":
content:
application/json:
schema:
type: string
description: bad request
"401":
content:
application/json:
schema:
type: string
description: Unauthorized
"404":
content:
application/json:
schema:
type: string
description: not found
"500":
content:
application/json:
schema:
type: string
description: db error
security:
- BearerAuth: []
summary: Delete an action
tags:
- Actions
get:
description: Returns a single action.
operationId: GetAction
parameters:
- description: Action ID
in: path
name: actionID
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/dto.ActionResponse'
description: OK
"400":
content:
application/json:
schema:
type: string
description: bad request
"401":
content:
application/json:
schema:
type: string
description: Unauthorized
"404":
content:
application/json:
schema:
type: string
description: not found
"500":
content:
application/json:
schema:
type: string
description: db error
security:
- BearerAuth: []
summary: Get a single action by ID
tags:
- Actions
patch:
description: Updates an action. Only provided fields are modified.
operationId: UpdateAction
parameters:
- description: Action ID
in: path
name: actionID
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/dto.UpdateActionRequest'
description: payload
required: true
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/dto.ActionResponse'
description: OK
"400":
content:
application/json:
schema:
type: string
description: bad request
"401":
content:
application/json:
schema:
type: string
description: Unauthorized
"404":
content:
application/json:
schema:
type: string
description: not found
"500":
content:
application/json:
schema:
type: string
description: db error
security:
- BearerAuth: []
summary: Update an action
tags:
- Actions
/admin/archer/jobs: /admin/archer/jobs:
get: get:
description: Paginated background jobs with optional filters. Search `q` may description: Paginated background jobs with optional filters. Search `q` may
@@ -2124,6 +2403,73 @@ paths:
summary: Update basic cluster details (org scoped) summary: Update basic cluster details (org scoped)
tags: tags:
- Clusters - Clusters
/clusters/{clusterID}/actions/{actionID}/runs:
post:
description: Creates a ClusterRun record for the cluster/action. Execution is
handled asynchronously by workers.
operationId: RunClusterAction
parameters:
- description: Organization UUID
in: header
name: X-Org-ID
schema:
type: string
- description: Cluster ID
in: path
name: clusterID
required: true
schema:
type: string
- description: Action ID
in: path
name: actionID
required: true
schema:
type: string
responses:
"201":
content:
application/json:
schema:
$ref: '#/components/schemas/dto.ClusterRunResponse'
description: Created
"400":
content:
application/json:
schema:
type: string
description: bad request
"401":
content:
application/json:
schema:
type: string
description: Unauthorized
"403":
content:
application/json:
schema:
type: string
description: organization required
"404":
content:
application/json:
schema:
type: string
description: cluster or action not found
"500":
content:
application/json:
schema:
type: string
description: db error
security:
- BearerAuth: []
- OrgKeyAuth: []
- OrgSecretAuth: []
summary: Run an admin-configured action on a cluster (org scoped)
tags:
- ClusterRuns
/clusters/{clusterID}/apps-load-balancer: /clusters/{clusterID}/apps-load-balancer:
delete: delete:
description: Clears apps_load_balancer_id on the cluster. description: Clears apps_load_balancer_id on the cluster.
@@ -3017,6 +3363,128 @@ paths:
summary: Detach a node pool from a cluster summary: Detach a node pool from a cluster
tags: tags:
- Clusters - Clusters
/clusters/{clusterID}/runs:
get:
description: Returns runs for a cluster within the organization in X-Org-ID.
operationId: ListClusterRuns
parameters:
- description: Organization UUID
in: header
name: X-Org-ID
schema:
type: string
- description: Cluster ID
in: path
name: clusterID
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
items:
$ref: '#/components/schemas/dto.ClusterRunResponse'
type: array
description: OK
"401":
content:
application/json:
schema:
type: string
description: Unauthorized
"403":
content:
application/json:
schema:
type: string
description: organization required
"404":
content:
application/json:
schema:
type: string
description: cluster not found
"500":
content:
application/json:
schema:
type: string
description: db error
security:
- BearerAuth: []
- OrgKeyAuth: []
- OrgSecretAuth: []
summary: List cluster runs (org scoped)
tags:
- ClusterRuns
/clusters/{clusterID}/runs/{runID}:
get:
description: Returns a single run for a cluster within the organization in X-Org-ID.
operationId: GetClusterRun
parameters:
- description: Organization UUID
in: header
name: X-Org-ID
schema:
type: string
- description: Cluster ID
in: path
name: clusterID
required: true
schema:
type: string
- description: Run ID
in: path
name: runID
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/dto.ClusterRunResponse'
description: OK
"400":
content:
application/json:
schema:
type: string
description: bad request
"401":
content:
application/json:
schema:
type: string
description: Unauthorized
"403":
content:
application/json:
schema:
type: string
description: organization required
"404":
content:
application/json:
schema:
type: string
description: not found
"500":
content:
application/json:
schema:
type: string
description: db error
security:
- BearerAuth: []
- OrgKeyAuth: []
- OrgSecretAuth: []
summary: Get a cluster run (org scoped)
tags:
- ClusterRuns
/credentials: /credentials:
get: get:
description: Returns credential metadata for the current org. Secrets are never description: Returns credential metadata for the current org. Secrets are never

View File

@@ -22,5 +22,16 @@ func mountAdminRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs, authUser func(ht
archer.Post("/jobs/{id}/cancel", handlers.AdminCancelArcherJob(db)) archer.Post("/jobs/{id}/cancel", handlers.AdminCancelArcherJob(db))
archer.Get("/queues", handlers.AdminListArcherQueues(db)) archer.Get("/queues", handlers.AdminListArcherQueues(db))
}) })
admin.Route("/actions", func(action chi.Router) {
action.Use(authUser)
action.Use(httpmiddleware.RequirePlatformAdmin())
action.Get("/", handlers.ListActions(db))
action.Post("/", handlers.CreateAction(db))
action.Get("/{actionID}", handlers.GetAction(db))
action.Patch("/{actionID}", handlers.UpdateAction(db))
action.Delete("/{actionID}", handlers.DeleteAction(db))
})
}) })
} }

View File

@@ -33,7 +33,7 @@ func mountAPIRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs) {
mountNodePoolRoutes(v1, db, authOrg) mountNodePoolRoutes(v1, db, authOrg)
mountDNSRoutes(v1, db, authOrg) mountDNSRoutes(v1, db, authOrg)
mountLoadBalancerRoutes(v1, db, authOrg) mountLoadBalancerRoutes(v1, db, authOrg)
mountClusterRoutes(v1, db, authOrg) mountClusterRoutes(v1, db, jobs, authOrg)
}) })
}) })
} }

View File

@@ -3,12 +3,13 @@ package api
import ( import (
"net/http" "net/http"
"github.com/glueops/autoglue/internal/bg"
"github.com/glueops/autoglue/internal/handlers" "github.com/glueops/autoglue/internal/handlers"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"gorm.io/gorm" "gorm.io/gorm"
) )
func mountClusterRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) http.Handler) { func mountClusterRoutes(r chi.Router, db *gorm.DB, jobs *bg.Jobs, authOrg func(http.Handler) http.Handler) {
r.Route("/clusters", func(c chi.Router) { r.Route("/clusters", func(c chi.Router) {
c.Use(authOrg) c.Use(authOrg)
c.Get("/", handlers.ListClusters(db)) c.Get("/", handlers.ListClusters(db))
@@ -36,6 +37,10 @@ func mountClusterRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) ht
c.Delete("/{clusterID}/kubeconfig", handlers.ClearClusterKubeconfig(db)) c.Delete("/{clusterID}/kubeconfig", handlers.ClearClusterKubeconfig(db))
c.Post("/{clusterID}/node-pools", handlers.AttachNodePool(db)) c.Post("/{clusterID}/node-pools", handlers.AttachNodePool(db))
c.Delete("/{clusterID}/node-pools/{nodePoolID}", handlers.DeleteNodePool(db)) c.Delete("/{clusterID}/node-pools/{nodePoolID}", handlers.DetachNodePool(db))
c.Get("/{clusterID}/runs", handlers.ListClusterRuns(db))
c.Get("/{clusterID}/runs/{runID}", handlers.GetClusterRun(db))
c.Post("/{clusterID}/actions/{actionID}/runs", handlers.RunClusterAction(db, jobs))
}) })
} }

View File

@@ -44,6 +44,9 @@ func NewRuntime() *Runtime {
&models.RecordSet{}, &models.RecordSet{},
&models.LoadBalancer{}, &models.LoadBalancer{},
&models.Cluster{}, &models.Cluster{},
&models.Action{},
&models.Cluster{},
&models.ClusterRun{},
) )
if err != nil { if err != nil {

View File

@@ -107,27 +107,29 @@ func NewJobs(gdb *gorm.DB, dbUrl string) (*Jobs, error) {
archer.WithInstances(1), archer.WithInstances(1),
archer.WithTimeout(2*time.Minute), archer.WithTimeout(2*time.Minute),
) )
/*
c.Register(
"prepare_cluster",
ClusterPrepareWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(2*time.Minute),
)
c.Register( c.Register(
"prepare_cluster", "cluster_setup",
ClusterPrepareWorker(gdb, jobs), ClusterSetupWorker(gdb, jobs),
archer.WithInstances(1), archer.WithInstances(1),
archer.WithTimeout(2*time.Minute), archer.WithTimeout(2*time.Minute),
) )
c.Register( c.Register(
"cluster_setup", "cluster_bootstrap",
ClusterSetupWorker(gdb, jobs), ClusterBootstrapWorker(gdb, jobs),
archer.WithInstances(1), archer.WithInstances(1),
archer.WithTimeout(2*time.Minute), archer.WithTimeout(60*time.Minute),
) )
c.Register( */
"cluster_bootstrap",
ClusterBootstrapWorker(gdb, jobs),
archer.WithInstances(1),
archer.WithTimeout(60*time.Minute),
)
c.Register( c.Register(
"org_key_sweeper", "org_key_sweeper",
@@ -135,6 +137,8 @@ func NewJobs(gdb *gorm.DB, dbUrl string) (*Jobs, error) {
archer.WithInstances(1), archer.WithInstances(1),
archer.WithTimeout(5*time.Minute), archer.WithTimeout(5*time.Minute),
) )
c.Register("cluster_action", ClusterActionWorker(gdb))
return jobs, nil return jobs, nil
} }

View File

@@ -0,0 +1,166 @@
package bg
import (
"context"
"encoding/json"
"fmt"
"time"
"github.com/dyaksa/archer"
"github.com/dyaksa/archer/job"
"github.com/glueops/autoglue/internal/mapper"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
"gorm.io/gorm"
)
type ClusterActionArgs struct {
OrgID uuid.UUID `json:"org_id"`
ClusterID uuid.UUID `json:"cluster_id"`
Action string `json:"action"`
MakeTarget string `json:"make_target"`
}
type ClusterActionResult struct {
Status string `json:"status"`
Action string `json:"action"`
ClusterID string `json:"cluster_id"`
ElapsedMs int `json:"elapsed_ms"`
}
func ClusterActionWorker(db *gorm.DB) archer.WorkerFn {
return func(ctx context.Context, j job.Job) (any, error) {
start := time.Now()
var args ClusterActionArgs
_ = j.ParseArguments(&args)
logger := log.With().
Str("job", j.ID).
Str("cluster_id", args.ClusterID.String()).
Str("action", args.Action).
Logger()
var c models.Cluster
if err := db.
Preload("BastionServer.SshKey").
Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers.SshKey").
Where("id = ? AND organization_id = ?", args.ClusterID, args.OrgID).
First(&c).Error; err != nil {
return nil, fmt.Errorf("load cluster: %w", err)
}
// ---- Step 1: Prepare (mostly lifted from ClusterPrepareWorker)
if err := setClusterStatus(db, c.ID, clusterStatusBootstrapping, ""); err != nil {
return nil, fmt.Errorf("mark bootstrapping: %w", err)
}
c.Status = clusterStatusBootstrapping
if err := validateClusterForPrepare(&c); err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
return nil, fmt.Errorf("validate: %w", err)
}
allServers := flattenClusterServers(&c)
keyPayloads, sshConfig, err := buildSSHAssetsForCluster(db, &c, allServers)
if err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
return nil, fmt.Errorf("build ssh assets: %w", err)
}
dtoCluster := mapper.ClusterToDTO(c)
if c.EncryptedKubeconfig != "" && c.KubeIV != "" && c.KubeTag != "" {
kubeconfig, err := utils.DecryptForOrg(
c.OrganizationID,
c.EncryptedKubeconfig,
c.KubeIV,
c.KubeTag,
db,
)
if err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
return nil, fmt.Errorf("decrypt kubeconfig: %w", err)
}
dtoCluster.Kubeconfig = &kubeconfig
}
orgKey, orgSecret, err := findOrCreateClusterAutomationKey(db, c.OrganizationID, c.ID, 24*time.Hour)
if err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
return nil, fmt.Errorf("org key: %w", err)
}
dtoCluster.OrgKey = &orgKey
dtoCluster.OrgSecret = &orgSecret
payloadJSON, err := json.MarshalIndent(dtoCluster, "", " ")
if err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
return nil, fmt.Errorf("marshal payload: %w", err)
}
{
runCtx, cancel := context.WithTimeout(ctx, 8*time.Minute)
err := pushAssetsToBastion(runCtx, db, &c, sshConfig, keyPayloads, payloadJSON)
cancel()
if err != nil {
_ = setClusterStatus(db, c.ID, clusterStatusFailed, err.Error())
return nil, fmt.Errorf("push assets: %w", err)
}
}
if err := setClusterStatus(db, c.ID, clusterStatusPending, ""); err != nil {
return nil, fmt.Errorf("mark pending: %w", err)
}
c.Status = clusterStatusPending
// ---- Step 2: Setup (ping-servers)
{
runCtx, cancel := context.WithTimeout(ctx, 30*time.Minute)
out, err := runMakeOnBastion(runCtx, db, &c, "ping-servers")
cancel()
if err != nil {
logger.Error().Err(err).Str("output", out).Msg("ping-servers failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make ping-servers: %v", err))
return nil, fmt.Errorf("ping-servers: %w", err)
}
}
if err := setClusterStatus(db, c.ID, clusterStatusProvisioning, ""); err != nil {
return nil, fmt.Errorf("mark provisioning: %w", err)
}
c.Status = clusterStatusProvisioning
// ---- Step 3: Bootstrap (parameterized target)
{
runCtx, cancel := context.WithTimeout(ctx, 60*time.Minute)
out, err := runMakeOnBastion(runCtx, db, &c, args.MakeTarget)
cancel()
if err != nil {
logger.Error().Err(err).Str("output", out).Msg("bootstrap target failed")
_ = setClusterStatus(db, c.ID, clusterStatusFailed, fmt.Sprintf("make %s: %v", args.MakeTarget, err))
return nil, fmt.Errorf("make %s: %w", args.MakeTarget, err)
}
}
if err := setClusterStatus(db, c.ID, clusterStatusReady, ""); err != nil {
return nil, fmt.Errorf("mark ready: %w", err)
}
return ClusterActionResult{
Status: "ok",
Action: args.Action,
ClusterID: c.ID.String(),
ElapsedMs: int(time.Since(start).Milliseconds()),
}, nil
}
}

View File

@@ -0,0 +1,256 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListActions godoc
//
// @ID ListActions
// @Summary List available actions
// @Description Returns all admin-configured actions.
// @Tags Actions
// @Produce json
// @Success 200 {array} dto.ActionResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 500 {string} string "db error"
// @Router /admin/actions [get]
// @Security BearerAuth
func ListActions(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var rows []models.Action
if err := db.Order("label ASC").Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.ActionResponse, 0, len(rows))
for _, a := range rows {
out = append(out, actionToDTO(a))
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetAction godoc
//
// @ID GetAction
// @Summary Get a single action by ID
// @Description Returns a single action.
// @Tags Actions
// @Produce json
// @Param actionID path string true "Action ID"
// @Success 200 {object} dto.ActionResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "db error"
// @Router /admin/actions/{actionID} [get]
// @Security BearerAuth
func GetAction(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
return
}
var row models.Action
if err := db.Where("id = ?", actionID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, actionToDTO(row))
}
}
// CreateAction godoc
//
// @ID CreateAction
// @Summary Create an action
// @Description Creates a new admin-configured action.
// @Tags Actions
// @Accept json
// @Produce json
// @Param body body dto.CreateActionRequest true "payload"
// @Success 201 {object} dto.ActionResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 500 {string} string "db error"
// @Router /admin/actions [post]
// @Security BearerAuth
func CreateAction(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var in dto.CreateActionRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
label := strings.TrimSpace(in.Label)
desc := strings.TrimSpace(in.Description)
target := strings.TrimSpace(in.MakeTarget)
if label == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "label is required")
return
}
if desc == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "description is required")
return
}
if target == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "make_target is required")
return
}
row := models.Action{
Label: label,
Description: desc,
MakeTarget: target,
}
if err := db.Create(&row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusCreated, actionToDTO(row))
}
}
// UpdateAction godoc
//
// @ID UpdateAction
// @Summary Update an action
// @Description Updates an action. Only provided fields are modified.
// @Tags Actions
// @Accept json
// @Produce json
// @Param actionID path string true "Action ID"
// @Param body body dto.UpdateActionRequest true "payload"
// @Success 200 {object} dto.ActionResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "db error"
// @Router /admin/actions/{actionID} [patch]
// @Security BearerAuth
func UpdateAction(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
return
}
var in dto.UpdateActionRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
var row models.Action
if err := db.Where("id = ?", actionID).First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if in.Label != nil {
v := strings.TrimSpace(*in.Label)
if v == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "label cannot be empty")
return
}
row.Label = v
}
if in.Description != nil {
v := strings.TrimSpace(*in.Description)
if v == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "description cannot be empty")
return
}
row.Description = v
}
if in.MakeTarget != nil {
v := strings.TrimSpace(*in.MakeTarget)
if v == "" {
utils.WriteError(w, http.StatusBadRequest, "validation_error", "make_target cannot be empty")
return
}
row.MakeTarget = v
}
if err := db.Save(&row).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, actionToDTO(row))
}
}
// DeleteAction godoc
//
// @ID DeleteAction
// @Summary Delete an action
// @Description Deletes an action.
// @Tags Actions
// @Produce json
// @Param actionID path string true "Action ID"
// @Success 204 {string} string "deleted"
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "db error"
// @Router /admin/actions/{actionID} [delete]
// @Security BearerAuth
func DeleteAction(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
return
}
tx := db.Where("id = ?", actionID).Delete(&models.Action{})
if tx.Error != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if tx.RowsAffected == 0 {
utils.WriteError(w, http.StatusNotFound, "not_found", "action not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
}
func actionToDTO(a models.Action) dto.ActionResponse {
return dto.ActionResponse{
ID: a.ID,
Label: a.Label,
Description: a.Description,
MakeTarget: a.MakeTarget,
CreatedAt: a.CreatedAt,
UpdatedAt: a.UpdatedAt,
}
}

View File

@@ -0,0 +1,257 @@
package handlers
import (
"errors"
"net/http"
"time"
"github.com/dyaksa/archer"
"github.com/glueops/autoglue/internal/api/httpmiddleware"
"github.com/glueops/autoglue/internal/bg"
"github.com/glueops/autoglue/internal/handlers/dto"
"github.com/glueops/autoglue/internal/models"
"github.com/glueops/autoglue/internal/utils"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListClusterRuns godoc
//
// @ID ListClusterRuns
// @Summary List cluster runs (org scoped)
// @Description Returns runs for a cluster within the organization in X-Org-ID.
// @Tags ClusterRuns
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Success 200 {array} dto.ClusterRunResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/runs [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func ListClusterRuns(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
// Ensure cluster exists + org scoped
if err := db.Select("id").
Where("id = ? AND organization_id = ?", clusterID, orgID).
First(&models.Cluster{}).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
var rows []models.ClusterRun
if err := db.
Where("organization_id = ? AND cluster_id = ?", orgID, clusterID).
Order("created_at DESC").
Find(&rows).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
out := make([]dto.ClusterRunResponse, 0, len(rows))
for _, cr := range rows {
out = append(out, clusterRunToDTO(cr))
}
utils.WriteJSON(w, http.StatusOK, out)
}
}
// GetClusterRun godoc
//
// @ID GetClusterRun
// @Summary Get a cluster run (org scoped)
// @Description Returns a single run for a cluster within the organization in X-Org-ID.
// @Tags ClusterRuns
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param runID path string true "Run ID"
// @Success 200 {object} dto.ClusterRunResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/runs/{runID} [get]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func GetClusterRun(db *gorm.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
runID, err := uuid.Parse(chi.URLParam(r, "runID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_run_id", "invalid run id")
return
}
var row models.ClusterRun
if err := db.
Where("id = ? AND organization_id = ? AND cluster_id = ?", runID, orgID, clusterID).
First(&row).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "run not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterRunToDTO(row))
}
}
// RunClusterAction godoc
//
// @ID RunClusterAction
// @Summary Run an admin-configured action on a cluster (org scoped)
// @Description Creates a ClusterRun record for the cluster/action. Execution is handled asynchronously by workers.
// @Tags ClusterRuns
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param actionID path string true "Action ID"
// @Success 201 {object} dto.ClusterRunResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or action not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/actions/{actionID}/runs [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func RunClusterAction(db *gorm.DB, jobs *bg.Jobs) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
orgID, ok := httpmiddleware.OrgIDFrom(r.Context())
if !ok {
utils.WriteError(w, http.StatusForbidden, "org_required", "specify X-Org-ID")
return
}
clusterID, err := uuid.Parse(chi.URLParam(r, "clusterID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_cluster_id", "invalid cluster id")
return
}
actionID, err := uuid.Parse(chi.URLParam(r, "actionID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_action_id", "invalid action id")
return
}
// cluster must exist + org scoped
var cluster models.Cluster
if err := db.Select("id", "organization_id").
Where("id = ? AND organization_id = ?", clusterID, orgID).
First(&cluster).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "not_found", "cluster not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// action is global/admin-configured (not org scoped)
var action models.Action
if err := db.Where("id = ?", actionID).First(&action).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "action_not_found", "action not found")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
run := models.ClusterRun{
OrganizationID: orgID,
ClusterID: clusterID,
Action: action.MakeTarget, // this is what you actually execute
Status: models.ClusterRunStatusQueued,
Error: "",
FinishedAt: time.Time{},
}
if err := db.Create(&run).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// Enqueue with run.ID as the job ID so the worker can look it up.
_, enqueueErr := jobs.Enqueue(
r.Context(),
run.ID.String(),
"cluster_action",
bg.ClusterActionWorker,
archer.WithMaxRetries(3),
)
if enqueueErr != nil {
_ = db.Model(&models.ClusterRun{}).
Where("id = ?", run.ID).
Updates(map[string]any{
"status": models.ClusterRunStatusFailed,
"error": "failed to enqueue job",
"finished_at": time.Now().UTC(),
}).Error
utils.WriteError(w, http.StatusInternalServerError, "job_error", "failed to enqueue cluster action")
return
}
utils.WriteJSON(w, http.StatusCreated, clusterRunToDTO(run))
}
}
func clusterRunToDTO(cr models.ClusterRun) dto.ClusterRunResponse {
var finished *time.Time
if !cr.FinishedAt.IsZero() {
t := cr.FinishedAt
finished = &t
}
return dto.ClusterRunResponse{
ID: cr.ID,
OrganizationID: cr.OrganizationID,
ClusterID: cr.ClusterID,
Action: cr.Action,
Status: cr.Status,
Error: cr.Error,
CreatedAt: cr.CreatedAt,
UpdatedAt: cr.UpdatedAt,
FinishedAt: finished,
}
}

View File

@@ -0,0 +1,28 @@
package dto
import (
"time"
"github.com/google/uuid"
)
type ActionResponse struct {
ID uuid.UUID `json:"id" format:"uuid"`
Label string `json:"label"`
Description string `json:"description"`
MakeTarget string `json:"make_target"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
}
type CreateActionRequest struct {
Label string `json:"label"`
Description string `json:"description"`
MakeTarget string `json:"make_target"`
}
type UpdateActionRequest struct {
Label *string `json:"label,omitempty"`
Description *string `json:"description,omitempty"`
MakeTarget *string `json:"make_target,omitempty"`
}

View File

@@ -0,0 +1,19 @@
package dto
import (
"time"
"github.com/google/uuid"
)
type ClusterRunResponse struct {
ID uuid.UUID `json:"id" format:"uuid"`
OrganizationID uuid.UUID `json:"organization_id" format:"uuid"`
ClusterID uuid.UUID `json:"cluster_id" format:"uuid"`
Action string `json:"action"`
Status string `json:"status"`
Error string `json:"error"`
CreatedAt time.Time `json:"created_at" format:"date-time"`
UpdatedAt time.Time `json:"updated_at" format:"date-time"`
FinishedAt *time.Time `json:"finished_at,omitempty" format:"date-time"`
}

16
internal/models/action.go Normal file
View File

@@ -0,0 +1,16 @@
package models
import (
"time"
"github.com/google/uuid"
)
type Action struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
Label string `gorm:"type:varchar(255);not null;uniqueIndex" json:"label"`
Description string `gorm:"type:text;not null" json:"description"`
MakeTarget string `gorm:"type:varchar(255);not null;uniqueIndex" json:"make_target"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
}

View File

@@ -0,0 +1,27 @@
package models
import (
"time"
"github.com/google/uuid"
)
const (
ClusterRunStatusQueued = "queued"
ClusterRunStatusRunning = "running"
ClusterRunStatusSuccess = "success"
ClusterRunStatusFailed = "failed"
ClusterRunStatusCanceled = "canceled"
)
type ClusterRun struct {
ID uuid.UUID `gorm:"type:uuid;primaryKey;default:gen_random_uuid()" json:"id" format:"uuid"`
OrganizationID uuid.UUID `json:"organization_id" gorm:"type:uuid;index"`
ClusterID uuid.UUID `json:"cluster_id" gorm:"type:uuid;index"`
Action string `json:"action" gorm:"type:text;not null"`
Status string `json:"status" gorm:"type:text;not null"`
Error string `json:"error" gorm:"type:text;not null"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()" format:"date-time"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()" format:"date-time"`
FinishedAt time.Time `json:"finished_at,omitempty" gorm:"type:timestamptz" format:"date-time"`
}

View File

@@ -2,6 +2,7 @@ import { AppShell } from "@/layouts/app-shell.tsx"
import { Route, Routes } from "react-router-dom" import { Route, Routes } from "react-router-dom"
import { ProtectedRoute } from "@/components/protected-route.tsx" import { ProtectedRoute } from "@/components/protected-route.tsx"
import { ActionsPage } from "@/pages/actions-page.tsx"
import { AnnotationPage } from "@/pages/annotation-page.tsx" import { AnnotationPage } from "@/pages/annotation-page.tsx"
import { ClustersPage } from "@/pages/cluster-page" import { ClustersPage } from "@/pages/cluster-page"
import { CredentialPage } from "@/pages/credential-page.tsx" import { CredentialPage } from "@/pages/credential-page.tsx"
@@ -46,6 +47,7 @@ export default function App() {
<Route path="/clusters" element={<ClustersPage />} /> <Route path="/clusters" element={<ClustersPage />} />
<Route path="/admin/jobs" element={<JobsPage />} /> <Route path="/admin/jobs" element={<JobsPage />} />
<Route path="/admin/actions" element={<ActionsPage />} />
</Route> </Route>
</Route> </Route>
<Route path="*" element={<Login />} /> <Route path="*" element={<Login />} />

30
ui/src/api/actions.ts Normal file
View File

@@ -0,0 +1,30 @@
import { withRefresh } from "@/api/with-refresh.ts"
import type { DtoCreateActionRequest, DtoUpdateActionRequest } from "@/sdk"
import { makeActionsApi } from "@/sdkClient.ts"
const actions = makeActionsApi()
export const actionsApi = {
listActions: () =>
withRefresh(async () => {
return await actions.listActions()
}),
createAction: (body: DtoCreateActionRequest) =>
withRefresh(async () => {
return await actions.createAction({
dtoCreateActionRequest: body,
})
}),
updateAction: (id: string, body: DtoUpdateActionRequest) =>
withRefresh(async () => {
return await actions.updateAction({
actionID: id,
dtoUpdateActionRequest: body,
})
}),
deleteAction: (id: string) =>
withRefresh(async () => {
await actions.deleteAction({
actionID: id,
})
}),
}

View File

@@ -8,9 +8,10 @@ import type {
DtoSetKubeconfigRequest, DtoSetKubeconfigRequest,
DtoUpdateClusterRequest, DtoUpdateClusterRequest,
} from "@/sdk" } from "@/sdk"
import { makeClusterApi } from "@/sdkClient" import { makeClusterApi, makeClusterRunsApi } from "@/sdkClient"
const clusters = makeClusterApi() const clusters = makeClusterApi()
const clusterRuns = makeClusterRunsApi()
export const clustersApi = { export const clustersApi = {
// --- basic CRUD --- // --- basic CRUD ---
@@ -147,4 +148,20 @@ export const clustersApi = {
withRefresh(async () => { withRefresh(async () => {
return await clusters.detachNodePool({ clusterID, nodePoolID }) return await clusters.detachNodePool({ clusterID, nodePoolID })
}), }),
// --- cluster runs / actions ---
listClusterRuns: (clusterID: string) =>
withRefresh(async () => {
return await clusterRuns.listClusterRuns({ clusterID })
}),
getClusterRun: (clusterID: string, runID: string) =>
withRefresh(async () => {
return await clusterRuns.getClusterRun({ clusterID, runID })
}),
runClusterAction: (clusterID: string, actionID: string) =>
withRefresh(async () => {
return await clusterRuns.runClusterAction({ clusterID, actionID })
}),
} }

View File

@@ -6,6 +6,7 @@ import {
FileKey2Icon, FileKey2Icon,
KeyRound, KeyRound,
LockKeyholeIcon, LockKeyholeIcon,
PickaxeIcon,
ServerIcon, ServerIcon,
SprayCanIcon, SprayCanIcon,
TagsIcon, TagsIcon,
@@ -49,5 +50,6 @@ export const userNav: NavItem[] = [{ to: "/me", label: "Profile", icon: User2 }]
export const adminNav: NavItem[] = [ export const adminNav: NavItem[] = [
{ to: "/admin/users", label: "Users Admin", icon: Users }, { to: "/admin/users", label: "Users Admin", icon: Users },
{ to: "/admin/jobs", label: "Jobs Admin", icon: GrUserWorker }, { to: "/admin/jobs", label: "Jobs Admin", icon: GrUserWorker },
{ to: "/admin/actions", label: "Actions Admin", icon: PickaxeIcon},
{ to: "/docs", label: "API Docs ", icon: SiSwagger, target: "_blank" }, { to: "/docs", label: "API Docs ", icon: SiSwagger, target: "_blank" },
] ]

View File

@@ -0,0 +1,433 @@
import { useMemo, useState } from "react"
import { actionsApi } from "@/api/actions.ts"
import type { DtoActionResponse } from "@/sdk"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import { AlertCircle, CircleSlash2, Loader2, Pencil, Plus, Search, Trash2 } from "lucide-react"
import { useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import { Badge } from "@/components/ui/badge.tsx"
import { Button } from "@/components/ui/button.tsx"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog.tsx"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx"
import { Textarea } from "@/components/ui/textarea.tsx"
const createActionSchema = z.object({
label: z.string().trim().min(1, "Label is required").max(255, "Max 255 chars"),
description: z.string().trim().min(1, "Description is required"),
make_target: z
.string()
.trim()
.min(1, "Make target is required")
.max(255, "Max 255 chars")
// keep client-side fairly strict to avoid surprises; server should also validate
.regex(/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/, "Invalid make target (allowed: a-z A-Z 0-9 . _ -)"),
})
type CreateActionInput = z.input<typeof createActionSchema>
const updateActionSchema = createActionSchema.partial()
type UpdateActionInput = z.input<typeof updateActionSchema>
function TargetBadge({ target }: { target?: string | null }) {
if (!target) {
return (
<Badge variant="outline" className="text-xs">
</Badge>
)
}
return (
<Badge variant="secondary" className="font-mono text-xs">
{target}
</Badge>
)
}
export const ActionsPage = () => {
const qc = useQueryClient()
const [filter, setFilter] = useState("")
const [createOpen, setCreateOpen] = useState(false)
const [updateOpen, setUpdateOpen] = useState(false)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [editing, setEditing] = useState<DtoActionResponse | null>(null)
const actionsQ = useQuery({
queryKey: ["admin-actions"],
queryFn: () => actionsApi.listActions(),
})
const filtered = useMemo(() => {
const data: DtoActionResponse[] = actionsQ.data ?? []
const q = filter.trim().toLowerCase()
if (!q) return data
return data.filter((a) => {
return (
(a.label ?? "").toLowerCase().includes(q) ||
(a.description ?? "").toLowerCase().includes(q) ||
(a.make_target ?? "").toLowerCase().includes(q)
)
})
}, [filter, actionsQ.data])
const createForm = useForm<CreateActionInput>({
resolver: zodResolver(createActionSchema),
defaultValues: {
label: "",
description: "",
make_target: "",
},
})
const createMut = useMutation({
mutationFn: (values: CreateActionInput) => actionsApi.createAction(values),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["admin-actions"] })
createForm.reset()
setCreateOpen(false)
toast.success("Action created.")
},
onError: (err: any) => {
toast.error(err?.message ?? "Failed to create action.")
},
})
const updateForm = useForm<UpdateActionInput>({
resolver: zodResolver(updateActionSchema),
defaultValues: {},
})
const updateMut = useMutation({
mutationFn: ({ id, values }: { id: string; values: UpdateActionInput }) =>
actionsApi.updateAction(id, values),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["admin-actions"] })
updateForm.reset()
setUpdateOpen(false)
setEditing(null)
toast.success("Action updated.")
},
onError: (err: any) => {
toast.error(err?.message ?? "Failed to update action.")
},
})
const openEdit = (a: DtoActionResponse) => {
if (!a.id) return
setEditing(a)
updateForm.reset({
label: a.label ?? "",
description: a.description ?? "",
make_target: a.make_target ?? "",
})
setUpdateOpen(true)
}
const deleteMut = useMutation({
mutationFn: (id: string) => actionsApi.deleteAction(id),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["admin-actions"] })
setDeleteId(null)
toast.success("Action deleted.")
},
onError: (err: any) => {
toast.error(err?.message ?? "Failed to delete action.")
},
})
if (actionsQ.isLoading) return <div className="p-6">Loading actions</div>
if (actionsQ.error) return <div className="p-6 text-red-500">Error loading actions.</div>
return (
<div className="space-y-4 p-6">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h1 className="text-2xl font-bold">Admin Actions</h1>
<div className="flex flex-wrap items-center gap-2">
<div className="relative">
<Search className="absolute top-2.5 left-2 h-4 w-4 opacity-60" />
<Input
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="Search actions"
className="w-72 pl-8"
/>
</div>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Action
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create Action</DialogTitle>
</DialogHeader>
<Form {...createForm}>
<form
className="space-y-4"
onSubmit={createForm.handleSubmit((v) => createMut.mutate(v))}
>
<FormField
control={createForm.control}
name="label"
render={({ field }) => (
<FormItem>
<FormLabel>Label</FormLabel>
<FormControl>
<Input placeholder="Setup" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="make_target"
render={({ field }) => (
<FormItem>
<FormLabel>Make Target</FormLabel>
<FormControl>
<Input placeholder="setup" className="font-mono" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea
rows={4}
placeholder="Runs prepare, ping-servers, then make setup on the bastion."
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={createMut.isPending}>
{createMut.isPending ? "Creating…" : "Create"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
<div className="bg-background overflow-hidden rounded-2xl border shadow-sm">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Label</TableHead>
<TableHead>Make Target</TableHead>
<TableHead>Description</TableHead>
<TableHead className="w-[260px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((a) => (
<TableRow key={a.id}>
<TableCell className="font-medium">{a.label}</TableCell>
<TableCell>
<TargetBadge target={a.make_target} />
</TableCell>
<TableCell className="text-muted-foreground max-w-[680px] truncate">
{a.description}
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(a)}>
<Pencil className="mr-2 h-4 w-4" />
Edit
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => a.id && setDeleteId(a.id)}
disabled={deleteMut.isPending && deleteId === a.id}
>
<Trash2 className="mr-2 h-4 w-4" />
{deleteMut.isPending && deleteId === a.id ? "Deleting…" : "Delete"}
</Button>
</div>
</TableCell>
</TableRow>
))}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={4} className="text-muted-foreground py-10 text-center">
<CircleSlash2 className="mx-auto mb-2 h-6 w-6 opacity-60" />
No actions match your search.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
{/* Update dialog */}
<Dialog
open={updateOpen}
onOpenChange={(open) => {
setUpdateOpen(open)
if (!open) setEditing(null)
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit Action</DialogTitle>
</DialogHeader>
{editing ? (
<Form {...updateForm}>
<form
className="space-y-4"
onSubmit={updateForm.handleSubmit((values) => {
if (!editing.id) return
updateMut.mutate({ id: editing.id, values })
})}
>
<FormField
control={updateForm.control}
name="label"
render={({ field }) => (
<FormItem>
<FormLabel>Label</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updateForm.control}
name="make_target"
render={({ field }) => (
<FormItem>
<FormLabel>Make Target</FormLabel>
<FormControl>
<Input className="font-mono" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={updateForm.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel>Description</FormLabel>
<FormControl>
<Textarea rows={4} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setUpdateOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={updateMut.isPending}>
{updateMut.isPending ? (
<span className="inline-flex items-center gap-2">
<Loader2 className="h-4 w-4 animate-spin" />
Saving
</span>
) : (
"Save changes"
)}
</Button>
</DialogFooter>
</form>
</Form>
) : (
<div className="text-muted-foreground text-sm">No action selected.</div>
)}
</DialogContent>
</Dialog>
{/* Delete confirm dialog */}
<Dialog open={!!deleteId} onOpenChange={(open) => !open && setDeleteId(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete action</DialogTitle>
</DialogHeader>
<div className="flex items-start gap-3">
<AlertCircle className="mt-0.5 h-5 w-5 text-red-500" />
<p className="text-muted-foreground text-sm">
This action cannot be undone. Are you sure you want to delete it?
</p>
</div>
<DialogFooter className="gap-2">
<Button variant="outline" onClick={() => setDeleteId(null)}>
Cancel
</Button>
<Button
variant="destructive"
onClick={() => deleteId && deleteMut.mutate(deleteId)}
disabled={deleteMut.isPending}
>
{deleteMut.isPending ? "Deleting…" : "Delete"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,14 +1,11 @@
;
// src/pages/ClustersPage.tsx
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { actionsApi } from "@/api/actions";
import { clustersApi } from "@/api/clusters"; import { clustersApi } from "@/api/clusters";
import { dnsApi } from "@/api/dns"; import { dnsApi } from "@/api/dns";
import { loadBalancersApi } from "@/api/loadbalancers"; import { loadBalancersApi } from "@/api/loadbalancers";
import { nodePoolsApi } from "@/api/node_pools"; import { nodePoolsApi } from "@/api/node_pools";
import { serversApi } from "@/api/servers"; import { serversApi } from "@/api/servers";
import type { DtoClusterResponse, DtoDomainResponse, DtoLoadBalancerResponse, DtoNodePoolResponse, DtoRecordSetResponse, DtoServerResponse } from "@/sdk"; import type { DtoActionResponse, DtoClusterResponse, DtoClusterRunResponse, DtoDomainResponse, DtoLoadBalancerResponse, DtoNodePoolResponse, DtoRecordSetResponse, DtoServerResponse } from "@/sdk";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertCircle, CheckCircle2, CircleSlash2, FileCode2, Globe2, Loader2, MapPin, Pencil, Plus, Search, Server, Wrench } from "lucide-react"; import { AlertCircle, CheckCircle2, CircleSlash2, FileCode2, Globe2, Loader2, MapPin, Pencil, Plus, Search, Server, Wrench } from "lucide-react";
@@ -19,45 +16,15 @@ import { z } from "zod";
import { truncateMiddle } from "@/lib/utils"; import { truncateMiddle } from "@/lib/utils";
import { Badge } from "@/components/ui/badge.tsx"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button.tsx"; import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog.tsx"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form.tsx"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input.tsx"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label.tsx"; import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select.tsx"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table.tsx"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea.tsx"; import { Textarea } from "@/components/ui/textarea";
;
@@ -77,6 +44,22 @@ type CreateClusterInput = z.input<typeof createClusterSchema>
const updateClusterSchema = createClusterSchema.partial() const updateClusterSchema = createClusterSchema.partial()
type UpdateClusterValues = z.infer<typeof updateClusterSchema> type UpdateClusterValues = z.infer<typeof updateClusterSchema>
// --- Data normalization helpers (fixes rows.some is not a function) ---
function asArray<T>(res: any): T[] {
if (Array.isArray(res)) return res as T[]
if (Array.isArray(res?.data)) return res.data as T[]
if (Array.isArray(res?.body)) return res.body as T[]
if (Array.isArray(res?.result)) return res.result as T[]
return []
}
function asObject<T>(res: any): T {
// for get endpoints that might return {data: {...}}
if (res?.data && typeof res.data === "object") return res.data as T
return res as T
}
// --- UI helpers --- // --- UI helpers ---
function StatusBadge({ status }: { status?: string | null }) { function StatusBadge({ status }: { status?: string | null }) {
@@ -133,6 +116,61 @@ function StatusBadge({ status }: { status?: string | null }) {
) )
} }
function RunStatusBadge({ status }: { status?: string | null }) {
const s = (status ?? "").toLowerCase()
if (!s)
return (
<Badge variant="outline" className="text-xs">
unknown
</Badge>
)
if (s === "succeeded" || s === "success") {
return (
<Badge variant="default" className="flex items-center gap-1 text-xs">
<CheckCircle2 className="h-3 w-3" />
succeeded
</Badge>
)
}
if (s === "failed" || s === "error") {
return (
<Badge variant="destructive" className="flex items-center gap-1 text-xs">
<AlertCircle className="h-3 w-3" />
failed
</Badge>
)
}
if (s === "queued" || s === "running") {
return (
<Badge variant="secondary" className="flex items-center gap-1 text-xs">
<Loader2 className="h-3 w-3 animate-spin" />
{s}
</Badge>
)
}
return (
<Badge variant="outline" className="text-xs">
{s}
</Badge>
)
}
function fmtTime(v: any): string {
if (!v) return "-"
try {
const d = v instanceof Date ? v : new Date(v)
if (Number.isNaN(d.getTime())) return "-"
return d.toLocaleString()
} catch {
return "-"
}
}
function ClusterSummary({ c }: { c: DtoClusterResponse }) { function ClusterSummary({ c }: { c: DtoClusterResponse }) {
return ( return (
<div className="text-muted-foreground flex flex-col gap-1 text-xs"> <div className="text-muted-foreground flex flex-col gap-1 text-xs">
@@ -173,7 +211,7 @@ export const ClustersPage = () => {
const [deleteId, setDeleteId] = useState<string | null>(null) const [deleteId, setDeleteId] = useState<string | null>(null)
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
// Config dialog state // Configure dialog state
const [configCluster, setConfigCluster] = useState<DtoClusterResponse | null>(null) const [configCluster, setConfigCluster] = useState<DtoClusterResponse | null>(null)
const [captainDomainId, setCaptainDomainId] = useState("") const [captainDomainId, setCaptainDomainId] = useState("")
@@ -193,36 +231,69 @@ export const ClustersPage = () => {
const clustersQ = useQuery({ const clustersQ = useQuery({
queryKey: ["clusters"], queryKey: ["clusters"],
queryFn: () => clustersApi.listClusters(), queryFn: async () => asArray<DtoClusterResponse>(await clustersApi.listClusters()),
}) })
const lbsQ = useQuery({ const lbsQ = useQuery({
queryKey: ["load-balancers"], queryKey: ["load-balancers"],
queryFn: () => loadBalancersApi.listLoadBalancers(), queryFn: async () =>
asArray<DtoLoadBalancerResponse>(await loadBalancersApi.listLoadBalancers()),
}) })
const domainsQ = useQuery({ const domainsQ = useQuery({
queryKey: ["domains"], queryKey: ["domains"],
queryFn: () => dnsApi.listDomains(), queryFn: async () => asArray<DtoDomainResponse>(await dnsApi.listDomains()),
}) })
// record sets fetched per captain domain
const recordSetsQ = useQuery({ const recordSetsQ = useQuery({
queryKey: ["record-sets", captainDomainId], queryKey: ["record-sets", captainDomainId],
enabled: !!captainDomainId, enabled: !!captainDomainId,
queryFn: () => dnsApi.listRecordSetsByDomain(captainDomainId), queryFn: async () =>
asArray<DtoRecordSetResponse>(await dnsApi.listRecordSetsByDomain(captainDomainId)),
}) })
const serversQ = useQuery({ const serversQ = useQuery({
queryKey: ["servers"], queryKey: ["servers"],
queryFn: () => serversApi.listServers(), queryFn: async () => asArray<DtoServerResponse>(await serversApi.listServers()),
}) })
const npQ = useQuery({ const npQ = useQuery({
queryKey: ["node-pools"], queryKey: ["node-pools"],
queryFn: () => nodePoolsApi.listNodePools(), queryFn: async () => asArray<DtoNodePoolResponse>(await nodePoolsApi.listNodePools()),
}) })
const actionsQ = useQuery({
queryKey: ["actions"],
queryFn: async () => asArray<DtoActionResponse>(await actionsApi.listActions()),
})
const runsQ = useQuery({
queryKey: ["cluster-runs", configCluster?.id],
enabled: !!configCluster?.id,
queryFn: async () =>
asArray<DtoClusterRunResponse>(await clustersApi.listClusterRuns(configCluster!.id!)),
refetchInterval: (data) => {
// IMPORTANT: data might not be array if queryFn isn't normalizing. But it is here anyway.
const rows = Array.isArray(data) ? data : []
const active = rows.some((r: any) => {
const s = String(r?.status ?? "").toLowerCase()
return s === "queued" || s === "running"
})
return active ? 2000 : false
},
})
const actionLabelByTarget = useMemo(() => {
const m = new Map<string, string>()
;(actionsQ.data ?? []).forEach((a) => {
if (a.make_target) m.set(a.make_target, a.label ?? a.make_target)
})
return m
}, [actionsQ.data])
const runDisplayName = (r: DtoClusterRunResponse) =>
actionLabelByTarget.get(r.action ?? "") ?? r.action ?? "unknown"
// --- Create --- // --- Create ---
const createForm = useForm<CreateClusterInput>({ const createForm = useForm<CreateClusterInput>({
@@ -244,15 +315,10 @@ export const ClustersPage = () => {
setCreateOpen(false) setCreateOpen(false)
toast.success("Cluster created successfully.") toast.success("Cluster created successfully.")
}, },
onError: (err: any) => { onError: (err: any) =>
toast.error(err?.message ?? "There was an error while creating the cluster") toast.error(err?.message ?? "There was an error while creating the cluster"),
},
}) })
const onCreateSubmit = (values: CreateClusterInput) => {
createMut.mutate(values)
}
// --- Update basic details --- // --- Update basic details ---
const updateForm = useForm<UpdateClusterValues>({ const updateForm = useForm<UpdateClusterValues>({
@@ -269,9 +335,8 @@ export const ClustersPage = () => {
setUpdateOpen(false) setUpdateOpen(false)
toast.success("Cluster updated successfully.") toast.success("Cluster updated successfully.")
}, },
onError: (err: any) => { onError: (err: any) =>
toast.error(err?.message ?? "There was an error while updating the cluster") toast.error(err?.message ?? "There was an error while updating the cluster"),
},
}) })
const openEdit = (cluster: DtoClusterResponse) => { const openEdit = (cluster: DtoClusterResponse) => {
@@ -296,11 +361,32 @@ export const ClustersPage = () => {
setDeleteId(null) setDeleteId(null)
toast.success("Cluster deleted successfully.") toast.success("Cluster deleted successfully.")
}, },
onError: (err: any) => { onError: (err: any) =>
toast.error(err?.message ?? "There was an error while deleting the cluster") toast.error(err?.message ?? "There was an error while deleting the cluster"),
},
}) })
// --- Run Action ---
const runActionMut = useMutation({
mutationFn: ({ clusterID, actionID }: { clusterID: string; actionID: string }) =>
clustersApi.runClusterAction(clusterID, actionID),
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ["cluster-runs", configCluster?.id] })
toast.success("Action enqueued.")
},
onError: (err: any) => toast.error(err?.message ?? "Failed to enqueue action."),
})
async function handleRunAction(actionID: string) {
if (!configCluster?.id) return
setBusyKey(`run:${actionID}`)
try {
await runActionMut.mutateAsync({ clusterID: configCluster.id, actionID })
} finally {
setBusyKey(null)
}
}
// --- Filter --- // --- Filter ---
const filtered = useMemo(() => { const filtered = useMemo(() => {
@@ -333,30 +419,23 @@ export const ClustersPage = () => {
return return
} }
// Prefill IDs from current attachments if (configCluster.captain_domain?.id) setCaptainDomainId(configCluster.captain_domain.id)
if (configCluster.captain_domain?.id) { if (configCluster.control_plane_record_set?.id)
setCaptainDomainId(configCluster.captain_domain.id)
}
if (configCluster.control_plane_record_set?.id) {
setRecordSetId(configCluster.control_plane_record_set.id) setRecordSetId(configCluster.control_plane_record_set.id)
} if (configCluster.apps_load_balancer?.id) setAppsLbId(configCluster.apps_load_balancer.id)
if (configCluster.apps_load_balancer?.id) { if (configCluster.glueops_load_balancer?.id)
setAppsLbId(configCluster.apps_load_balancer.id)
}
if (configCluster.glueops_load_balancer?.id) {
setGlueopsLbId(configCluster.glueops_load_balancer.id) setGlueopsLbId(configCluster.glueops_load_balancer.id)
} if (configCluster.bastion_server?.id) setBastionId(configCluster.bastion_server.id)
if (configCluster.bastion_server?.id) {
setBastionId(configCluster.bastion_server.id)
}
}, [configCluster]) }, [configCluster])
async function refreshConfigCluster() { async function refreshConfigCluster() {
if (!configCluster?.id) return if (!configCluster?.id) return
try { try {
const updated = await clustersApi.getCluster(configCluster.id) const updatedRaw = await clustersApi.getCluster(configCluster.id)
const updated = asObject<DtoClusterResponse>(updatedRaw)
setConfigCluster(updated) setConfigCluster(updated)
await qc.invalidateQueries({ queryKey: ["clusters"] }) await qc.invalidateQueries({ queryKey: ["clusters"] })
await qc.invalidateQueries({ queryKey: ["cluster-runs", configCluster.id] })
} catch { } catch {
// ignore // ignore
} }
@@ -364,15 +443,10 @@ export const ClustersPage = () => {
async function handleAttachCaptain() { async function handleAttachCaptain() {
if (!configCluster?.id) return if (!configCluster?.id) return
if (!captainDomainId) { if (!captainDomainId) return toast.error("Domain is required")
toast.error("Domain is required")
return
}
setBusyKey("captain") setBusyKey("captain")
try { try {
await clustersApi.attachCaptainDomain(configCluster.id, { await clustersApi.attachCaptainDomain(configCluster.id, { domain_id: captainDomainId })
domain_id: captainDomainId,
})
toast.success("Captain domain attached.") toast.success("Captain domain attached.")
await refreshConfigCluster() await refreshConfigCluster()
} catch (err: any) { } catch (err: any) {
@@ -398,10 +472,7 @@ export const ClustersPage = () => {
async function handleAttachRecordSet() { async function handleAttachRecordSet() {
if (!configCluster?.id) return if (!configCluster?.id) return
if (!recordSetId) { if (!recordSetId) return toast.error("Record set is required")
toast.error("Record set is required")
return
}
setBusyKey("recordset") setBusyKey("recordset")
try { try {
await clustersApi.attachControlPlaneRecordSet(configCluster.id, { await clustersApi.attachControlPlaneRecordSet(configCluster.id, {
@@ -432,15 +503,10 @@ export const ClustersPage = () => {
async function handleAttachAppsLb() { async function handleAttachAppsLb() {
if (!configCluster?.id) return if (!configCluster?.id) return
if (!appsLbId) { if (!appsLbId) return toast.error("Load balancer is required")
toast.error("Load balancer is required")
return
}
setBusyKey("apps-lb") setBusyKey("apps-lb")
try { try {
await clustersApi.attachAppsLoadBalancer(configCluster.id, { await clustersApi.attachAppsLoadBalancer(configCluster.id, { load_balancer_id: appsLbId })
load_balancer_id: appsLbId,
})
toast.success("Apps load balancer attached.") toast.success("Apps load balancer attached.")
await refreshConfigCluster() await refreshConfigCluster()
} catch (err: any) { } catch (err: any) {
@@ -466,10 +532,7 @@ export const ClustersPage = () => {
async function handleAttachGlueopsLb() { async function handleAttachGlueopsLb() {
if (!configCluster?.id) return if (!configCluster?.id) return
if (!glueopsLbId) { if (!glueopsLbId) return toast.error("Load balancer is required")
toast.error("Load balancer is required")
return
}
setBusyKey("glueops-lb") setBusyKey("glueops-lb")
try { try {
await clustersApi.attachGlueOpsLoadBalancer(configCluster.id, { await clustersApi.attachGlueOpsLoadBalancer(configCluster.id, {
@@ -500,15 +563,10 @@ export const ClustersPage = () => {
async function handleAttachBastion() { async function handleAttachBastion() {
if (!configCluster?.id) return if (!configCluster?.id) return
if (!bastionId) { if (!bastionId) return toast.error("Server is required")
toast.error("Server is required")
return
}
setBusyKey("bastion") setBusyKey("bastion")
try { try {
await clustersApi.attachBastion(configCluster.id, { await clustersApi.attachBastion(configCluster.id, { server_id: bastionId })
server_id: bastionId,
})
toast.success("Bastion server attached.") toast.success("Bastion server attached.")
await refreshConfigCluster() await refreshConfigCluster()
} catch (err: any) { } catch (err: any) {
@@ -534,10 +592,7 @@ export const ClustersPage = () => {
async function handleAttachNodePool() { async function handleAttachNodePool() {
if (!configCluster?.id) return if (!configCluster?.id) return
if (!nodePoolId) { if (!nodePoolId) return toast.error("Node pool is required")
toast.error("Node pool is required")
return
}
setBusyKey("nodepool") setBusyKey("nodepool")
try { try {
await clustersApi.attachNodePool(configCluster.id, nodePoolId) await clustersApi.attachNodePool(configCluster.id, nodePoolId)
@@ -567,15 +622,10 @@ export const ClustersPage = () => {
async function handleSetKubeconfig() { async function handleSetKubeconfig() {
if (!configCluster?.id) return if (!configCluster?.id) return
if (!kubeconfigText.trim()) { if (!kubeconfigText.trim()) return toast.error("Kubeconfig is required")
toast.error("Kubeconfig is required")
return
}
setBusyKey("kubeconfig") setBusyKey("kubeconfig")
try { try {
await clustersApi.setKubeconfig(configCluster.id, { await clustersApi.setKubeconfig(configCluster.id, { kubeconfig: kubeconfigText })
kubeconfig: kubeconfigText,
})
toast.success("Kubeconfig updated.") toast.success("Kubeconfig updated.")
setKubeconfigText("") setKubeconfigText("")
await refreshConfigCluster() await refreshConfigCluster()
@@ -636,7 +686,10 @@ export const ClustersPage = () => {
</DialogHeader> </DialogHeader>
<Form {...createForm}> <Form {...createForm}>
<form className="space-y-4" onSubmit={createForm.handleSubmit(onCreateSubmit)}> <form
className="space-y-4"
onSubmit={createForm.handleSubmit((v) => createMut.mutate(v))}
>
<FormField <FormField
control={createForm.control} control={createForm.control}
name="name" name="name"
@@ -750,7 +803,7 @@ export const ClustersPage = () => {
</div> </div>
)} )}
</TableCell> </TableCell>
<TableCell>{c.docker_image + ":" + c.docker_tag}</TableCell> <TableCell>{(c.docker_image ?? "") + ":" + (c.docker_tag ?? "")}</TableCell>
<TableCell> <TableCell>
<ClusterSummary c={c} /> <ClusterSummary c={c} />
{c.id && ( {c.id && (
@@ -782,7 +835,7 @@ export const ClustersPage = () => {
{filtered.length === 0 && ( {filtered.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-muted-foreground py-10 text-center"> <TableCell colSpan={7} className="text-muted-foreground py-10 text-center">
<CircleSlash2 className="mx-auto mb-2 h-6 w-6 opacity-60" /> <CircleSlash2 className="mx-auto mb-2 h-6 w-6 opacity-60" />
No clusters match your search. No clusters match your search.
</TableCell> </TableCell>
@@ -799,6 +852,7 @@ export const ClustersPage = () => {
<DialogHeader> <DialogHeader>
<DialogTitle>Edit Cluster</DialogTitle> <DialogTitle>Edit Cluster</DialogTitle>
</DialogHeader> </DialogHeader>
<Form {...updateForm}> <Form {...updateForm}>
<form <form
className="space-y-4" className="space-y-4"
@@ -890,7 +944,7 @@ export const ClustersPage = () => {
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Configure dialog (attachments + kubeconfig + node pools) */} {/* Configure dialog (attachments + kubeconfig + node pools + actions/runs) */}
<Dialog open={!!configCluster} onOpenChange={(open) => !open && setConfigCluster(null)}> <Dialog open={!!configCluster} onOpenChange={(open) => !open && setConfigCluster(null)}>
<DialogContent className="max-h-[90vh] w-full max-w-3xl overflow-y-auto"> <DialogContent className="max-h-[90vh] w-full max-w-3xl overflow-y-auto">
<DialogHeader> <DialogHeader>
@@ -901,26 +955,144 @@ export const ClustersPage = () => {
{configCluster && ( {configCluster && (
<div className="space-y-6 py-2"> <div className="space-y-6 py-2">
{/* Kubeconfig */} {/* Cluster Actions */}
<section className="space-y-2 rounded-xl border p-4"> <section className="space-y-2 rounded-xl border p-4">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div> <div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FileCode2 className="h-4 w-4" /> <Wrench className="h-4 w-4" />
<h3 className="text-sm font-semibold">Kubeconfig</h3> <h3 className="text-sm font-semibold">Cluster Actions</h3>
</div> </div>
<p className="text-muted-foreground text-xs"> <p className="text-muted-foreground text-xs">
Paste the kubeconfig for this cluster. It will be stored encrypted and never Run admin-configured actions on this cluster. Actions are executed
returned by the API. asynchronously.
</p> </p>
</div> </div>
<Button
size="sm"
variant="outline"
onClick={() => runsQ.refetch()}
disabled={runsQ.isFetching || !configCluster?.id}
>
{runsQ.isFetching ? "Refreshing…" : "Refresh runs"}
</Button>
</div>
<div className="space-y-2">
{actionsQ.isLoading ? (
<p className="text-muted-foreground text-xs">Loading actions</p>
) : (actionsQ.data ?? []).length === 0 ? (
<p className="text-muted-foreground text-xs">
No actions configured yet. Create actions in Admin Actions.
</p>
) : (
<div className="divide-border rounded-md border">
{(actionsQ.data ?? []).map((a: DtoActionResponse) => (
<div
key={a.id}
className="flex items-center justify-between gap-3 px-3 py-2"
>
<div className="flex min-w-0 flex-col">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{a.label}</span>
{a.make_target && (
<code className="text-muted-foreground text-xs">
{a.make_target}
</code>
)}
</div>
{a.description && (
<p className="text-muted-foreground line-clamp-2 text-xs">
{a.description}
</p>
)}
</div>
<Button
size="sm"
onClick={() => a.id && handleRunAction(a.id)}
disabled={!a.id || isBusy(`run:${a.id}`)}
>
{a.id && isBusy(`run:${a.id}`) ? "Enqueueing…" : "Run"}
</Button>
</div>
))}
</div>
)}
</div>
<div className="mt-3 space-y-1">
<Label className="text-xs">Recent Runs</Label>
{runsQ.isLoading ? (
<p className="text-muted-foreground text-xs">Loading runs</p>
) : (runsQ.data ?? []).length === 0 ? (
<p className="text-muted-foreground text-xs">No runs yet for this cluster.</p>
) : (
<div className="overflow-x-auto rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Action</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead>Finished</TableHead>
<TableHead>Error</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(runsQ.data ?? []).slice(0, 20).map((r) => (
<TableRow key={r.id}>
<TableCell className="min-w-[220px]">
<div className="flex flex-col">
<span className="text-sm font-medium">{runDisplayName(r)}</span>
{r.id && (
<code className="text-muted-foreground text-xs">
{truncateMiddle(r.id, 8)}
</code>
)}
</div>
</TableCell>
<TableCell>
<RunStatusBadge status={r.status} />
</TableCell>
<TableCell className="text-xs">
{fmtTime((r as any).created_at)}
</TableCell>
<TableCell className="text-xs">
{fmtTime((r as any).finished_at)}
</TableCell>
<TableCell className="text-xs">
{r.error ? truncateMiddle(r.error, 80) : "-"}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)}
</div>
</section>
{/* Kubeconfig */}
<section className="space-y-2 rounded-xl border p-4">
<div>
<div className="flex items-center gap-2">
<FileCode2 className="h-4 w-4" />
<h3 className="text-sm font-semibold">Kubeconfig</h3>
</div>
<p className="text-muted-foreground text-xs">
Paste the kubeconfig for this cluster. It will be stored encrypted and never
returned by the API.
</p>
</div> </div>
<Textarea <Textarea
value={kubeconfigText} value={kubeconfigText}
onChange={(e) => setKubeconfigText(e.target.value)} onChange={(e) => setKubeconfigText(e.target.value)}
rows={6} rows={6}
placeholder="apiVersion: v1&#10;clusters:&#10; - cluster: ..." placeholder={"apiVersion: v1\nclusters:\n - cluster: ..."}
className="font-mono text-xs" className="font-mono text-xs"
/> />
@@ -1005,7 +1177,7 @@ export const ClustersPage = () => {
</div> </div>
</section> </section>
{/* Control Plane Record Set (shown once we have a captainDomainId) */} {/* Control Plane Record Set */}
{captainDomainId && ( {captainDomainId && (
<section className="space-y-2 rounded-xl border p-4"> <section className="space-y-2 rounded-xl border p-4">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
@@ -1242,14 +1414,12 @@ export const ClustersPage = () => {
{/* Node Pools */} {/* Node Pools */}
<section className="space-y-2 rounded-xl border p-4"> <section className="space-y-2 rounded-xl border p-4">
<div className="flex items-center justify-between gap-2"> <div>
<div> <h3 className="text-sm font-semibold">Node Pools</h3>
<h3 className="text-sm font-semibold">Node Pools</h3> <p className="text-muted-foreground text-xs">
<p className="text-muted-foreground text-xs"> Attach node pools to this cluster. Each node pool may have its own labels,
Attach node pools to this cluster. Each node pool may have its own labels, taints, and backing servers.
taints, and backing servers. </p>
</p>
</div>
</div> </div>
<div className="flex flex-col gap-2 md:flex-row md:items-end"> <div className="flex flex-col gap-2 md:flex-row md:items-end">
@@ -1348,8 +1518,6 @@ export const ClustersPage = () => {
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
<pre>{JSON.stringify(clustersQ.data, null, 2)}</pre>
</div> </div>
) )
} }

View File

@@ -1,9 +1,11 @@
import { orgStore } from "@/auth/org.ts" import { orgStore } from "@/auth/org.ts"
import { authStore } from "@/auth/store.ts" import { authStore } from "@/auth/store.ts"
import { import {
ActionsApi,
AnnotationsApi, AnnotationsApi,
ArcherAdminApi, ArcherAdminApi,
AuthApi, AuthApi,
ClusterRunsApi,
ClustersApi, ClustersApi,
Configuration, Configuration,
CredentialsApi, CredentialsApi,
@@ -133,3 +135,11 @@ export function makeLoadBalancerApi() {
export function makeClusterApi() { export function makeClusterApi() {
return makeApiClient(ClustersApi) return makeApiClient(ClustersApi)
} }
export function makeActionsApi() {
return makeApiClient(ActionsApi)
}
export function makeClusterRunsApi() {
return makeApiClient(ClusterRunsApi)
}