From d831e911fde46ee78d4d501276b408751289966d Mon Sep 17 00:00:00 2001 From: allanice001 Date: Wed, 3 Sep 2025 23:10:23 +0100 Subject: [PATCH] annotations api and page --- docs/docs.go | 691 +++++++++++++++++++ docs/swagger.json | 691 +++++++++++++++++++ docs/swagger.yaml | 453 ++++++++++++ internal/api/routes.go | 13 + internal/handlers/annotations/annotations.go | 558 +++++++++++++++ ui/src/pages/core/annotations-page.tsx | 632 ++++++++++++++++- 6 files changed, 3030 insertions(+), 8 deletions(-) diff --git a/docs/docs.go b/docs/docs.go index 7129607..fce082e 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -282,6 +282,627 @@ const docTemplate = `{ } } }, + "/api/v1/annotations": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns annotations for the organization in X-Org-ID. Filters: ` + "`" + `name` + "`" + `, ` + "`" + `value` + "`" + `, and ` + "`" + `q` + "`" + ` (name contains). Add ` + "`" + `include=node_pools` + "`" + ` to include linked node pools.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "annotations" + ], + "summary": "List annotations (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Exact name", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Exact value", + "name": "value", + "in": "query" + }, + { + "type": "string", + "description": "name contains (case-insensitive)", + "name": "q", + "in": "query" + }, + { + "type": "string", + "description": "Optional: node_pools", + "name": "include", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/annotations.annotationResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to list annotations", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates an annotation. Optionally link to node pools.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "annotations" + ], + "summary": "Create annotation (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "description": "Annotation payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/annotations.createAnnotationRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/annotations.annotationResponse" + } + }, + "400": { + "description": "invalid json / missing fields / invalid node_pool_ids", + "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/annotations/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns one annotation. Add ` + "`" + `include=node_pools` + "`" + ` to include node pools.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "annotations" + ], + "summary": "Get annotation by ID (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Annotation ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Optional: node_pools", + "name": "include", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/annotations.annotationResponse" + } + }, + "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 annotation.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "annotations" + ], + "summary": "Delete annotation (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Annotation 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 annotation fields.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "annotations" + ], + "summary": "Update annotation (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Annotation ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Fields to update", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/annotations.updateAnnotationRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/annotations.annotationResponse" + } + }, + "400": { + "description": "invalid id / invalid json", + "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/annotations/{id}/node_pools": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns node pools attached to the annotation. Supports ` + "`" + `q` + "`" + ` (name contains, case-insensitive).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "annotations" + ], + "summary": "List node pools linked to an annotation (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Annotation ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Name contains (case-insensitive)", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/annotations.nodePoolBrief" + } + } + }, + "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" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Links the annotation to one or more node pools in the same organization.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "annotations" + ], + "summary": "Attach annotation to node pools (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Annotation ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "IDs to attach", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/annotations.addAnnotationToNodePool" + } + }, + { + "type": "string", + "description": "Optional: node_pools", + "name": "include", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/annotations.annotationResponse" + } + }, + "400": { + "description": "invalid id / invalid json / invalid node_pool_ids", + "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": "attach failed", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/annotations/{id}/node_pools/{poolId}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Unlinks the annotation from the specified node pool.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "annotations" + ], + "summary": "Detach annotation from a node pool (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Annotation 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" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "detach failed", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/auth/introspect": { "post": { "description": "Returns whether the token is active and basic metadata", @@ -4172,6 +4793,76 @@ const docTemplate = `{ } }, "definitions": { + "annotations.addAnnotationToNodePool": { + "type": "object", + "properties": { + "node_pool_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "annotations.annotationResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_pools": { + "type": "array", + "items": { + "$ref": "#/definitions/annotations.nodePoolBrief" + } + }, + "value": { + "type": "string" + } + } + }, + "annotations.createAnnotationRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "node_pool_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "value": { + "type": "string" + } + } + }, + "annotations.nodePoolBrief": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "annotations.updateAnnotationRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "authn.AdminCreateUserRequest": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index cc4aeab..4609747 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -278,6 +278,627 @@ } } }, + "/api/v1/annotations": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns annotations for the organization in X-Org-ID. Filters: `name`, `value`, and `q` (name contains). Add `include=node_pools` to include linked node pools.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "annotations" + ], + "summary": "List annotations (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Exact name", + "name": "name", + "in": "query" + }, + { + "type": "string", + "description": "Exact value", + "name": "value", + "in": "query" + }, + { + "type": "string", + "description": "name contains (case-insensitive)", + "name": "q", + "in": "query" + }, + { + "type": "string", + "description": "Optional: node_pools", + "name": "include", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/annotations.annotationResponse" + } + } + }, + "401": { + "description": "Unauthorized", + "schema": { + "type": "string" + } + }, + "403": { + "description": "organization required", + "schema": { + "type": "string" + } + }, + "500": { + "description": "failed to list annotations", + "schema": { + "type": "string" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Creates an annotation. Optionally link to node pools.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "annotations" + ], + "summary": "Create annotation (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "description": "Annotation payload", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/annotations.createAnnotationRequest" + } + } + ], + "responses": { + "201": { + "description": "Created", + "schema": { + "$ref": "#/definitions/annotations.annotationResponse" + } + }, + "400": { + "description": "invalid json / missing fields / invalid node_pool_ids", + "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/annotations/{id}": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns one annotation. Add `include=node_pools` to include node pools.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "annotations" + ], + "summary": "Get annotation by ID (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Annotation ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Optional: node_pools", + "name": "include", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/annotations.annotationResponse" + } + }, + "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 annotation.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "annotations" + ], + "summary": "Delete annotation (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Annotation 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 annotation fields.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "annotations" + ], + "summary": "Update annotation (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Annotation ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "Fields to update", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/annotations.updateAnnotationRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/annotations.annotationResponse" + } + }, + "400": { + "description": "invalid id / invalid json", + "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/annotations/{id}/node_pools": { + "get": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Returns node pools attached to the annotation. Supports `q` (name contains, case-insensitive).", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "annotations" + ], + "summary": "List node pools linked to an annotation (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Annotation ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Name contains (case-insensitive)", + "name": "q", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "type": "array", + "items": { + "$ref": "#/definitions/annotations.nodePoolBrief" + } + } + }, + "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" + } + } + } + }, + "post": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Links the annotation to one or more node pools in the same organization.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "annotations" + ], + "summary": "Attach annotation to node pools (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Annotation ID (UUID)", + "name": "id", + "in": "path", + "required": true + }, + { + "description": "IDs to attach", + "name": "body", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/annotations.addAnnotationToNodePool" + } + }, + { + "type": "string", + "description": "Optional: node_pools", + "name": "include", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/annotations.annotationResponse" + } + }, + "400": { + "description": "invalid id / invalid json / invalid node_pool_ids", + "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": "attach failed", + "schema": { + "type": "string" + } + } + } + } + }, + "/api/v1/annotations/{id}/node_pools/{poolId}": { + "delete": { + "security": [ + { + "BearerAuth": [] + } + ], + "description": "Unlinks the annotation from the specified node pool.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "annotations" + ], + "summary": "Detach annotation from a node pool (org scoped)", + "parameters": [ + { + "type": "string", + "description": "Organization UUID", + "name": "X-Org-ID", + "in": "header", + "required": true + }, + { + "type": "string", + "description": "Annotation 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" + } + }, + "404": { + "description": "not found", + "schema": { + "type": "string" + } + }, + "500": { + "description": "detach failed", + "schema": { + "type": "string" + } + } + } + } + }, "/api/v1/auth/introspect": { "post": { "description": "Returns whether the token is active and basic metadata", @@ -4168,6 +4789,76 @@ } }, "definitions": { + "annotations.addAnnotationToNodePool": { + "type": "object", + "properties": { + "node_pool_ids": { + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "annotations.annotationResponse": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "node_pools": { + "type": "array", + "items": { + "$ref": "#/definitions/annotations.nodePoolBrief" + } + }, + "value": { + "type": "string" + } + } + }, + "annotations.createAnnotationRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "node_pool_ids": { + "type": "array", + "items": { + "type": "string" + } + }, + "value": { + "type": "string" + } + } + }, + "annotations.nodePoolBrief": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "annotations.updateAnnotationRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "value": { + "type": "string" + } + } + }, "authn.AdminCreateUserRequest": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index a45e17e..f52bf77 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -1,5 +1,50 @@ basePath: / definitions: + annotations.addAnnotationToNodePool: + properties: + node_pool_ids: + items: + type: string + type: array + type: object + annotations.annotationResponse: + properties: + id: + type: string + name: + type: string + node_pools: + items: + $ref: '#/definitions/annotations.nodePoolBrief' + type: array + value: + type: string + type: object + annotations.createAnnotationRequest: + properties: + name: + type: string + node_pool_ids: + items: + type: string + type: array + value: + type: string + type: object + annotations.nodePoolBrief: + properties: + id: + type: string + name: + type: string + type: object + annotations.updateAnnotationRequest: + properties: + name: + type: string + value: + type: string + type: object authn.AdminCreateUserRequest: properties: email: @@ -726,6 +771,414 @@ paths: summary: 'Admin: update user' tags: - admin + /api/v1/annotations: + get: + consumes: + - application/json + description: 'Returns annotations for the organization in X-Org-ID. Filters: + `name`, `value`, and `q` (name contains). Add `include=node_pools` to include + linked node pools.' + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Exact name + in: query + name: name + type: string + - description: Exact value + in: query + name: value + type: string + - description: name contains (case-insensitive) + in: query + name: q + type: string + - description: 'Optional: node_pools' + in: query + name: include + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/annotations.annotationResponse' + type: array + "401": + description: Unauthorized + schema: + type: string + "403": + description: organization required + schema: + type: string + "500": + description: failed to list annotations + schema: + type: string + security: + - BearerAuth: [] + summary: List annotations (org scoped) + tags: + - annotations + post: + consumes: + - application/json + description: Creates an annotation. Optionally link to node pools. + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Annotation payload + in: body + name: body + required: true + schema: + $ref: '#/definitions/annotations.createAnnotationRequest' + produces: + - application/json + responses: + "201": + description: Created + schema: + $ref: '#/definitions/annotations.annotationResponse' + "400": + description: invalid json / missing fields / invalid node_pool_ids + 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 annotation (org scoped) + tags: + - annotations + /api/v1/annotations/{id}: + delete: + consumes: + - application/json + description: Permanently deletes the annotation. + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Annotation 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 annotation (org scoped) + tags: + - annotations + get: + consumes: + - application/json + description: Returns one annotation. Add `include=node_pools` to include node + pools. + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Annotation ID (UUID) + in: path + name: id + required: true + type: string + - description: 'Optional: node_pools' + in: query + name: include + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/annotations.annotationResponse' + "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 annotation by ID (org scoped) + tags: + - annotations + patch: + consumes: + - application/json + description: Partially update annotation fields. + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Annotation ID (UUID) + in: path + name: id + required: true + type: string + - description: Fields to update + in: body + name: body + required: true + schema: + $ref: '#/definitions/annotations.updateAnnotationRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/annotations.annotationResponse' + "400": + description: invalid id / invalid json + 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 annotation (org scoped) + tags: + - annotations + /api/v1/annotations/{id}/node_pools: + get: + consumes: + - application/json + description: Returns node pools attached to the annotation. Supports `q` (name + contains, case-insensitive). + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Annotation ID (UUID) + in: path + name: id + required: true + type: string + - description: Name contains (case-insensitive) + in: query + name: q + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + items: + $ref: '#/definitions/annotations.nodePoolBrief' + type: array + "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: List node pools linked to an annotation (org scoped) + tags: + - annotations + post: + consumes: + - application/json + description: Links the annotation to one or more node pools in the same organization. + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Annotation ID (UUID) + in: path + name: id + required: true + type: string + - description: IDs to attach + in: body + name: body + required: true + schema: + $ref: '#/definitions/annotations.addAnnotationToNodePool' + - description: 'Optional: node_pools' + in: query + name: include + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/annotations.annotationResponse' + "400": + description: invalid id / invalid json / invalid node_pool_ids + 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: attach failed + schema: + type: string + security: + - BearerAuth: [] + summary: Attach annotation to node pools (org scoped) + tags: + - annotations + /api/v1/annotations/{id}/node_pools/{poolId}: + delete: + consumes: + - application/json + description: Unlinks the annotation from the specified node pool. + parameters: + - description: Organization UUID + in: header + name: X-Org-ID + required: true + type: string + - description: Annotation 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 + "404": + description: not found + schema: + type: string + "500": + description: detach failed + schema: + type: string + security: + - BearerAuth: [] + summary: Detach annotation from a node pool (org scoped) + tags: + - annotations /api/v1/auth/introspect: post: consumes: diff --git a/internal/api/routes.go b/internal/api/routes.go index 0dffbaf..63df585 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -4,6 +4,7 @@ import ( httpPprof "net/http/pprof" "github.com/glueops/autoglue/internal/config" + "github.com/glueops/autoglue/internal/handlers/annotations" "github.com/glueops/autoglue/internal/handlers/authn" "github.com/glueops/autoglue/internal/handlers/health" "github.com/glueops/autoglue/internal/handlers/labels" @@ -54,6 +55,18 @@ func RegisterRoutes(r chi.Router) { }) }) + v1.Route("/annotations", func(a chi.Router) { + a.Use(authMW) + a.Get("/", annotations.ListAnnotations) + a.Post("/", annotations.CreateAnnotation) + a.Get("/{id}", annotations.GetAnnotation) + a.Patch("/{id}", annotations.UpdateAnnotation) + a.Delete("/{id}", annotations.DeleteAnnotation) + a.Get("/{id}/node_pools", annotations.ListNodePoolsWithAnnotation) + a.Post("/{id}/node_pools", annotations.AddAnnotationToNodePools) + a.Delete("/{id}/node_pools/{poolId}", annotations.RemoveAnnotationFromNodePool) + }) + v1.Route("/orgs", func(o chi.Router) { o.Use(authMW) o.Post("/", orgs.CreateOrganization) diff --git a/internal/handlers/annotations/annotations.go b/internal/handlers/annotations/annotations.go index 5d7f7ec..88afa4d 100644 --- a/internal/handlers/annotations/annotations.go +++ b/internal/handlers/annotations/annotations.go @@ -1 +1,559 @@ package annotations + +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" +) + +/* ------------------------------- DTOs ----------------------------------- */ + +type nodePoolBrief struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` +} + +type annotationResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Value string `json:"value"` + NodePools []nodePoolBrief `json:"node_pools,omitempty"` +} + +type createAnnotationRequest struct { + Name string `json:"name"` + Value string `json:"value"` + NodePoolIDs []string `json:"node_pool_ids"` +} + +type updateAnnotationRequest struct { + Name *string `json:"name,omitempty"` + Value *string `json:"value,omitempty"` +} + +type addAnnotationToNodePool struct { + NodePoolIDs []string `json:"node_pool_ids"` +} + +/* ------------------------------- Helpers -------------------------------- */ + +func toResp(a models.Annotation, includePools bool) annotationResponse { + out := annotationResponse{ + ID: a.ID, + Name: a.Name, + Value: a.Value, + } + if includePools { + for _, p := range a.NodePools { + out.NodePools = append(out.NodePools, nodePoolBrief{ID: p.ID, Name: p.Name}) + } + } + return out +} + +func parseUUIDs(in []string) ([]uuid.UUID, error) { + out := make([]uuid.UUID, 0, len(in)) + for _, s := range in { + id, err := uuid.Parse(s) + if err != nil { + return nil, err + } + out = append(out, id) + } + return out, nil +} + +func ensureNodePoolsBelongToOrg(orgID uuid.UUID, ids []uuid.UUID) error { + if len(ids) == 0 { + return nil + } + var count int64 + if err := db.DB.Model(&models.NodePool{}). + Where("id IN ? AND organization_id = ?", ids, orgID). + Count(&count).Error; err != nil { + return err + } + if count != int64(len(ids)) { + return errors.New("one or more node pools do not belong to organization") + } + return nil +} + +/* -------------------------------- Routes -------------------------------- */ + +// ListAnnotations godoc +// @Summary List annotations (org scoped) +// @Description Returns annotations for the organization in X-Org-ID. Filters: `name`, `value`, and `q` (name contains). Add `include=node_pools` to include linked node pools. +// @Tags annotations +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param name query string false "Exact name" +// @Param value query string false "Exact value" +// @Param q query string false "name contains (case-insensitive)" +// @Param include query string false "Optional: node_pools" +// @Security BearerAuth +// @Success 200 {array} annotationResponse +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 500 {string} string "failed to list annotations" +// @Router /api/v1/annotations [get] +func ListAnnotations(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 name := strings.TrimSpace(r.URL.Query().Get("name")); name != "" { + q = q.Where(`name = ?`, name) + } + if val := strings.TrimSpace(r.URL.Query().Get("value")); val != "" { + q = q.Where(`value = ?`, val) + } + if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" { + q = q.Where(`name ILIKE ?`, "%"+needle+"%") + } + + includePools := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools") + if includePools { + q = q.Preload("NodePools") + } + + var rows []models.Annotation + if err := q.Order("created_at DESC").Find(&rows).Error; err != nil { + http.Error(w, "failed to list annotations", http.StatusInternalServerError) + return + } + + out := make([]annotationResponse, 0, len(rows)) + for _, a := range rows { + out = append(out, toResp(a, includePools)) + } + _ = response.JSON(w, http.StatusOK, out) +} + +// GetAnnotation godoc +// @Summary Get annotation by ID (org scoped) +// @Description Returns one annotation. Add `include=node_pools` to include node pools. +// @Tags annotations +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Annotation ID (UUID)" +// @Param include query string false "Optional: node_pools" +// @Security BearerAuth +// @Success 200 {object} annotationResponse +// @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/annotations/{id} [get] +func GetAnnotation(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 + } + + includePools := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools") + + var a models.Annotation + q := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID) + if includePools { + q = q.Preload("NodePools") + } + + if err := q.First(&a).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, toResp(a, includePools)) +} + +// CreateAnnotation godoc +// @Summary Create annotation (org scoped) +// @Description Creates an annotation. Optionally link to node pools. +// @Tags annotations +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param body body createAnnotationRequest true "Annotation payload" +// @Security BearerAuth +// @Success 201 {object} annotationResponse +// @Failure 400 {string} string "invalid json / missing fields / invalid node_pool_ids" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 500 {string} string "create failed" +// @Router /api/v1/annotations [post] +func CreateAnnotation(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 createAnnotationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Value) == "" { + http.Error(w, "invalid json or missing name/value", http.StatusBadRequest) + return + } + + a := models.Annotation{ + OrganizationID: ac.OrganizationID, + Name: strings.TrimSpace(req.Name), + Value: strings.TrimSpace(req.Value), + } + + if err := db.DB.Create(&a).Error; err != nil { + http.Error(w, "create failed", http.StatusInternalServerError) + return + } + + if len(req.NodePoolIDs) > 0 { + ids, err := parseUUIDs(req.NodePoolIDs) + if err != nil { + http.Error(w, "invalid node_pool_ids", http.StatusBadRequest) + return + } + if err := ensureNodePoolsBelongToOrg(ac.OrganizationID, ids); err != nil { + http.Error(w, "invalid node_pool_ids for this organization", http.StatusBadRequest) + return + } + var pools []models.NodePool + if err := db.DB.Where("id IN ? AND organization_id = ?", ids, ac.OrganizationID).Find(&pools).Error; err != nil { + http.Error(w, "attach failed", http.StatusInternalServerError) + return + } + if err := db.DB.Model(&a).Association("NodePools").Append(&pools); err != nil { + http.Error(w, "attach failed", http.StatusInternalServerError) + return + } + } + + _ = response.JSON(w, http.StatusCreated, toResp(a, false)) +} + +// UpdateAnnotation godoc +// @Summary Update annotation (org scoped) +// @Description Partially update annotation fields. +// @Tags annotations +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Annotation ID (UUID)" +// @Param body body updateAnnotationRequest true "Fields to update" +// @Security BearerAuth +// @Success 200 {object} annotationResponse +// @Failure 400 {string} string "invalid id / invalid json" +// @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/annotations/{id} [patch] +func UpdateAnnotation(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 a models.Annotation + if err := db.DB.Where("id = ? AND organization_id = ?", id, ac.OrganizationID).First(&a).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 updateAnnotationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid json", http.StatusBadRequest) + return + } + if req.Name != nil { + a.Name = strings.TrimSpace(*req.Name) + } + if req.Value != nil { + a.Value = strings.TrimSpace(*req.Value) + } + + if err := db.DB.Save(&a).Error; err != nil { + http.Error(w, "update failed", http.StatusInternalServerError) + return + } + _ = response.JSON(w, http.StatusOK, toResp(a, false)) +} + +// DeleteAnnotation godoc +// @Summary Delete annotation (org scoped) +// @Description Permanently deletes the annotation. +// @Tags annotations +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Annotation 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/annotations/{id} [delete] +func DeleteAnnotation(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.Annotation{}).Error; err != nil { + http.Error(w, "delete failed", http.StatusInternalServerError) + return + } + + response.NoContent(w) +} + +// AddAnnotationToNodePools godoc +// @Summary Attach annotation to node pools (org scoped) +// @Description Links the annotation to one or more node pools in the same organization. +// @Tags annotations +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Annotation ID (UUID)" +// @Param body body addAnnotationToNodePool true "IDs to attach" +// @Param include query string false "Optional: node_pools" +// @Security BearerAuth +// @Success 200 {object} annotationResponse +// @Failure 400 {string} string "invalid id / invalid json / invalid node_pool_ids" +// @Failure 401 {string} string "Unauthorized" +// @Failure 403 {string} string "organization required" +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "attach failed" +// @Router /api/v1/annotations/{id}/node_pools [post] +func AddAnnotationToNodePools(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 + } + + annID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var a models.Annotation + if err := db.DB.Where("id = ? AND organization_id = ?", annID, ac.OrganizationID).First(&a).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 in struct { + NodePoolIDs []string `json:"node_pool_ids"` + } + if err := json.NewDecoder(r.Body).Decode(&in); err != nil || len(in.NodePoolIDs) == 0 { + http.Error(w, "invalid json or empty node_pool_ids", http.StatusBadRequest) + return + } + + ids, err := parseUUIDs(in.NodePoolIDs) + if err != nil { + http.Error(w, "invalid node_pool_ids", http.StatusBadRequest) + return + } + if err := ensureNodePoolsBelongToOrg(ac.OrganizationID, ids); err != nil { + http.Error(w, "invalid node_pool_ids for this organization", http.StatusBadRequest) + return + } + + var pools []models.NodePool + if err := db.DB.Where("id IN ? AND organization_id = ?", ids, ac.OrganizationID).Find(&pools).Error; err != nil { + http.Error(w, "attach failed", http.StatusInternalServerError) + return + } + if err := db.DB.Model(&a).Association("NodePools").Append(&pools); err != nil { + http.Error(w, "attach failed", http.StatusInternalServerError) + return + } + + includePools := strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("include")), "node_pools") + if includePools { + if err := db.DB.Preload("NodePools").First(&a, "id = ? AND organization_id = ?", annID, ac.OrganizationID).Error; err != nil { + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + } + + _ = response.JSON(w, http.StatusOK, toResp(a, includePools)) +} + +// RemoveAnnotationFromNodePool godoc +// @Summary Detach annotation from a node pool (org scoped) +// @Description Unlinks the annotation from the specified node pool. +// @Tags annotations +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Annotation ID (UUID)" +// / @Param poolId path string true "Node Pool 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 404 {string} string "not found" +// @Failure 500 {string} string "detach failed" +// @Router /api/v1/annotations/{id}/node_pools/{poolId} [delete] +func RemoveAnnotationFromNodePool(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 + } + + annID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + poolID, err := uuid.Parse(chi.URLParam(r, "poolId")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + var a models.Annotation + if err := db.DB.Where("id = ? AND organization_id = ?", annID, ac.OrganizationID).First(&a).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 p models.NodePool + if err := db.DB.Where("id = ? AND organization_id = ?", poolID, ac.OrganizationID).First(&p).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 + } + + if err := db.DB.Model(&a).Association("NodePools").Delete(&p); err != nil { + http.Error(w, "detach failed", http.StatusInternalServerError) + return + } + + response.NoContent(w) +} + +// ListNodePoolsWithAnnotation godoc +// @Summary List node pools linked to an annotation (org scoped) +// @Description Returns node pools attached to the annotation. Supports `q` (name contains, case-insensitive). +// @Tags annotations +// @Accept json +// @Produce json +// @Param X-Org-ID header string true "Organization UUID" +// @Param id path string true "Annotation ID (UUID)" +// @Param q query string false "Name contains (case-insensitive)" +// @Security BearerAuth +// @Success 200 {array} nodePoolBrief +// @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/annotations/{id}/node_pools [get] +func ListNodePoolsWithAnnotation(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 + } + + annID, err := uuid.Parse(chi.URLParam(r, "id")) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + + // Ensure the annotation exists within this org + var a models.Annotation + if err := db.DB.Where("id = ? AND organization_id = ?", annID, ac.OrganizationID).First(&a).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 + } + + // Find pools joined via the M2M table "node_annotations" + q := db.DB.Model(&models.NodePool{}). + Joins("JOIN node_annotations na ON na.node_pool_id = node_pools.id"). + Where("na.annotation_id = ? AND node_pools.organization_id = ?", annID, ac.OrganizationID) + + if needle := strings.TrimSpace(r.URL.Query().Get("q")); needle != "" { + q = q.Where("node_pools.name ILIKE ?", "%"+needle+"%") + } + + var pools []models.NodePool + if err := q.Order("node_pools.created_at DESC").Find(&pools).Error; err != nil { + http.Error(w, "fetch failed", http.StatusInternalServerError) + return + } + + out := make([]nodePoolBrief, 0, len(pools)) + for _, p := range pools { + out = append(out, nodePoolBrief{ID: p.ID, Name: p.Name}) + } + + _ = response.JSON(w, http.StatusOK, out) +} diff --git a/ui/src/pages/core/annotations-page.tsx b/ui/src/pages/core/annotations-page.tsx index 65c52bb..3151068 100644 --- a/ui/src/pages/core/annotations-page.tsx +++ b/ui/src/pages/core/annotations-page.tsx @@ -1,9 +1,625 @@ -export const AnnotationsPage = () => { - return ( -
-
-

Annotations

-
-
- ) +import { useEffect, useMemo, useState } from "react" +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { + LinkIcon, + Pencil, + Plus, + RefreshCw, + Search, + Trash, + UnlinkIcon, + ServerIcon, +} from "lucide-react" + +import { api, ApiError } from "@/lib/api" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" + +/* ----------------------------- Types & Schemas ---------------------------- */ + +type NodePoolBrief = { + id: string + name: string +} + +type Annotation = { + id: string + name: string + value: string + node_pools?: NodePoolBrief[] +} + +const CreateSchema = z.object({ + name: z.string().trim().min(1, "Name is required").max(120, "Max 120 chars"), + value: z.string().trim().min(1, "Value is required").max(512, "Max 512 chars"), + node_pool_ids: z.array(z.string().uuid()).optional().default([]), +}) +type CreateInput = z.input +type CreateValues = z.output + +const UpdateSchema = z.object({ + name: z.string().trim().min(1, "Name is required").max(120, "Max 120 chars"), + value: z.string().trim().min(1, "Value is required").max(512, "Max 512 chars"), +}) +type UpdateValues = z.output + +const AttachPoolsSchema = z.object({ + node_pool_ids: z.array(z.string().uuid()).min(1, "Pick at least one node pool"), +}) +type AttachPoolsValues = z.output + +/* --------------------------------- Utils --------------------------------- */ + +function truncateMiddle(str: string, keep = 12) { + if (!str || str.length <= keep * 2 + 3) return str + return `${str.slice(0, keep)}…${str.slice(-keep)}` +} + +/* --------------------------------- Page ---------------------------------- */ + +export const AnnotationsPage = () => { + const [loading, setLoading] = useState(true) + const [err, setErr] = useState(null) + + const [annotations, setAnnotations] = useState([]) + const [allPools, setAllPools] = useState([]) + + const [q, setQ] = useState("") + + // Dialog state + const [createOpen, setCreateOpen] = useState(false) + const [editTarget, setEditTarget] = useState(null) + const [managePoolsTarget, setManagePoolsTarget] = useState(null) + + // Attached pools (for manage dialog) + const [attachedPools, setAttachedPools] = useState([]) + const [attachedLoading, setAttachedLoading] = useState(false) + const [attachedErr, setAttachedErr] = useState(null) + + /* ------------------------------- Data Load ------------------------------ */ + + async function loadAll() { + setLoading(true) + setErr(null) + try { + const [ann, pools] = await Promise.all([ + api.get("/api/v1/annotations?include=node_pools"), + api.get("/api/v1/node-pools"), + ]) + setAnnotations(ann || []) + setAllPools(pools || []) + + // keep dialog targets in sync + if (editTarget) { + const refreshed = (ann || []).find((a) => a.id === editTarget.id) || null + setEditTarget(refreshed) + } + if (managePoolsTarget) { + const refreshed = (ann || []).find((a) => a.id === managePoolsTarget.id) || null + setManagePoolsTarget(refreshed) + if (refreshed) { + void loadAttachedPools(refreshed.id) + } + } + } catch (e) { + console.error(e) + const msg = + e instanceof ApiError ? e.message : "Failed to load annotations / node pools" + setErr(msg) + } finally { + setLoading(false) + } + } + + async function loadAttachedPools(annotationId: string) { + setAttachedLoading(true) + setAttachedErr(null) + try { + const data = await api.get( + `/api/v1/annotations/${annotationId}/node_pools` + ) + setAttachedPools(data || []) + } catch (e) { + console.error(e) + const msg = + e instanceof ApiError ? e.message : "Failed to load pools for annotation" + setAttachedErr(msg) + } finally { + setAttachedLoading(false) + } + } + + useEffect(() => { + void loadAll() + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + /* -------------------------------- Filters ------------------------------- */ + + const filtered = useMemo(() => { + const needle = q.trim().toLowerCase() + if (!needle) return annotations + return annotations.filter( + (a) => + a.name.toLowerCase().includes(needle) || + a.value.toLowerCase().includes(needle) || + (a.node_pools || []).some((p) => p.name.toLowerCase().includes(needle)) + ) + }, [annotations, q]) + + /* ------------------------------ Mutations ------------------------------- */ + + async function deleteAnnotation(id: string) { + if (!confirm("Delete this annotation? This cannot be undone.")) return + await api.delete(`/api/v1/annotations/${id}`) + await loadAll() + } + + // Create + const createForm = useForm({ + resolver: zodResolver(CreateSchema), + defaultValues: { name: "", value: "", node_pool_ids: [] }, + }) + + const submitCreate = async (values: CreateValues) => { + const payload: any = { name: values.name.trim(), value: values.value.trim() } + if (values.node_pool_ids && values.node_pool_ids.length > 0) { + payload.node_pool_ids = values.node_pool_ids + } + await api.post("/api/v1/annotations", payload) + setCreateOpen(false) + createForm.reset({ name: "", value: "", node_pool_ids: [] }) + await loadAll() + } + + // Edit + const editForm = useForm({ + resolver: zodResolver(UpdateSchema), + defaultValues: { name: "", value: "" }, + }) + + function openEdit(a: Annotation) { + setEditTarget(a) + editForm.reset({ name: a.name, value: a.value }) + } + + const submitEdit = async (values: UpdateValues) => { + if (!editTarget) return + await api.patch(`/api/v1/annotations/${editTarget.id}`, { + name: values.name.trim(), + value: values.value.trim(), + }) + setEditTarget(null) + await loadAll() + } + + // Manage pools (attach/detach) + const attachPoolsForm = useForm({ + resolver: zodResolver(AttachPoolsSchema), + defaultValues: { node_pool_ids: [] }, + }) + + function openManagePools(a: Annotation) { + setManagePoolsTarget(a) + attachPoolsForm.reset({ node_pool_ids: [] }) + void loadAttachedPools(a.id) + } + + const submitAttachPools = async (values: AttachPoolsValues) => { + if (!managePoolsTarget) return + await api.post(`/api/v1/annotations/${managePoolsTarget.id}/node_pools`, { + node_pool_ids: values.node_pool_ids, + }) + attachPoolsForm.reset({ node_pool_ids: [] }) + await loadAttachedPools(managePoolsTarget.id) + await loadAll() + } + + async function detachPool(poolId: string) { + if (!managePoolsTarget) return + if (!confirm("Detach this node pool from the annotation?")) return + await api.delete(`/api/v1/annotations/${managePoolsTarget.id}/node_pools/${poolId}`) + await loadAttachedPools(managePoolsTarget.id) + await loadAll() + } + + /* --------------------------------- Render -------------------------------- */ + + if (loading) return
Loading annotations…
+ if (err) return
{err}
+ + return ( +
+
+

Annotations

+ +
+
+ + setQ(e.target.value)} + placeholder="Search name, value, pool…" + className="w-72 pl-8" + /> +
+ + + + + + + + + + Create annotation + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Value + + + + + + )} + /> + ( + + Initial node pools (optional) +
+ {allPools.length === 0 && ( +
No node pools available
+ )} + {allPools.map((p) => { + const checked = field.value?.includes(p.id) || false + return ( + + ) + })} +
+ +
+ )} + /> + + + + + + + +
+
+
+
+ +
+
+ + + + Name + Value + Node Pools + Actions + + + + {filtered.map((a) => { + const pools = a.node_pools || [] + return ( + + {a.name} + {a.value} + + +
+ {pools.slice(0, 6).map((p) => ( + + {p.name} + + ))} + {pools.length === 0 && ( + No node pools + )} + {pools.length > 6 && ( + +{pools.length - 6} more + )} +
+ +
+ + +
+ + + + + + + deleteAnnotation(a.id)}> + Confirm delete + + + +
+
+
+ ) + })} + + {filtered.length === 0 && ( + + + No annotations match your search. + + + )} +
+
+
+
+ + {/* Edit dialog */} + !o && setEditTarget(null)}> + + + Edit annotation + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Value + + + + + + )} + /> + + + + + + + +
+
+ + {/* Manage node pools dialog */} + !o && setManagePoolsTarget(null)}> + + + + Manage node pools for{" "} + {managePoolsTarget?.name} + + + + {/* Attached pools list */} +
+
Attached node pools
+ + {attachedLoading ? ( +
Loading…
+ ) : attachedErr ? ( +
{attachedErr}
+ ) : ( +
+ + + + Name + Detach + + + + {attachedPools.map((p) => ( + + {p.name} + +
+ +
+
+
+ ))} + {attachedPools.length === 0 && ( + + + No node pools attached yet. + + + )} +
+
+
+ )} +
+ + {/* Attach pools */} +
+
+ + ( + + Attach more node pools +
+ {(() => { + const attachedIds = new Set(attachedPools.map((p) => p.id)) + const attachable = allPools.filter((p) => !attachedIds.has(p.id)) + if (attachable.length === 0) { + return ( +
+ No more node pools available to attach +
+ ) + } + return attachable.map((p) => { + const checked = field.value?.includes(p.id) || false + return ( + + ) + }) + })()} +
+ +
+ )} + /> + + + + + + +
+
+
+
+ ) }