mirror of
https://github.com/GlueOps/autoglue.git
synced 2026-02-13 04:40:05 +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": {
|
||||
"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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ func Connect() {
|
||||
&models.OrganizationKey{},
|
||||
&models.PasswordReset{},
|
||||
&models.RefreshToken{},
|
||||
&models.Server{},
|
||||
&models.SshKey{},
|
||||
&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
|
||||
*
|
||||
* 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">
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
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