mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 21:00:06 +01:00
Servers Page & API
This commit is contained in:
445
docs/docs.go
445
docs/docs.go
@@ -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": {
|
"/api/v1/ssh": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"ssh.createSSHRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -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": {
|
"/api/v1/ssh": {
|
||||||
"get": {
|
"get": {
|
||||||
"security": [
|
"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": {
|
"ssh.createSSHRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -243,6 +243,64 @@ definitions:
|
|||||||
slug:
|
slug:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
ssh.createSSHRequest:
|
||||||
properties:
|
properties:
|
||||||
bits:
|
bits:
|
||||||
@@ -1003,6 +1061,241 @@ paths:
|
|||||||
summary: Remove member from organization
|
summary: Remove member from organization
|
||||||
tags:
|
tags:
|
||||||
- organizations
|
- 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:
|
/api/v1/ssh:
|
||||||
get:
|
get:
|
||||||
consumes:
|
consumes:
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"github.com/glueops/autoglue/internal/handlers/authn"
|
"github.com/glueops/autoglue/internal/handlers/authn"
|
||||||
"github.com/glueops/autoglue/internal/handlers/health"
|
"github.com/glueops/autoglue/internal/handlers/health"
|
||||||
"github.com/glueops/autoglue/internal/handlers/orgs"
|
"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/handlers/ssh"
|
||||||
"github.com/glueops/autoglue/internal/middleware"
|
"github.com/glueops/autoglue/internal/middleware"
|
||||||
"github.com/glueops/autoglue/internal/ui"
|
"github.com/glueops/autoglue/internal/ui"
|
||||||
@@ -69,6 +70,16 @@ func RegisterRoutes(r chi.Router) {
|
|||||||
s.Delete("/{id}", ssh.DeleteSSHKey)
|
s.Delete("/{id}", ssh.DeleteSSHKey)
|
||||||
s.Get("/{id}/download", ssh.DownloadSSHKey)
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ func Connect() {
|
|||||||
&models.OrganizationKey{},
|
&models.OrganizationKey{},
|
||||||
&models.PasswordReset{},
|
&models.PasswordReset{},
|
||||||
&models.RefreshToken{},
|
&models.RefreshToken{},
|
||||||
|
&models.Server{},
|
||||||
&models.SshKey{},
|
&models.SshKey{},
|
||||||
&models.User{},
|
&models.User{},
|
||||||
)
|
)
|
||||||
|
|||||||
17
internal/db/models/server.go
Normal file
17
internal/db/models/server.go
Normal 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
|
||||||
|
}
|
||||||
35
internal/handlers/servers/dto.go
Normal file
35
internal/handlers/servers/dto.go
Normal 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"`
|
||||||
|
}
|
||||||
47
internal/handlers/servers/funcs.go
Normal file
47
internal/handlers/servers/funcs.go
Normal 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
|
||||||
|
}
|
||||||
296
internal/handlers/servers/servers.go
Normal file
296
internal/handlers/servers/servers.go
Normal 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
1
internal/ui/dist/assets/index-C5NwS5VO.js
vendored
Normal file
1
internal/ui/dist/assets/index-C5NwS5VO.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
internal/ui/dist/assets/index-CJrhsj7s.css
vendored
1
internal/ui/dist/assets/index-CJrhsj7s.css
vendored
File diff suppressed because one or more lines are too long
1
internal/ui/dist/assets/index-DXA6UWYz.css
vendored
Normal file
1
internal/ui/dist/assets/index-DXA6UWYz.css
vendored
Normal file
File diff suppressed because one or more lines are too long
1
internal/ui/dist/assets/index-YQeQnKJK.js
vendored
1
internal/ui/dist/assets/index-YQeQnKJK.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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
|
* react-router v7.8.2
|
||||||
*
|
*
|
||||||
* Copyright (c) Remix Software Inc.
|
* Copyright (c) Remix Software Inc.
|
||||||
File diff suppressed because one or more lines are too long
14
internal/ui/dist/index.html
vendored
14
internal/ui/dist/index.html
vendored
@@ -1,16 +1,16 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>AutoGlue</title>
|
<title>AutoGlue</title>
|
||||||
<script type="module" crossorigin src="/assets/index-YQeQnKJK.js"></script>
|
<script type="module" crossorigin src="/assets/index-C5NwS5VO.js"></script>
|
||||||
<link rel="modulepreload" crossorigin href="/assets/router-CyXg69m3.js">
|
<link rel="modulepreload" crossorigin href="/assets/router-CcA--AgE.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/vendor-Cnbx_Mrt.js">
|
<link rel="modulepreload" crossorigin href="/assets/vendor-D1z0LlOQ.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/radix-DN_DrUzo.js">
|
<link rel="modulepreload" crossorigin href="/assets/radix-9eRs70j8.js">
|
||||||
<link rel="modulepreload" crossorigin href="/assets/icons-CHRYRpwL.js">
|
<link rel="modulepreload" crossorigin href="/assets/icons-BROtNQ6N.js">
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-CJrhsj7s.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DXA6UWYz.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -10,11 +10,12 @@ import { Me } from "@/pages/auth/me.tsx"
|
|||||||
import { Register } from "@/pages/auth/register.tsx"
|
import { Register } from "@/pages/auth/register.tsx"
|
||||||
import { ResetPassword } from "@/pages/auth/reset-password.tsx"
|
import { ResetPassword } from "@/pages/auth/reset-password.tsx"
|
||||||
import { VerifyEmail } from "@/pages/auth/verify-email.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 { Forbidden } from "@/pages/error/forbidden.tsx"
|
||||||
import { NotFoundPage } from "@/pages/error/not-found.tsx"
|
import { NotFoundPage } from "@/pages/error/not-found.tsx"
|
||||||
|
import { SshKeysPage } from "@/pages/security/ssh.tsx"
|
||||||
import { MemberManagement } from "@/pages/settings/members.tsx"
|
import { MemberManagement } from "@/pages/settings/members.tsx"
|
||||||
import { OrgManagement } from "@/pages/settings/orgs.tsx"
|
import { OrgManagement } from "@/pages/settings/orgs.tsx"
|
||||||
import {SshKeysPage} from "@/pages/security/ssh.tsx";
|
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@@ -39,10 +40,11 @@ function App() {
|
|||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="/core">
|
<Route path="/core">
|
||||||
|
<Route path="servers" element={<ServersPage />} />
|
||||||
{/*
|
{/*
|
||||||
<Route path="cluster" element={<ClusterListPage />} />
|
<Route path="cluster" element={<ClusterListPage />} />
|
||||||
<Route path="node-pools" element={<NodePoolsPage />} />
|
<Route path="node-pools" element={<NodePoolsPage />} />
|
||||||
<Route path="servers" element={<ServersPage />} />
|
|
||||||
<Route path="taints" element={<TaintsPage />} />
|
<Route path="taints" element={<TaintsPage />} />
|
||||||
*/}
|
*/}
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
699
ui/src/pages/core/servers-page.tsx
Normal file
699
ui/src/pages/core/servers-page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user