Servers Page & API

This commit is contained in:
allanice001
2025-09-01 23:53:48 +01:00
parent 5425ed5dcc
commit 7f29580d3b
20 changed files with 2350 additions and 49 deletions

View File

@@ -1121,6 +1121,365 @@ const docTemplate = `{
}
}
},
"/api/v1/servers": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Returns servers for the organization in X-Org-ID. Optional filters: status, role.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"servers"
],
"summary": "List servers (org scoped)",
"parameters": [
{
"type": "string",
"description": "Organization UUID",
"name": "X-Org-ID",
"in": "header",
"required": true
},
{
"type": "string",
"description": "Filter by status (pending|provisioning|ready|failed)",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "Filter by role",
"name": "role",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/servers.serverResponse"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "string"
}
},
"403": {
"description": "organization required",
"schema": {
"type": "string"
}
},
"500": {
"description": "failed to list servers",
"schema": {
"type": "string"
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Creates a server bound to the org in X-Org-ID. Validates that ssh_key_id belongs to the org.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"servers"
],
"summary": "Create server (org scoped)",
"parameters": [
{
"type": "string",
"description": "Organization UUID",
"name": "X-Org-ID",
"in": "header",
"required": true
},
{
"description": "Server payload",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/servers.createServerRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/servers.serverResponse"
}
},
"400": {
"description": "invalid json / missing fields / invalid status / invalid ssh_key_id",
"schema": {
"type": "string"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "string"
}
},
"403": {
"description": "organization required",
"schema": {
"type": "string"
}
},
"500": {
"description": "create failed",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/servers/{id}": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Returns one server in the given organization.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"servers"
],
"summary": "Get server by ID (org scoped)",
"parameters": [
{
"type": "string",
"description": "Organization UUID",
"name": "X-Org-ID",
"in": "header",
"required": true
},
{
"type": "string",
"description": "Server ID (UUID)",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/servers.serverResponse"
}
},
"400": {
"description": "invalid id",
"schema": {
"type": "string"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "string"
}
},
"403": {
"description": "organization required",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
},
"500": {
"description": "fetch failed",
"schema": {
"type": "string"
}
}
}
},
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "Permanently deletes the server.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"servers"
],
"summary": "Delete server (org scoped)",
"parameters": [
{
"type": "string",
"description": "Organization UUID",
"name": "X-Org-ID",
"in": "header",
"required": true
},
{
"type": "string",
"description": "Server ID (UUID)",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content",
"schema": {
"type": "string"
}
},
"400": {
"description": "invalid id",
"schema": {
"type": "string"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "string"
}
},
"403": {
"description": "organization required",
"schema": {
"type": "string"
}
},
"500": {
"description": "delete failed",
"schema": {
"type": "string"
}
}
}
},
"patch": {
"security": [
{
"BearerAuth": []
}
],
"description": "Partially update fields; changing ssh_key_id validates ownership.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"servers"
],
"summary": "Update server (org scoped)",
"parameters": [
{
"type": "string",
"description": "Organization UUID",
"name": "X-Org-ID",
"in": "header",
"required": true
},
{
"type": "string",
"description": "Server ID (UUID)",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Fields to update",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/servers.updateServerRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/servers.serverResponse"
}
},
"400": {
"description": "invalid id / invalid json / invalid status / invalid ssh_key_id",
"schema": {
"type": "string"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "string"
}
},
"403": {
"description": "organization required",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
},
"500": {
"description": "update failed",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/ssh": {
"get": {
"security": [
@@ -1844,6 +2203,92 @@ const docTemplate = `{
}
}
},
"servers.createServerRequest": {
"type": "object",
"properties": {
"hostname": {
"type": "string"
},
"ip_address": {
"type": "string"
},
"role": {
"type": "string",
"example": "master|worker|bastion"
},
"ssh_key_id": {
"type": "string"
},
"ssh_user": {
"type": "string"
},
"status": {
"type": "string",
"example": "pending|provisioning|ready|failed"
}
}
},
"servers.serverResponse": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"hostname": {
"type": "string"
},
"id": {
"type": "string"
},
"ip_address": {
"type": "string"
},
"organization_id": {
"type": "string"
},
"role": {
"type": "string"
},
"ssh_key_id": {
"type": "string"
},
"ssh_user": {
"type": "string"
},
"status": {
"type": "string"
},
"updated_at": {
"type": "string"
}
}
},
"servers.updateServerRequest": {
"type": "object",
"properties": {
"hostname": {
"type": "string"
},
"ip_address": {
"type": "string"
},
"role": {
"type": "string",
"example": "master|worker|bastion"
},
"ssh_key_id": {
"type": "string"
},
"ssh_user": {
"type": "string"
},
"status": {
"description": "enum: pending,provisioning,ready,failed",
"type": "string",
"example": "pending|provisioning|ready|failed"
}
}
},
"ssh.createSSHRequest": {
"type": "object",
"properties": {

View File

@@ -1117,6 +1117,365 @@
}
}
},
"/api/v1/servers": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Returns servers for the organization in X-Org-ID. Optional filters: status, role.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"servers"
],
"summary": "List servers (org scoped)",
"parameters": [
{
"type": "string",
"description": "Organization UUID",
"name": "X-Org-ID",
"in": "header",
"required": true
},
{
"type": "string",
"description": "Filter by status (pending|provisioning|ready|failed)",
"name": "status",
"in": "query"
},
{
"type": "string",
"description": "Filter by role",
"name": "role",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/servers.serverResponse"
}
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "string"
}
},
"403": {
"description": "organization required",
"schema": {
"type": "string"
}
},
"500": {
"description": "failed to list servers",
"schema": {
"type": "string"
}
}
}
},
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Creates a server bound to the org in X-Org-ID. Validates that ssh_key_id belongs to the org.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"servers"
],
"summary": "Create server (org scoped)",
"parameters": [
{
"type": "string",
"description": "Organization UUID",
"name": "X-Org-ID",
"in": "header",
"required": true
},
{
"description": "Server payload",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/servers.createServerRequest"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/servers.serverResponse"
}
},
"400": {
"description": "invalid json / missing fields / invalid status / invalid ssh_key_id",
"schema": {
"type": "string"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "string"
}
},
"403": {
"description": "organization required",
"schema": {
"type": "string"
}
},
"500": {
"description": "create failed",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/servers/{id}": {
"get": {
"security": [
{
"BearerAuth": []
}
],
"description": "Returns one server in the given organization.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"servers"
],
"summary": "Get server by ID (org scoped)",
"parameters": [
{
"type": "string",
"description": "Organization UUID",
"name": "X-Org-ID",
"in": "header",
"required": true
},
{
"type": "string",
"description": "Server ID (UUID)",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/servers.serverResponse"
}
},
"400": {
"description": "invalid id",
"schema": {
"type": "string"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "string"
}
},
"403": {
"description": "organization required",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
},
"500": {
"description": "fetch failed",
"schema": {
"type": "string"
}
}
}
},
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "Permanently deletes the server.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"servers"
],
"summary": "Delete server (org scoped)",
"parameters": [
{
"type": "string",
"description": "Organization UUID",
"name": "X-Org-ID",
"in": "header",
"required": true
},
{
"type": "string",
"description": "Server ID (UUID)",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content",
"schema": {
"type": "string"
}
},
"400": {
"description": "invalid id",
"schema": {
"type": "string"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "string"
}
},
"403": {
"description": "organization required",
"schema": {
"type": "string"
}
},
"500": {
"description": "delete failed",
"schema": {
"type": "string"
}
}
}
},
"patch": {
"security": [
{
"BearerAuth": []
}
],
"description": "Partially update fields; changing ssh_key_id validates ownership.",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"servers"
],
"summary": "Update server (org scoped)",
"parameters": [
{
"type": "string",
"description": "Organization UUID",
"name": "X-Org-ID",
"in": "header",
"required": true
},
{
"type": "string",
"description": "Server ID (UUID)",
"name": "id",
"in": "path",
"required": true
},
{
"description": "Fields to update",
"name": "body",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/servers.updateServerRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/servers.serverResponse"
}
},
"400": {
"description": "invalid id / invalid json / invalid status / invalid ssh_key_id",
"schema": {
"type": "string"
}
},
"401": {
"description": "Unauthorized",
"schema": {
"type": "string"
}
},
"403": {
"description": "organization required",
"schema": {
"type": "string"
}
},
"404": {
"description": "not found",
"schema": {
"type": "string"
}
},
"500": {
"description": "update failed",
"schema": {
"type": "string"
}
}
}
}
},
"/api/v1/ssh": {
"get": {
"security": [
@@ -1840,6 +2199,92 @@
}
}
},
"servers.createServerRequest": {
"type": "object",
"properties": {
"hostname": {
"type": "string"
},
"ip_address": {
"type": "string"
},
"role": {
"type": "string",
"example": "master|worker|bastion"
},
"ssh_key_id": {
"type": "string"
},
"ssh_user": {
"type": "string"
},
"status": {
"type": "string",
"example": "pending|provisioning|ready|failed"
}
}
},
"servers.serverResponse": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"hostname": {
"type": "string"
},
"id": {
"type": "string"
},
"ip_address": {
"type": "string"
},
"organization_id": {
"type": "string"
},
"role": {
"type": "string"
},
"ssh_key_id": {
"type": "string"
},
"ssh_user": {
"type": "string"
},
"status": {
"type": "string"
},
"updated_at": {
"type": "string"
}
}
},
"servers.updateServerRequest": {
"type": "object",
"properties": {
"hostname": {
"type": "string"
},
"ip_address": {
"type": "string"
},
"role": {
"type": "string",
"example": "master|worker|bastion"
},
"ssh_key_id": {
"type": "string"
},
"ssh_user": {
"type": "string"
},
"status": {
"description": "enum: pending,provisioning,ready,failed",
"type": "string",
"example": "pending|provisioning|ready|failed"
}
}
},
"ssh.createSSHRequest": {
"type": "object",
"properties": {

View File

@@ -243,6 +243,64 @@ definitions:
slug:
type: string
type: object
servers.createServerRequest:
properties:
hostname:
type: string
ip_address:
type: string
role:
example: master|worker|bastion
type: string
ssh_key_id:
type: string
ssh_user:
type: string
status:
example: pending|provisioning|ready|failed
type: string
type: object
servers.serverResponse:
properties:
created_at:
type: string
hostname:
type: string
id:
type: string
ip_address:
type: string
organization_id:
type: string
role:
type: string
ssh_key_id:
type: string
ssh_user:
type: string
status:
type: string
updated_at:
type: string
type: object
servers.updateServerRequest:
properties:
hostname:
type: string
ip_address:
type: string
role:
example: master|worker|bastion
type: string
ssh_key_id:
type: string
ssh_user:
type: string
status:
description: 'enum: pending,provisioning,ready,failed'
example: pending|provisioning|ready|failed
type: string
type: object
ssh.createSSHRequest:
properties:
bits:
@@ -1003,6 +1061,241 @@ paths:
summary: Remove member from organization
tags:
- organizations
/api/v1/servers:
get:
consumes:
- application/json
description: 'Returns servers for the organization in X-Org-ID. Optional filters:
status, role.'
parameters:
- description: Organization UUID
in: header
name: X-Org-ID
required: true
type: string
- description: Filter by status (pending|provisioning|ready|failed)
in: query
name: status
type: string
- description: Filter by role
in: query
name: role
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/servers.serverResponse'
type: array
"401":
description: Unauthorized
schema:
type: string
"403":
description: organization required
schema:
type: string
"500":
description: failed to list servers
schema:
type: string
security:
- BearerAuth: []
summary: List servers (org scoped)
tags:
- servers
post:
consumes:
- application/json
description: Creates a server bound to the org in X-Org-ID. Validates that ssh_key_id
belongs to the org.
parameters:
- description: Organization UUID
in: header
name: X-Org-ID
required: true
type: string
- description: Server payload
in: body
name: body
required: true
schema:
$ref: '#/definitions/servers.createServerRequest'
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/servers.serverResponse'
"400":
description: invalid json / missing fields / invalid status / invalid ssh_key_id
schema:
type: string
"401":
description: Unauthorized
schema:
type: string
"403":
description: organization required
schema:
type: string
"500":
description: create failed
schema:
type: string
security:
- BearerAuth: []
summary: Create server (org scoped)
tags:
- servers
/api/v1/servers/{id}:
delete:
consumes:
- application/json
description: Permanently deletes the server.
parameters:
- description: Organization UUID
in: header
name: X-Org-ID
required: true
type: string
- description: Server ID (UUID)
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"204":
description: No Content
schema:
type: string
"400":
description: invalid id
schema:
type: string
"401":
description: Unauthorized
schema:
type: string
"403":
description: organization required
schema:
type: string
"500":
description: delete failed
schema:
type: string
security:
- BearerAuth: []
summary: Delete server (org scoped)
tags:
- servers
get:
consumes:
- application/json
description: Returns one server in the given organization.
parameters:
- description: Organization UUID
in: header
name: X-Org-ID
required: true
type: string
- description: Server ID (UUID)
in: path
name: id
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/servers.serverResponse'
"400":
description: invalid id
schema:
type: string
"401":
description: Unauthorized
schema:
type: string
"403":
description: organization required
schema:
type: string
"404":
description: not found
schema:
type: string
"500":
description: fetch failed
schema:
type: string
security:
- BearerAuth: []
summary: Get server by ID (org scoped)
tags:
- servers
patch:
consumes:
- application/json
description: Partially update fields; changing ssh_key_id validates ownership.
parameters:
- description: Organization UUID
in: header
name: X-Org-ID
required: true
type: string
- description: Server ID (UUID)
in: path
name: id
required: true
type: string
- description: Fields to update
in: body
name: body
required: true
schema:
$ref: '#/definitions/servers.updateServerRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/servers.serverResponse'
"400":
description: invalid id / invalid json / invalid status / invalid ssh_key_id
schema:
type: string
"401":
description: Unauthorized
schema:
type: string
"403":
description: organization required
schema:
type: string
"404":
description: not found
schema:
type: string
"500":
description: update failed
schema:
type: string
security:
- BearerAuth: []
summary: Update server (org scoped)
tags:
- servers
/api/v1/ssh:
get:
consumes:

View File

@@ -7,6 +7,7 @@ import (
"github.com/glueops/autoglue/internal/handlers/authn"
"github.com/glueops/autoglue/internal/handlers/health"
"github.com/glueops/autoglue/internal/handlers/orgs"
"github.com/glueops/autoglue/internal/handlers/servers"
"github.com/glueops/autoglue/internal/handlers/ssh"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/ui"
@@ -69,6 +70,16 @@ func RegisterRoutes(r chi.Router) {
s.Delete("/{id}", ssh.DeleteSSHKey)
s.Get("/{id}/download", ssh.DownloadSSHKey)
})
v1.Route("/servers", func(s chi.Router) {
s.Use(authMW)
s.Get("/", servers.ListServers)
s.Post("/", servers.CreateServer)
s.Get("/{id}", servers.GetServer)
s.Patch("/{id}", servers.UpdateServer)
s.Delete("/{id}", servers.DeleteServer)
})
})
})

View File

@@ -35,6 +35,7 @@ func Connect() {
&models.OrganizationKey{},
&models.PasswordReset{},
&models.RefreshToken{},
&models.Server{},
&models.SshKey{},
&models.User{},
)

View File

@@ -0,0 +1,17 @@
package models
import "github.com/google/uuid"
type Server 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"`
Hostname string `json:"hostname"`
IPAddress string `gorm:"not null"`
SSHUser string `gorm:"not null"`
SshKeyID uuid.UUID `gorm:"type:uuid;not null"`
SshKey SshKey `gorm:"foreignKey:SshKeyID"`
Role string `gorm:"not null"` // e.g., "master", "worker", "bastion"
Status string `gorm:"default:'pending'"` // pending, provisioning, ready, failed
Timestamped
}

View File

@@ -0,0 +1,35 @@
package servers
import "github.com/google/uuid"
type createServerRequest struct {
Hostname string `json:"hostname,omitempty"`
IPAddress string `json:"ip_address"`
SSHUser string `json:"ssh_user"`
SshKeyID string `json:"ssh_key_id"`
Role string `json:"role" example:"master|worker|bastion"`
Status string `json:"status,omitempty" example:"pending|provisioning|ready|failed"`
}
type updateServerRequest struct {
Hostname *string `json:"hostname,omitempty"`
IPAddress *string `json:"ip_address,omitempty"`
SSHUser *string `json:"ssh_user,omitempty"`
SshKeyID *string `json:"ssh_key_id,omitempty"`
Role *string `json:"role,omitempty" example:"master|worker|bastion"`
// enum: pending,provisioning,ready,failed
Status *string `json:"status,omitempty" example:"pending|provisioning|ready|failed"`
}
type serverResponse struct {
ID uuid.UUID `json:"id"`
OrganizationID uuid.UUID `json:"organization_id"`
Hostname string `json:"hostname"`
IPAddress string `json:"ip_address"`
SSHUser string `json:"ssh_user"`
SshKeyID uuid.UUID `json:"ssh_key_id"`
Role string `json:"role"`
Status string `json:"status"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}

View File

@@ -0,0 +1,47 @@
package servers
import (
"errors"
"strings"
"time"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/google/uuid"
"gorm.io/gorm"
)
func toResponse(s models.Server) serverResponse {
return serverResponse{
ID: s.ID,
OrganizationID: s.OrganizationID,
Hostname: s.Hostname,
IPAddress: s.IPAddress,
SSHUser: s.SSHUser,
SshKeyID: s.SshKeyID,
Role: s.Role,
Status: s.Status,
CreatedAt: s.CreatedAt.UTC().Format(time.RFC3339),
UpdatedAt: s.UpdatedAt.UTC().Format(time.RFC3339),
}
}
func validStatus(s string) bool {
switch strings.ToLower(s) {
case "pending", "provisioning", "ready", "failed", "":
return true
default:
return false
}
}
func ensureKeyBelongsToOrg(orgID uuid.UUID, keyID uuid.UUID) error {
var k models.SshKey
if err := db.DB.Where("id = ? AND organization_id = ?", keyID, orgID).First(&k).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errors.New("ssh key not found for this organization")
}
return err
}
return nil
}

View File

@@ -0,0 +1,296 @@
package servers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/glueops/autoglue/internal/db"
"github.com/glueops/autoglue/internal/db/models"
"github.com/glueops/autoglue/internal/middleware"
"github.com/glueops/autoglue/internal/response"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
// ListServers godoc
// @Summary List servers (org scoped)
// @Description Returns servers for the organization in X-Org-ID. Optional filters: status, role.
// @Tags servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param status query string false "Filter by status (pending|provisioning|ready|failed)"
// @Param role query string false "Filter by role"
// @Security BearerAuth
// @Success 200 {array} serverResponse
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "failed to list servers"
// @Router /api/v1/servers [get]
func ListServers(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
q := db.DB.Where("organization_id = ?", ac.OrganizationID)
if s := strings.TrimSpace(r.URL.Query().Get("status")); s != "" {
if !validStatus(s) {
http.Error(w, "invalid status", http.StatusBadRequest)
return
}
q = q.Where("status = ?", strings.ToLower(s))
}
if role := strings.TrimSpace(r.URL.Query().Get("role")); role != "" {
q = q.Where("role = ?", role)
}
var rows []models.Server
if err := q.Order("created_at DESC").Find(&rows).Error; err != nil {
http.Error(w, "failed to list servers", http.StatusInternalServerError)
return
}
out := make([]serverResponse, 0, len(rows))
for _, s := range rows {
out = append(out, toResponse(s))
}
_ = response.JSON(w, http.StatusOK, out)
}
// GetServer godoc
// @Summary Get server by ID (org scoped)
// @Description Returns one server in the given organization.
// @Tags servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Security BearerAuth
// @Success 200 {object} serverResponse
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "fetch failed"
// @Router /api/v1/servers/{id} [get]
func GetServer(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var s models.Server
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).First(&s).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, toResponse(s))
}
// CreateServer godoc
// @Summary Create server (org scoped)
// @Description Creates a server bound to the org in X-Org-ID. Validates that ssh_key_id belongs to the org.
// @Tags servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param body body createServerRequest true "Server payload"
// @Security BearerAuth
// @Success 201 {object} serverResponse
// @Failure 400 {string} string "invalid json / missing fields / invalid status / invalid ssh_key_id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "create failed"
// @Router /api/v1/servers [post]
func CreateServer(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
var req createServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if req.IPAddress == "" || req.SSHUser == "" || req.SshKeyID == "" || req.Role == "" {
http.Error(w, "ip_address, ssh_user, ssh_key_id and role are required", http.StatusBadRequest)
return
}
if req.Status != "" && !validStatus(req.Status) {
http.Error(w, "invalid status", http.StatusBadRequest)
return
}
keyID, err := uuid.Parse(req.SshKeyID)
if err != nil {
http.Error(w, "invalid ssh_key_id", http.StatusBadRequest)
return
}
if err := ensureKeyBelongsToOrg(ac.OrganizationID, keyID); err != nil {
http.Error(w, "invalid or unauthorized ssh_key_id", http.StatusBadRequest)
return
}
s := models.Server{
OrganizationID: ac.OrganizationID,
Hostname: req.Hostname,
IPAddress: req.IPAddress,
SSHUser: req.SSHUser,
SshKeyID: keyID,
Role: req.Role,
Status: "pending",
}
if req.Status != "" {
s.Status = strings.ToLower(req.Status)
}
if err := db.DB.Create(&s).Error; err != nil {
http.Error(w, "create failed", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusCreated, toResponse(s))
}
// UpdateServer godoc
// @Summary Update server (org scoped)
// @Description Partially update fields; changing ssh_key_id validates ownership.
// @Tags servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Param body body updateServerRequest true "Fields to update"
// @Security BearerAuth
// @Success 200 {object} serverResponse
// @Failure 400 {string} string "invalid id / invalid json / invalid status / invalid ssh_key_id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 404 {string} string "not found"
// @Failure 500 {string} string "update failed"
// @Router /api/v1/servers/{id} [patch]
func UpdateServer(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
var s models.Server
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).First(&s).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
http.Error(w, "not found", http.StatusNotFound)
return
}
http.Error(w, "fetch failed", http.StatusInternalServerError)
return
}
var req updateServerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "invalid json", http.StatusBadRequest)
return
}
if req.Hostname != nil {
s.Hostname = *req.Hostname
}
if req.IPAddress != nil {
s.IPAddress = *req.IPAddress
}
if req.SSHUser != nil {
s.SSHUser = *req.SSHUser
}
if req.Role != nil {
s.Role = *req.Role
}
if req.Status != nil {
if !validStatus(*req.Status) {
http.Error(w, "invalid status", http.StatusBadRequest)
return
}
s.Status = strings.ToLower(*req.Status)
}
if req.SshKeyID != nil {
keyID, err := uuid.Parse(*req.SshKeyID)
if err != nil {
http.Error(w, "invalid ssh_key_id", http.StatusBadRequest)
return
}
if err := ensureKeyBelongsToOrg(ac.OrganizationID, keyID); err != nil {
http.Error(w, "invalid or unauthorized ssh_key_id", http.StatusBadRequest)
return
}
s.SshKeyID = keyID
}
if err := db.DB.Save(&s).Error; err != nil {
http.Error(w, "update failed", http.StatusInternalServerError)
return
}
_ = response.JSON(w, http.StatusOK, toResponse(s))
}
// DeleteServer godoc
// @Summary Delete server (org scoped)
// @Description Permanently deletes the server.
// @Tags servers
// @Accept json
// @Produce json
// @Param X-Org-ID header string true "Organization UUID"
// @Param id path string true "Server ID (UUID)"
// @Security BearerAuth
// @Success 204 {string} string "No Content"
// @Failure 400 {string} string "invalid id"
// @Failure 401 {string} string "Unauthorized"
// @Failure 403 {string} string "organization required"
// @Failure 500 {string} string "delete failed"
// @Router /api/v1/servers/{id} [delete]
func DeleteServer(w http.ResponseWriter, r *http.Request) {
ac := middleware.GetAuthContext(r)
if ac == nil || ac.OrganizationID == uuid.Nil {
http.Error(w, "organization required", http.StatusForbidden)
return
}
id, err := uuid.Parse(chi.URLParam(r, "id"))
if err != nil {
http.Error(w, "invalid id", http.StatusBadRequest)
return
}
if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).
Delete(&models.Server{}).Error; err != nil {
http.Error(w, "delete failed", http.StatusInternalServerError)
return
}
response.NoContent(w)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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,4 +1,4 @@
import{r as i}from"./vendor-Cnbx_Mrt.js";function Zt(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}/**
import{r as i}from"./vendor-D1z0LlOQ.js";function Zt(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}/**
* react-router v7.8.2
*
* Copyright (c) Remix Software Inc.

File diff suppressed because one or more lines are too long

View File

@@ -1,16 +1,16 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AutoGlue</title>
<script type="module" crossorigin src="/assets/index-YQeQnKJK.js"></script>
<link rel="modulepreload" crossorigin href="/assets/router-CyXg69m3.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-Cnbx_Mrt.js">
<link rel="modulepreload" crossorigin href="/assets/radix-DN_DrUzo.js">
<link rel="modulepreload" crossorigin href="/assets/icons-CHRYRpwL.js">
<link rel="stylesheet" crossorigin href="/assets/index-CJrhsj7s.css">
<script type="module" crossorigin src="/assets/index-C5NwS5VO.js"></script>
<link rel="modulepreload" crossorigin href="/assets/router-CcA--AgE.js">
<link rel="modulepreload" crossorigin href="/assets/vendor-D1z0LlOQ.js">
<link rel="modulepreload" crossorigin href="/assets/radix-9eRs70j8.js">
<link rel="modulepreload" crossorigin href="/assets/icons-BROtNQ6N.js">
<link rel="stylesheet" crossorigin href="/assets/index-DXA6UWYz.css">
</head>
<body>
<div id="root"></div>

View File

@@ -10,11 +10,12 @@ import { Me } from "@/pages/auth/me.tsx"
import { Register } from "@/pages/auth/register.tsx"
import { ResetPassword } from "@/pages/auth/reset-password.tsx"
import { VerifyEmail } from "@/pages/auth/verify-email.tsx"
import { ServersPage } from "@/pages/core/servers-page.tsx"
import { Forbidden } from "@/pages/error/forbidden.tsx"
import { NotFoundPage } from "@/pages/error/not-found.tsx"
import { SshKeysPage } from "@/pages/security/ssh.tsx"
import { MemberManagement } from "@/pages/settings/members.tsx"
import { OrgManagement } from "@/pages/settings/orgs.tsx"
import {SshKeysPage} from "@/pages/security/ssh.tsx";
function App() {
return (
@@ -39,10 +40,11 @@ function App() {
</Route>
<Route path="/core">
<Route path="servers" element={<ServersPage />} />
{/*
<Route path="cluster" element={<ClusterListPage />} />
<Route path="node-pools" element={<NodePoolsPage />} />
<Route path="servers" element={<ServersPage />} />
<Route path="taints" element={<TaintsPage />} />
*/}
</Route>

View File

@@ -0,0 +1,699 @@
// src/pages/core/servers-page.tsx
import { useEffect, useMemo, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { Pencil, Plus, RefreshCw, Search, Trash } from "lucide-react"
import { useForm } from "react-hook-form"
import { z } from "zod"
import { api } from "@/lib/api.ts"
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu.tsx"
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form.tsx"
import { Input } from "@/components/ui/input.tsx"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select.tsx"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table.tsx"
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip.tsx"
type Server = {
id: string
hostname?: string | null
ip_address: string
role: string
ssh_key_id: string
ssh_user: string
status: "pending" | "provisioning" | "ready" | "failed" | string
organization_id: string
created_at: string
updated_at: string
}
type SshKey = {
id: string
name?: string | null
public_keys: string
fingerprint: string
created_at: string
updated_at: string
organization_id: string
}
const STATUS = ["pending", "provisioning", "ready", "failed"] as const
type Status = (typeof STATUS)[number]
const ROLE_OPTIONS = ["master", "worker", "bastion"] as const
type Role = (typeof ROLE_OPTIONS)[number]
const CreateServerSchema = z.object({
hostname: z.string().trim().max(120, "Max 120 chars").optional(),
ip_address: z.string().trim().min(1, "IP address is required"),
role: z.enum(ROLE_OPTIONS),
ssh_key_id: z.string().uuid("Pick a valid SSH key"),
ssh_user: z.string().trim().min(1, "SSH user is required"),
status: z.enum(STATUS).default("pending"),
})
type CreateServerInput = z.input<typeof CreateServerSchema>
type CreateServerValues = z.output<typeof CreateServerSchema>
const UpdateServerSchema = CreateServerSchema.partial()
type UpdateServerValues = z.infer<typeof UpdateServerSchema>
function StatusBadge({ status }: { status: string }) {
const v =
status === "ready"
? "default"
: status === "provisioning"
? "secondary"
: status === "failed"
? "destructive"
: "outline"
return (
<Badge variant={v as any} className="capitalize">
{status}
</Badge>
)
}
function truncateMiddle(str: string, keep = 16) {
if (!str || str.length <= keep * 2 + 3) return str
return `${str.slice(0, keep)}${str.slice(-keep)}`
}
export const ServersPage = () => {
const [servers, setServers] = useState<Server[]>([])
const [sshKeys, setSshKeys] = useState<SshKey[]>([])
const [loading, setLoading] = useState(false)
const [err, setErr] = useState<string | null>(null)
const [q, setQ] = useState("")
const [statusFilter, setStatusFilter] = useState<Status | "">("")
const [roleFilter, setRoleFilter] = useState<Role | "">("")
const [createOpen, setCreateOpen] = useState(false)
const [editTarget, setEditTarget] = useState<Server | null>(null)
function buildServersURL() {
const qs = new URLSearchParams()
if (statusFilter) qs.set("status", statusFilter)
if (roleFilter) qs.set("role", roleFilter)
const suffix = qs.toString() ? `?${qs.toString()}` : ""
return `/api/v1/servers${suffix}`
}
async function loadAll() {
setLoading(true)
setErr(null)
try {
// NOTE: your api.get<T> returns parsed JSON directly
const [srv, keys] = await Promise.all([
api.get<Server[]>(buildServersURL()),
api.get<SshKey[]>("/api/v1/ssh"),
])
setServers(srv ?? [])
setSshKeys(keys ?? [])
} catch (e) {
console.error(e)
setErr("Failed to load servers or SSH keys")
} finally {
setLoading(false)
}
}
useEffect(() => {
loadAll()
const onStorage = (e: StorageEvent) => {
if (e.key === "active_org_id") loadAll()
}
window.addEventListener("storage", onStorage)
return () => window.removeEventListener("storage", onStorage)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
loadAll()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [statusFilter, roleFilter])
const keyById = useMemo(() => {
const m = new Map<string, SshKey>()
sshKeys.forEach((k) => m.set(k.id, k))
return m
}, [sshKeys])
const filtered = useMemo(() => {
const needle = q.trim().toLowerCase()
if (!needle) return servers
return servers.filter(
(s) =>
(s.hostname ?? "").toLowerCase().includes(needle) ||
s.ip_address.toLowerCase().includes(needle) ||
s.role.toLowerCase().includes(needle) ||
s.ssh_user.toLowerCase().includes(needle)
)
}, [servers, q])
async function deleteServer(id: string) {
if (!confirm("Delete this server? This cannot be undone.")) return
await api.delete<void>(`/api/v1/servers/${encodeURIComponent(id)}`)
await loadAll()
}
const createForm = useForm<CreateServerInput, any, CreateServerValues>({
resolver: zodResolver(CreateServerSchema),
defaultValues: {
hostname: "",
ip_address: "",
role: "worker",
ssh_key_id: "",
ssh_user: "ubuntu",
status: "pending",
},
})
const submitCreate = async (values: CreateServerValues) => {
const payload: Record<string, any> = {
ip_address: values.ip_address.trim(),
role: values.role,
ssh_key_id: values.ssh_key_id,
ssh_user: values.ssh_user.trim(),
status: values.status,
}
if (values.hostname && values.hostname.trim()) {
payload.hostname = values.hostname.trim()
}
await api.post<Server>("/api/v1/servers", payload)
setCreateOpen(false)
createForm.reset()
await loadAll()
}
const editForm = useForm<UpdateServerValues>({
resolver: zodResolver(UpdateServerSchema),
defaultValues: {},
})
function openEdit(s: Server) {
setEditTarget(s)
editForm.reset({
hostname: s.hostname ?? "",
ip_address: s.ip_address,
role: (ROLE_OPTIONS.includes(s.role as Role) ? (s.role as Role) : "worker") as any,
ssh_key_id: s.ssh_key_id,
ssh_user: s.ssh_user,
status: (STATUS.includes(s.status as Status) ? (s.status as Status) : "pending") as any,
})
}
const submitEdit = async (values: UpdateServerValues) => {
if (!editTarget) return
const payload: Record<string, any> = {}
if (values.hostname !== undefined) payload.hostname = values.hostname?.trim() || ""
if (values.ip_address !== undefined) payload.ip_address = values.ip_address.trim()
if (values.role !== undefined) payload.role = values.role
if (values.ssh_key_id !== undefined) payload.ssh_key_id = values.ssh_key_id
if (values.ssh_user !== undefined) payload.ssh_user = values.ssh_user.trim()
if (values.status !== undefined) payload.status = values.status
await api.patch<Server>(`/api/v1/servers/${encodeURIComponent(editTarget.id)}`, payload)
setEditTarget(null)
await loadAll()
}
if (loading) return <div className="p-6">Loading servers</div>
if (err) return <div className="p-6 text-red-500">{err}</div>
return (
<TooltipProvider>
<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="mb-4 text-2xl font-bold">Servers</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={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Search hostname, IP, role, user…"
className="w-64 pl-8"
/>
</div>
<Select value={roleFilter} onValueChange={(v) => setRoleFilter(v as Role | "")}>
<SelectTrigger className="w-36">
<SelectValue placeholder="Role (all)" />
</SelectTrigger>
<SelectContent>
{/* <SelectItem value="">All roles</SelectItem> */}
{ROLE_OPTIONS.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={(v) => setStatusFilter(v as Status | "")}>
<SelectTrigger className="w-40">
<SelectValue placeholder="Status (all)" />
</SelectTrigger>
<SelectContent>
{/* <SelectItem value="">All status</SelectItem> */}
{STATUS.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="outline" onClick={loadAll}>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogTrigger asChild>
<Button onClick={() => setCreateOpen(true)}>
<Plus className="mr-2 h-4 w-4" />
Create Server
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Create server</DialogTitle>
</DialogHeader>
<Form {...createForm}>
<form onSubmit={createForm.handleSubmit(submitCreate)} className="space-y-4">
<FormField
control={createForm.control}
name="hostname"
render={({ field }) => (
<FormItem>
<FormLabel>Hostname</FormLabel>
<FormControl>
<Input placeholder="worker-01" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="ip_address"
render={({ field }) => (
<FormItem>
<FormLabel>IP address</FormLabel>
<FormControl>
<Input placeholder="10.0.1.23" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={createForm.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ROLE_OPTIONS.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="ssh_user"
render={({ field }) => (
<FormItem>
<FormLabel>SSH user</FormLabel>
<FormControl>
<Input placeholder="ubuntu" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={createForm.control}
name="ssh_key_id"
render={({ field }) => (
<FormItem>
<FormLabel>SSH key</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={
sshKeys.length ? "Select SSH key" : "No SSH keys found"
}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{sshKeys.map((k) => (
<SelectItem key={k.id} value={k.id}>
{k.name ? k.name : "Unnamed key"} {" "}
{truncateMiddle(k.fingerprint, 8)}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={createForm.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Initial status</FormLabel>
<Select onValueChange={field.onChange} value={field.value}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="pending" />
</SelectTrigger>
</FormControl>
<SelectContent>
{STATUS.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setCreateOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={createForm.formState.isSubmitting}>
{createForm.formState.isSubmitting ? "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>Hostname</TableHead>
<TableHead>IP address</TableHead>
<TableHead>Role</TableHead>
<TableHead>SSH user</TableHead>
<TableHead>SSH key</TableHead>
<TableHead>Status</TableHead>
<TableHead>Created</TableHead>
<TableHead className="w-[180px] text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filtered.map((s) => {
const key = keyById.get(s.ssh_key_id)
return (
<TableRow key={s.id}>
<TableCell className="font-medium">{s.hostname || "—"}</TableCell>
<TableCell>
<code className="font-mono text-sm">{s.ip_address}</code>
</TableCell>
<TableCell className="capitalize">{s.role}</TableCell>
<TableCell>
<code className="font-mono text-sm">{s.ssh_user}</code>
</TableCell>
<TableCell>
{key ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex items-center gap-2">
<Badge variant="secondary">{key.name || "SSH key"}</Badge>
<code className="font-mono text-xs">
{truncateMiddle(key.fingerprint, 8)}
</code>
</span>
</TooltipTrigger>
<TooltipContent className="max-w-[70vw]">
<p className="font-mono text-xs break-all">{key.public_keys}</p>
</TooltipContent>
</Tooltip>
) : (
<span className="text-muted-foreground">Unknown</span>
)}
</TableCell>
<TableCell>
<StatusBadge status={s.status} />
</TableCell>
<TableCell>{new Date(s.created_at).toLocaleString()}</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<Button variant="outline" size="sm" onClick={() => openEdit(s)}>
<Pencil className="mr-2 h-4 w-4" /> Edit
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="destructive" size="sm">
<Trash className="mr-2 h-4 w-4" /> Delete
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => deleteServer(s.id)}>
Confirm delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
)
})}
{filtered.length === 0 && (
<TableRow>
<TableCell colSpan={8} className="text-muted-foreground py-10 text-center">
No servers match your filters.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
</div>
{/* Edit dialog */}
<Dialog open={!!editTarget} onOpenChange={(o) => !o && setEditTarget(null)}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Edit server</DialogTitle>
</DialogHeader>
<Form {...editForm}>
<form onSubmit={editForm.handleSubmit(submitEdit)} className="space-y-4">
<FormField
control={editForm.control}
name="hostname"
render={({ field }) => (
<FormItem>
<FormLabel>Hostname</FormLabel>
<FormControl>
<Input placeholder="worker-01" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="ip_address"
render={({ field }) => (
<FormItem>
<FormLabel>IP address</FormLabel>
<FormControl>
<Input placeholder="10.0.1.23" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<FormField
control={editForm.control}
name="role"
render={({ field }) => (
<FormItem>
<FormLabel>Role</FormLabel>
<Select onValueChange={field.onChange} value={field.value as any}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="Select role" />
</SelectTrigger>
</FormControl>
<SelectContent>
{ROLE_OPTIONS.map((r) => (
<SelectItem key={r} value={r}>
{r}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="ssh_user"
render={({ field }) => (
<FormItem>
<FormLabel>SSH user</FormLabel>
<FormControl>
<Input placeholder="ubuntu" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
<FormField
control={editForm.control}
name="ssh_key_id"
render={({ field }) => (
<FormItem>
<FormLabel>SSH key</FormLabel>
<Select onValueChange={field.onChange} value={field.value as any}>
<FormControl>
<SelectTrigger>
<SelectValue
placeholder={sshKeys.length ? "Select SSH key" : "No SSH keys found"}
/>
</SelectTrigger>
</FormControl>
<SelectContent>
{sshKeys.map((k) => (
<SelectItem key={k.id} value={k.id}>
{k.name ? k.name : "SSH key"} {truncateMiddle(k.fingerprint, 8)}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={editForm.control}
name="status"
render={({ field }) => (
<FormItem>
<FormLabel>Status</FormLabel>
<Select onValueChange={field.onChange} value={field.value as any}>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="pending" />
</SelectTrigger>
</FormControl>
<SelectContent>
{STATUS.map((s) => (
<SelectItem key={s} value={s}>
{s}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" onClick={() => setEditTarget(null)}>
Cancel
</Button>
<Button type="submit" disabled={editForm.formState.isSubmitting}>
{editForm.formState.isSubmitting ? "Saving…" : "Save changes"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</TooltipProvider>
)
}