feat: cluster page ui

Signed-off-by: allanice001 <allanice001@gmail.com>
This commit is contained in:
allanice001
2025-11-17 18:21:48 +00:00
parent d163a050d8
commit 56f86a11b4
24 changed files with 1864 additions and 68 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -46,6 +46,11 @@ components:
load_balancer_id:
type: string
type: object
dto.AttachNodePoolRequest:
properties:
node_pool_id:
type: string
type: object
dto.AttachRecordSetRequest:
properties:
record_set_id:
@@ -1154,7 +1159,7 @@ info:
name: GlueOps
description: API for managing K3s clusters across cloud providers
title: AutoGlue API
version: "1.0"
version: ""
openapi: 3.1.0
paths:
/.well-known/jwks.json:
@@ -2853,6 +2858,139 @@ paths:
summary: Set (or replace) the kubeconfig for a cluster
tags:
- Clusters
/clusters/{clusterID}/node-pools:
post:
description: Adds an entry in the cluster_node_pools join table.
operationId: AttachNodePool
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
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/dto.AttachNodePoolRequest'
description: payload
required: true
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/dto.ClusterResponse'
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: cluster or node pool not found
"500":
content:
application/json:
schema:
type: string
description: db error
security:
- BearerAuth: []
- OrgKeyAuth: []
- OrgSecretAuth: []
summary: Attach a node pool to a cluster
tags:
- Clusters
/clusters/{clusterID}/node-pools/{nodePoolID}:
delete:
description: Removes an entry from the cluster_node_pools join table.
operationId: DetachNodePool
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: Node Pool ID
in: path
name: nodePoolID
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
$ref: '#/components/schemas/dto.ClusterResponse'
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: cluster or node pool not found
"500":
content:
application/json:
schema:
type: string
description: db error
security:
- BearerAuth: []
- OrgKeyAuth: []
- OrgSecretAuth: []
summary: Detach a node pool from a cluster
tags:
- Clusters
/credentials:
get:
description: Returns credential metadata for the current org. Secrets are never

View File

@@ -35,5 +35,7 @@ func mountClusterRoutes(r chi.Router, db *gorm.DB, authOrg func(http.Handler) ht
c.Post("/{clusterID}/kubeconfig", handlers.SetClusterKubeconfig(db))
c.Delete("/{clusterID}/kubeconfig", handlers.ClearClusterKubeconfig(db))
c.Post("/{clusterID}/node-pools", handlers.AttachNodePool(db))
c.Delete("/{clusterID}/node-pools/{nodePoolID}", handlers.DeleteNodePool(db))
})
}

View File

@@ -1294,6 +1294,199 @@ func ClearClusterKubeconfig(db *gorm.DB) http.HandlerFunc {
}
}
// AttachNodePool godoc
//
// @ID AttachNodePool
// @Summary Attach a node pool to a cluster
// @Description Adds an entry in the cluster_node_pools join table.
// @Tags Clusters
// @Accept json
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param body body dto.AttachNodePoolRequest true "payload"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or node pool not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/node-pools [post]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func AttachNodePool(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
}
var in dto.AttachNodePoolRequest
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
// Load cluster (org scoped)
var cluster models.Cluster
if err := db.
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
}
// Load node pool (org scoped)
var np models.NodePool
if err := db.
Where("id = ? AND organization_id = ?", in.NodePoolID, orgID).
First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "nodepool_not_found", "node pool not found for organization")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
// Create association in join table
if err := db.Model(&cluster).Association("NodePools").Append(&np); err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to attach node pool")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
// Reload for rich response
if err := db.
Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// DetachNodePool godoc
//
// @ID DetachNodePool
// @Summary Detach a node pool from a cluster
// @Description Removes an entry from the cluster_node_pools join table.
// @Tags Clusters
// @Produce json
// @Param X-Org-ID header string false "Organization UUID"
// @Param clusterID path string true "Cluster ID"
// @Param nodePoolID path string true "Node Pool ID"
// @Success 200 {object} dto.ClusterResponse
// @Failure 400 {string} string "bad request"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "cluster or node pool not found"
// @Failure 500 {string} string "db error"
// @Router /clusters/{clusterID}/node-pools/{nodePoolID} [delete]
// @Security BearerAuth
// @Security OrgKeyAuth
// @Security OrgSecretAuth
func DetachNodePool(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
}
nodePoolID, err := uuid.Parse(chi.URLParam(r, "nodePoolID"))
if err != nil {
utils.WriteError(w, http.StatusBadRequest, "bad_nodepool_id", "invalid node pool id")
return
}
var cluster models.Cluster
if err := db.
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
}
var np models.NodePool
if err := db.
Where("id = ? AND organization_id = ?", nodePoolID, orgID).
First(&np).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
utils.WriteError(w, http.StatusNotFound, "nodepool_not_found", "node pool not found for organization")
return
}
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
if err := db.Model(&cluster).Association("NodePools").Delete(&np); err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "failed to detach node pool")
return
}
_ = markClusterNeedsValidation(db, cluster.ID)
if err := db.
Preload("CaptainDomain").
Preload("ControlPlaneRecordSet").
Preload("AppsLoadBalancer").
Preload("GlueOpsLoadBalancer").
Preload("BastionServer").
Preload("NodePools").
Preload("NodePools.Labels").
Preload("NodePools.Annotations").
Preload("NodePools.Taints").
Preload("NodePools.Servers").
First(&cluster, "id = ?", cluster.ID).Error; err != nil {
utils.WriteError(w, http.StatusInternalServerError, "db_error", "db error")
return
}
utils.WriteJSON(w, http.StatusOK, clusterToDTO(cluster))
}
}
// -- Helpers
func clusterToDTO(c models.Cluster) dto.ClusterResponse {

View File

@@ -56,3 +56,7 @@ type AttachBastionRequest struct {
type SetKubeconfigRequest struct {
Kubeconfig string `json:"kubeconfig"`
}
type AttachNodePoolRequest struct {
NodePoolID uuid.UUID `json:"node_pool_id"`
}

View File

@@ -19,14 +19,11 @@ type Cluster struct {
ID uuid.UUID `gorm:"type:uuid;default:gen_random_uuid();primaryKey" json:"id"`
OrganizationID uuid.UUID `gorm:"type:uuid;not null" json:"organization_id"`
Organization Organization `gorm:"foreignKey:OrganizationID;constraint:OnDelete:CASCADE" json:"organization"`
Name string `gorm:"not null" json:"name"`
Provider string `json:"provider"`
Region string `json:"region"`
Status string `gorm:"type:varchar(20);not null;default:'pre_pending'" json:"status"`
LastError string `gorm:"type:text;not null;default:''" json:"last_error"`
CaptainDomainID *uuid.UUID `gorm:"type:uuid" json:"captain_domain_id"`
CaptainDomain Domain `gorm:"foreignKey:CaptainDomainID" json:"captain_domain"`
ControlPlaneRecordSetID *uuid.UUID `gorm:"type:uuid" json:"control_plane_record_set_id,omitempty"`
@@ -37,16 +34,12 @@ type Cluster struct {
GlueOpsLoadBalancer *LoadBalancer `gorm:"foreignKey:GlueOpsLoadBalancerID" json:"glueops_load_balancer,omitempty"`
BastionServerID *uuid.UUID `gorm:"type:uuid" json:"bastion_server_id,omitempty"`
BastionServer *Server `gorm:"foreignKey:BastionServerID" json:"bastion_server,omitempty"`
NodePools []NodePool `gorm:"many2many:cluster_node_pools;constraint:OnDelete:CASCADE" json:"node_pools,omitempty"`
RandomToken string `json:"random_token"`
CertificateKey string `json:"certificate_key"`
EncryptedKubeconfig string `gorm:"type:text" json:"-"`
KubeIV string `json:"-"`
KubeTag string `json:"-"`
CreatedAt time.Time `json:"created_at,omitempty" gorm:"type:timestamptz;column:created_at;not null;default:now()"`
UpdatedAt time.Time `json:"updated_at,omitempty" gorm:"type:timestamptz;autoUpdateTime;column:updated_at;not null;default:now()"`
}

View File

@@ -2,10 +2,12 @@ package main
import (
"github.com/glueops/autoglue/cmd"
"github.com/glueops/autoglue/docs"
"github.com/glueops/autoglue/internal/version"
)
// @title AutoGlue API
// @version 1.0
// @version dev
// @description API for managing K3s clusters across cloud providers
// @contact.name GlueOps
@@ -37,5 +39,6 @@ import (
// @description Org-level secret
func main() {
docs.SwaggerInfo.Version = version.Version
cmd.Execute()
}

View File

@@ -2,22 +2,23 @@ import { AppShell } from "@/layouts/app-shell.tsx"
import { Route, Routes } from "react-router-dom"
import { ProtectedRoute } from "@/components/protected-route.tsx"
import { AnnotationPage } from "@/pages/annotations/annotation-page.tsx"
import { Login } from "@/pages/auth/login.tsx"
import { CredentialPage } from "@/pages/credentials/credential-page.tsx"
import { DnsPage } from "@/pages/dns/dns-page.tsx"
import { DocsPage } from "@/pages/docs/docs-page.tsx"
import { JobsPage } from "@/pages/jobs/jobs-page.tsx"
import { LabelsPage } from "@/pages/labels/labels-page.tsx"
import { LoadBalancersPage } from "@/pages/loadbalancers/load-balancers-page"
import { MePage } from "@/pages/me/me-page.tsx"
import { NodePoolsPage } from "@/pages/nodepools/node-pools-page.tsx"
import { AnnotationPage } from "@/pages/annotation-page.tsx"
import { ClustersPage } from "@/pages/cluster-page"
import { CredentialPage } from "@/pages/credential-page.tsx"
import { DnsPage } from "@/pages/dns-page.tsx"
import { DocsPage } from "@/pages/docs-page.tsx"
import { JobsPage } from "@/pages/jobs-page.tsx"
import { LabelsPage } from "@/pages/labels-page.tsx"
import { LoadBalancersPage } from "@/pages/load-balancers-page"
import { Login } from "@/pages/login.tsx"
import { MePage } from "@/pages/me-page.tsx"
import { NodePoolsPage } from "@/pages/node-pools-page.tsx"
import { OrgApiKeys } from "@/pages/org/api-keys.tsx"
import { OrgMembers } from "@/pages/org/members.tsx"
import { OrgSettings } from "@/pages/org/settings.tsx"
import { ServerPage } from "@/pages/servers/server-page.tsx"
import { SshPage } from "@/pages/ssh/ssh-page.tsx"
import { TaintsPage } from "@/pages/taints/taints-page.tsx"
import { ServerPage } from "@/pages/server-page.tsx"
import { SshPage } from "@/pages/ssh-page.tsx"
import { TaintsPage } from "@/pages/taints-page.tsx"
export default function App() {
return (
@@ -42,6 +43,7 @@ export default function App() {
<Route path="/credentials" element={<CredentialPage />} />
<Route path="/dns" element={<DnsPage />} />
<Route path="/load-balancers" element={<LoadBalancersPage />} />
<Route path="/clusters" element={<ClustersPage />} />
<Route path="/admin/jobs" element={<JobsPage />} />
</Route>

150
ui/src/api/clusters.ts Normal file
View File

@@ -0,0 +1,150 @@
import { withRefresh } from "@/api/with-refresh"
import type {
DtoAttachBastionRequest,
DtoAttachCaptainDomainRequest,
DtoAttachLoadBalancerRequest,
DtoAttachRecordSetRequest,
DtoCreateClusterRequest,
DtoSetKubeconfigRequest,
DtoUpdateClusterRequest,
} from "@/sdk"
import { makeClusterApi } from "@/sdkClient"
const clusters = makeClusterApi()
export const clustersApi = {
// --- basic CRUD ---
listClusters: (q?: string) =>
withRefresh(async () => {
return await clusters.listClusters(q ? { q } : {})
}),
getCluster: (id: string) =>
withRefresh(async () => {
return await clusters.getCluster({ clusterID: id })
}),
createCluster: (body: DtoCreateClusterRequest) =>
withRefresh(async () => {
return await clusters.createCluster({
dtoCreateClusterRequest: body,
})
}),
updateCluster: (id: string, body: DtoUpdateClusterRequest) =>
withRefresh(async () => {
return await clusters.updateCluster({
clusterID: id,
dtoUpdateClusterRequest: body,
})
}),
deleteCluster: (id: string) =>
withRefresh(async () => {
return await clusters.deleteCluster({ clusterID: id })
}),
// --- kubeconfig ---
setKubeconfig: (clusterID: string, body: DtoSetKubeconfigRequest) =>
withRefresh(async () => {
return await clusters.setClusterKubeconfig({
clusterID,
dtoSetKubeconfigRequest: body,
})
}),
clearKubeconfig: (clusterID: string) =>
withRefresh(async () => {
return await clusters.clearClusterKubeconfig({ clusterID })
}),
// --- captain domain ---
attachCaptainDomain: (clusterID: string, body: DtoAttachCaptainDomainRequest) =>
withRefresh(async () => {
return await clusters.attachCaptainDomain({
clusterID,
dtoAttachCaptainDomainRequest: body,
})
}),
detachCaptainDomain: (clusterID: string) =>
withRefresh(async () => {
return await clusters.detachCaptainDomain({ clusterID })
}),
// --- control plane record set ---
attachControlPlaneRecordSet: (clusterID: string, body: DtoAttachRecordSetRequest) =>
withRefresh(async () => {
return await clusters.attachControlPlaneRecordSet({
clusterID,
dtoAttachRecordSetRequest: body,
})
}),
detachControlPlaneRecordSet: (clusterID: string) =>
withRefresh(async () => {
return await clusters.detachControlPlaneRecordSet({ clusterID })
}),
// --- load balancers ---
attachAppsLoadBalancer: (clusterID: string, body: DtoAttachLoadBalancerRequest) =>
withRefresh(async () => {
return await clusters.attachAppsLoadBalancer({
clusterID,
dtoAttachLoadBalancerRequest: body,
})
}),
detachAppsLoadBalancer: (clusterID: string) =>
withRefresh(async () => {
return await clusters.detachAppsLoadBalancer({ clusterID })
}),
attachGlueOpsLoadBalancer: (clusterID: string, body: DtoAttachLoadBalancerRequest) =>
withRefresh(async () => {
return await clusters.attachGlueOpsLoadBalancer({
clusterID,
dtoAttachLoadBalancerRequest: body,
})
}),
detachGlueOpsLoadBalancer: (clusterID: string) =>
withRefresh(async () => {
return await clusters.detachGlueOpsLoadBalancer({ clusterID })
}),
// --- bastion ---
attachBastion: (clusterID: string, body: DtoAttachBastionRequest) =>
withRefresh(async () => {
return await clusters.attachBastionServer({
clusterID,
dtoAttachBastionRequest: body,
})
}),
detachBastion: (clusterID: string) =>
withRefresh(async () => {
return await clusters.detachBastionServer({ clusterID })
}),
// -- node-pools
attachNodePool: (clusterID: string, nodePoolID: string) =>
withRefresh(async () => {
return await clusters.attachNodePool({
clusterID,
dtoAttachNodePoolRequest: { node_pool_id: nodePoolID },
})
}),
detachNodePool: (clusterID: string, nodePoolID: string) =>
withRefresh(async () => {
return await clusters.detachNodePool({ clusterID, nodePoolID })
}),
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +1,66 @@
import { useMemo, useState } from "react";
import { credentialsApi } from "@/api/credentials";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Eye, Loader2, MoreHorizontal, Pencil, Plus, Search, Trash2 } from "lucide-react";
import { Controller, useForm } from "react-hook-form";
import { toast } from "sonner";
import { z } from "zod";
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from "@/components/ui/alert-dialog";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { useMemo, useState } from "react"
import { credentialsApi } from "@/api/credentials"
import { zodResolver } from "@hookform/resolvers/zod"
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"
import {
AlertTriangle,
Eye,
Loader2,
MoreHorizontal,
Pencil,
Plus,
Search,
Trash2,
} from "lucide-react"
import { Controller, useForm } from "react-hook-form"
import { toast } from "sonner"
import { z } from "zod"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { Switch } from "@/components/ui/switch"
import { Textarea } from "@/components/ui/textarea"
// -------------------- Constants --------------------