feat: status page authoring API
Adds owner|admin routes under /api/status-pages for authoring status pages and their incidents/maintenance windows, gated by the status_pages licence feature. Adds the "status" token scope resource and the ten route-scope entries, and regenerates the committed OpenAPI document. Also types ErrPageInvalid as a sentinel for status page/incident validation failures (previously bare errors), so statusPageError maps them to 400 instead of 500, and createStatusIncident/updateStatusIncident route through the shared error mapper rather than hand-rolling a 400 for any service error.
This commit is contained in:
@@ -453,6 +453,21 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"api.StatusIncidentUpdateRequest": {
|
||||
"properties": {
|
||||
"body": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"body",
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"api.StatusResponse": {
|
||||
"properties": {
|
||||
"status": {
|
||||
@@ -1601,6 +1616,170 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"models.StatusIncident": {
|
||||
"properties": {
|
||||
"_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"affected_monitors": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array",
|
||||
"uniqueItems": false
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"impact": {
|
||||
"type": "string"
|
||||
},
|
||||
"incident_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"instance_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"kind": {
|
||||
"type": "string"
|
||||
},
|
||||
"page_ids": {
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array",
|
||||
"uniqueItems": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheduled_end": {
|
||||
"type": "string"
|
||||
},
|
||||
"scheduled_start": {
|
||||
"type": "string"
|
||||
},
|
||||
"started_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"updates": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/models.StatusIncidentUpdate"
|
||||
},
|
||||
"type": "array",
|
||||
"uniqueItems": false
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"models.StatusIncidentUpdate": {
|
||||
"properties": {
|
||||
"at": {
|
||||
"type": "string"
|
||||
},
|
||||
"author": {
|
||||
"type": "string"
|
||||
},
|
||||
"body": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"models.StatusPage": {
|
||||
"properties": {
|
||||
"_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"banner": {
|
||||
"$ref": "#/components/schemas/models.StatusPageBanner"
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"instance_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"logo_url": {
|
||||
"type": "string"
|
||||
},
|
||||
"page_id": {
|
||||
"type": "string"
|
||||
},
|
||||
"published": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sections": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/models.StatusPageSection"
|
||||
},
|
||||
"type": "array",
|
||||
"uniqueItems": false
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"models.StatusPageBanner": {
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"level": {
|
||||
"type": "string"
|
||||
},
|
||||
"text": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"models.StatusPageEntry": {
|
||||
"properties": {
|
||||
"display_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"monitor_id": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"models.StatusPageSection": {
|
||||
"properties": {
|
||||
"entries": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/models.StatusPageEntry"
|
||||
},
|
||||
"type": "array",
|
||||
"uniqueItems": false
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"models.StepOverride": {
|
||||
"properties": {
|
||||
"script": {
|
||||
@@ -6871,6 +7050,593 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/status-pages": {
|
||||
"get": {
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/models.StatusPage"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/api.ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Internal Server Error"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "List status pages",
|
||||
"tags": [
|
||||
"status-pages"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/models.StatusPage",
|
||||
"summary": "body",
|
||||
"description": "Status page"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Status page",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/models.StatusPage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Created"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/api.ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"409": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/api.ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Conflict"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Create a status page",
|
||||
"tags": [
|
||||
"status-pages"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/status-pages/{pageId}": {
|
||||
"delete": {
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Page id",
|
||||
"in": "path",
|
||||
"name": "pageId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/api.ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Delete a status page",
|
||||
"tags": [
|
||||
"status-pages"
|
||||
]
|
||||
},
|
||||
"get": {
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Page id",
|
||||
"in": "path",
|
||||
"name": "pageId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/models.StatusPage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/api.ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Get a status page",
|
||||
"tags": [
|
||||
"status-pages"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Page id",
|
||||
"in": "path",
|
||||
"name": "pageId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/models.StatusPage",
|
||||
"summary": "body",
|
||||
"description": "Status page"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Status page",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/models.StatusPage"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/api.ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Update a status page",
|
||||
"tags": [
|
||||
"status-pages"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/status-pages/{pageId}/incidents": {
|
||||
"get": {
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Page id",
|
||||
"in": "path",
|
||||
"name": "pageId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/models.StatusIncident"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "List authored incidents for a status page",
|
||||
"tags": [
|
||||
"status-pages"
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Page id",
|
||||
"in": "path",
|
||||
"name": "pageId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/models.StatusIncident",
|
||||
"summary": "body",
|
||||
"description": "Incident"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Incident",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/models.StatusIncident"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Created"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/api.ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Bad Request"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Create an incident or maintenance window",
|
||||
"tags": [
|
||||
"status-pages"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/status-pages/{pageId}/incidents/{incidentId}": {
|
||||
"delete": {
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Page id",
|
||||
"in": "path",
|
||||
"name": "pageId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Incident id",
|
||||
"in": "path",
|
||||
"name": "incidentId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/api.ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Delete an incident or maintenance window",
|
||||
"tags": [
|
||||
"status-pages"
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Page id",
|
||||
"in": "path",
|
||||
"name": "pageId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Incident id",
|
||||
"in": "path",
|
||||
"name": "incidentId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/models.StatusIncident",
|
||||
"summary": "body",
|
||||
"description": "Incident"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Incident",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/models.StatusIncident"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/api.ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Update an incident or maintenance window",
|
||||
"tags": [
|
||||
"status-pages"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/status-pages/{pageId}/incidents/{incidentId}/updates": {
|
||||
"post": {
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Page id",
|
||||
"in": "path",
|
||||
"name": "pageId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"description": "Incident id",
|
||||
"in": "path",
|
||||
"name": "incidentId",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"oneOf": [
|
||||
{
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/api.StatusIncidentUpdateRequest",
|
||||
"summary": "body",
|
||||
"description": "Update"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Update",
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/models.StatusIncident"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "OK"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/api.ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Bad Request"
|
||||
},
|
||||
"404": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/api.ErrorResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Not Found"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"cookieAuth": []
|
||||
},
|
||||
{
|
||||
"bearerAuth": []
|
||||
}
|
||||
],
|
||||
"summary": "Post an update to an incident",
|
||||
"tags": [
|
||||
"status-pages"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/steps": {
|
||||
"get": {
|
||||
"responses": {
|
||||
|
||||
@@ -166,6 +166,8 @@ func RegisterRoutes(r *gin.Engine) {
|
||||
apiGroup.POST("/servers/:id/workloads/refresh", refreshServerWorkloads)
|
||||
apiGroup.POST("/servers/:id/workloads/:wid/action", auth.RequireRole("owner", "admin"), controlWorkload)
|
||||
apiGroup.GET("/servers/:id/workloads/:wid/logs", auth.RequireRole("owner", "admin"), getWorkloadLogs)
|
||||
|
||||
registerStatusPageRoutes(apiGroup)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -156,6 +156,19 @@ var routeScopes = map[string]string{
|
||||
"GET /api/openapi.json": "settings:read",
|
||||
"GET /api/docs": "settings:read",
|
||||
"GET /api/docs/scalar.js": "settings:read",
|
||||
|
||||
// Status pages. Reading is status:read even though the pages themselves
|
||||
// are public, because these routes read the unpublished ones too.
|
||||
"GET /api/status-pages": "status:read",
|
||||
"POST /api/status-pages": "status:write",
|
||||
"GET /api/status-pages/:pageId": "status:read",
|
||||
"PUT /api/status-pages/:pageId": "status:write",
|
||||
"DELETE /api/status-pages/:pageId": "status:write",
|
||||
"GET /api/status-pages/:pageId/incidents": "status:read",
|
||||
"POST /api/status-pages/:pageId/incidents": "status:write",
|
||||
"PUT /api/status-pages/:pageId/incidents/:incidentId": "status:write",
|
||||
"DELETE /api/status-pages/:pageId/incidents/:incidentId": "status:write",
|
||||
"POST /api/status-pages/:pageId/incidents/:incidentId/updates": "status:write",
|
||||
}
|
||||
|
||||
// RequireScopes enforces routeScopes for token-authenticated requests and does
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services"
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/shared/license"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func registerStatusPageRoutes(g *gin.RouterGroup) {
|
||||
// Owner or admin throughout: publishing a page is speaking to the public
|
||||
// in the instance's name. The feature gate sits alongside the role gate so
|
||||
// authoring and serving are gated by the same licence feature.
|
||||
sp := g.Group("/status-pages")
|
||||
sp.Use(auth.RequireRole("owner", "admin"), RequireFeature(license.FeatureStatusPages))
|
||||
|
||||
sp.GET("", listStatusPages)
|
||||
sp.POST("", createStatusPage)
|
||||
sp.GET("/:pageId", getStatusPage)
|
||||
sp.PUT("/:pageId", updateStatusPage)
|
||||
sp.DELETE("/:pageId", deleteStatusPage)
|
||||
sp.GET("/:pageId/incidents", listStatusIncidents)
|
||||
sp.POST("/:pageId/incidents", createStatusIncident)
|
||||
sp.PUT("/:pageId/incidents/:incidentId", updateStatusIncident)
|
||||
sp.DELETE("/:pageId/incidents/:incidentId", deleteStatusIncident)
|
||||
sp.POST("/:pageId/incidents/:incidentId/updates", appendStatusIncidentUpdate)
|
||||
}
|
||||
|
||||
// statusPageError maps the service errors onto codes once, so ten handlers do
|
||||
// not each invent their own. services.ErrPageInvalid covers every validation
|
||||
// failure in the status page and incident services — a missing title or an
|
||||
// invalid incident status is a 400, not a 500.
|
||||
func statusPageError(c *gin.Context, err error) {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrPageNotFound), errors.Is(err, services.ErrIncidentNotFound):
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrPageIDTaken):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrInvalidPageID), errors.Is(err, services.ErrPageInvalid):
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// listStatusPages godoc
|
||||
//
|
||||
// @Summary List status pages
|
||||
// @Tags status-pages
|
||||
// @Produce json
|
||||
// @Success 200 {array} models.StatusPage
|
||||
// @Failure 500 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages [get]
|
||||
func listStatusPages(c *gin.Context) {
|
||||
pages, err := services.ListStatusPages(auth.InstanceID(c))
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, pages)
|
||||
}
|
||||
|
||||
// createStatusPage godoc
|
||||
//
|
||||
// @Summary Create a status page
|
||||
// @Tags status-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param body body models.StatusPage true "Status page"
|
||||
// @Success 201 {object} models.StatusPage
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 409 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages [post]
|
||||
func createStatusPage(c *gin.Context) {
|
||||
var p models.StatusPage
|
||||
if err := c.ShouldBindJSON(&p); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
created, err := services.CreateStatusPage(auth.InstanceID(c), &p)
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_page_created", actorFromCtx(c), "", "",
|
||||
"Status page '"+created.PageID+"' created")
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
// getStatusPage godoc
|
||||
//
|
||||
// @Summary Get a status page
|
||||
// @Tags status-pages
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Success 200 {object} models.StatusPage
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId} [get]
|
||||
func getStatusPage(c *gin.Context) {
|
||||
page, err := services.GetStatusPage(auth.InstanceID(c), c.Param("pageId"))
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, page)
|
||||
}
|
||||
|
||||
// updateStatusPage godoc
|
||||
//
|
||||
// @Summary Update a status page
|
||||
// @Tags status-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Param body body models.StatusPage true "Status page"
|
||||
// @Success 200 {object} models.StatusPage
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId} [put]
|
||||
func updateStatusPage(c *gin.Context) {
|
||||
var p models.StatusPage
|
||||
if err := c.ShouldBindJSON(&p); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := services.UpdateStatusPage(auth.InstanceID(c), c.Param("pageId"), &p)
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_page_updated", actorFromCtx(c), "", "",
|
||||
"Status page '"+updated.PageID+"' updated")
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// deleteStatusPage godoc
|
||||
//
|
||||
// @Summary Delete a status page
|
||||
// @Tags status-pages
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId} [delete]
|
||||
func deleteStatusPage(c *gin.Context) {
|
||||
if err := services.DeleteStatusPage(auth.InstanceID(c), c.Param("pageId")); err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_page_deleted", actorFromCtx(c), "", "",
|
||||
"Status page '"+c.Param("pageId")+"' deleted")
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// listStatusIncidents godoc
|
||||
//
|
||||
// @Summary List authored incidents for a status page
|
||||
// @Tags status-pages
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Success 200 {array} models.StatusIncident
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId}/incidents [get]
|
||||
func listStatusIncidents(c *gin.Context) {
|
||||
incs, err := services.ListStatusIncidents(auth.InstanceID(c), c.Param("pageId"))
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, incs)
|
||||
}
|
||||
|
||||
// createStatusIncident godoc
|
||||
//
|
||||
// @Summary Create an incident or maintenance window
|
||||
// @Tags status-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Param body body models.StatusIncident true "Incident"
|
||||
// @Success 201 {object} models.StatusIncident
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId}/incidents [post]
|
||||
func createStatusIncident(c *gin.Context) {
|
||||
var inc models.StatusIncident
|
||||
if err := c.ShouldBindJSON(&inc); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// The page in the path is always one of the pages the incident names, so
|
||||
// creating from a page cannot produce an incident that page never shows.
|
||||
if !contains(inc.PageIDs, c.Param("pageId")) {
|
||||
inc.PageIDs = append(inc.PageIDs, c.Param("pageId"))
|
||||
}
|
||||
created, err := services.CreateStatusIncident(auth.InstanceID(c), &inc)
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_incident_created", actorFromCtx(c), "", "",
|
||||
"Status "+created.Kind+" '"+created.Title+"' created")
|
||||
c.JSON(http.StatusCreated, created)
|
||||
}
|
||||
|
||||
func contains(list []string, want string) bool {
|
||||
for _, v := range list {
|
||||
if v == want {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// updateStatusIncident godoc
|
||||
//
|
||||
// @Summary Update an incident or maintenance window
|
||||
// @Tags status-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Param incidentId path string true "Incident id"
|
||||
// @Param body body models.StatusIncident true "Incident"
|
||||
// @Success 200 {object} models.StatusIncident
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId}/incidents/{incidentId} [put]
|
||||
func updateStatusIncident(c *gin.Context) {
|
||||
var inc models.StatusIncident
|
||||
if err := c.ShouldBindJSON(&inc); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := services.UpdateStatusIncident(auth.InstanceID(c), c.Param("incidentId"), &inc)
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_incident_updated", actorFromCtx(c), "", "",
|
||||
"Status "+updated.Kind+" '"+updated.Title+"' updated")
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
|
||||
// deleteStatusIncident godoc
|
||||
//
|
||||
// @Summary Delete an incident or maintenance window
|
||||
// @Tags status-pages
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Param incidentId path string true "Incident id"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId}/incidents/{incidentId} [delete]
|
||||
func deleteStatusIncident(c *gin.Context) {
|
||||
if err := services.DeleteStatusIncident(auth.InstanceID(c), c.Param("incidentId")); err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_incident_deleted", actorFromCtx(c), "", "",
|
||||
"Status incident '"+c.Param("incidentId")+"' deleted")
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// appendStatusIncidentUpdate godoc
|
||||
//
|
||||
// @Summary Post an update to an incident
|
||||
// @Tags status-pages
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param pageId path string true "Page id"
|
||||
// @Param incidentId path string true "Incident id"
|
||||
// @Param body body StatusIncidentUpdateRequest true "Update"
|
||||
// @Success 200 {object} models.StatusIncident
|
||||
// @Failure 400 {object} ErrorResponse
|
||||
// @Failure 404 {object} ErrorResponse
|
||||
// @Security cookieAuth
|
||||
// @Security bearerAuth
|
||||
// @Router /status-pages/{pageId}/incidents/{incidentId}/updates [post]
|
||||
func appendStatusIncidentUpdate(c *gin.Context) {
|
||||
var body StatusIncidentUpdateRequest
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
updated, err := services.AppendStatusIncidentUpdate(
|
||||
auth.InstanceID(c), c.Param("incidentId"), body.Status, body.Body, actorFromCtx(c))
|
||||
if err != nil {
|
||||
statusPageError(c, err)
|
||||
return
|
||||
}
|
||||
services.LogEvent(auth.InstanceID(c), "status_incident_update_posted", actorFromCtx(c), "", "",
|
||||
"Update posted to '"+updated.Title+"' ("+body.Status+")")
|
||||
c.JSON(http.StatusOK, updated)
|
||||
}
|
||||
@@ -237,3 +237,12 @@ type WorkloadLogsResponse struct {
|
||||
Text string `json:"text"`
|
||||
Truncated bool `json:"truncated"`
|
||||
}
|
||||
|
||||
// --- status pages ---
|
||||
|
||||
// StatusIncidentUpdateRequest is one post to an incident's timeline. The author
|
||||
// is taken from the session, never from the body.
|
||||
type StatusIncidentUpdateRequest struct {
|
||||
Status string `json:"status" binding:"required"`
|
||||
Body string `json:"body" binding:"required"`
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ var ScopeResources = []string{
|
||||
"vulns",
|
||||
"workloads",
|
||||
"settings",
|
||||
"status",
|
||||
}
|
||||
|
||||
const (
|
||||
|
||||
@@ -35,34 +35,34 @@ var impacts = map[string]bool{
|
||||
|
||||
func validateIncident(inc *models.StatusIncident) error {
|
||||
if inc.Title == "" {
|
||||
return errors.New("title is required")
|
||||
return fmt.Errorf("%w: title is required", ErrPageInvalid)
|
||||
}
|
||||
if len(inc.PageIDs) == 0 {
|
||||
return errors.New("at least one page is required")
|
||||
return fmt.Errorf("%w: at least one page is required", ErrPageInvalid)
|
||||
}
|
||||
switch inc.Kind {
|
||||
case models.StatusKindIncident:
|
||||
if !incidentStatuses[inc.Status] {
|
||||
return fmt.Errorf("invalid incident status %q", inc.Status)
|
||||
return fmt.Errorf("%w: invalid incident status %q", ErrPageInvalid, inc.Status)
|
||||
}
|
||||
case models.StatusKindMaintenance:
|
||||
if !maintenanceStatuses[inc.Status] {
|
||||
return fmt.Errorf("invalid maintenance status %q", inc.Status)
|
||||
return fmt.Errorf("%w: invalid maintenance status %q", ErrPageInvalid, inc.Status)
|
||||
}
|
||||
if inc.ScheduledStart == nil || inc.ScheduledEnd == nil {
|
||||
return errors.New("maintenance needs a scheduled start and end")
|
||||
return fmt.Errorf("%w: maintenance needs a scheduled start and end", ErrPageInvalid)
|
||||
}
|
||||
if !inc.ScheduledEnd.After(*inc.ScheduledStart) {
|
||||
return errors.New("maintenance must end after it starts")
|
||||
return fmt.Errorf("%w: maintenance must end after it starts", ErrPageInvalid)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("invalid kind %q", inc.Kind)
|
||||
return fmt.Errorf("%w: invalid kind %q", ErrPageInvalid, inc.Kind)
|
||||
}
|
||||
if inc.Impact == "" {
|
||||
inc.Impact = models.ImpactNone
|
||||
}
|
||||
if !impacts[inc.Impact] {
|
||||
return fmt.Errorf("invalid impact %q", inc.Impact)
|
||||
return fmt.Errorf("%w: invalid impact %q", ErrPageInvalid, inc.Impact)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -214,14 +214,14 @@ func AppendStatusIncidentUpdate(instanceID, incidentID, status, body, author str
|
||||
return nil, err
|
||||
}
|
||||
if body == "" {
|
||||
return nil, errors.New("update body is required")
|
||||
return nil, fmt.Errorf("%w: update body is required", ErrPageInvalid)
|
||||
}
|
||||
valid := incidentStatuses
|
||||
if existing.Kind == models.StatusKindMaintenance {
|
||||
valid = maintenanceStatuses
|
||||
}
|
||||
if !valid[status] {
|
||||
return nil, fmt.Errorf("invalid status %q for a %s", status, existing.Kind)
|
||||
return nil, fmt.Errorf("%w: invalid status %q for a %s", ErrPageInvalid, status, existing.Kind)
|
||||
}
|
||||
|
||||
upd := models.StatusIncidentUpdate{At: time.Now(), Status: status, Body: body, Author: author}
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"regexp"
|
||||
"time"
|
||||
@@ -74,6 +75,12 @@ func EnsureStatusPageIndexes() error {
|
||||
var (
|
||||
ErrPageNotFound = errors.New("status page not found")
|
||||
ErrPageIDTaken = errors.New("that page id is already in use")
|
||||
|
||||
// ErrPageInvalid is the sentinel for validation failures on a page or
|
||||
// incident body — anything the caller can fix by sending a different
|
||||
// request. statusPageError maps it to 400; wrap it rather than returning a
|
||||
// bare error, or a bad request answers 500.
|
||||
ErrPageInvalid = errors.New("status page request invalid")
|
||||
)
|
||||
|
||||
// statusRedis is set by main.go at boot. It is nil in any process that has not
|
||||
@@ -134,7 +141,7 @@ func CreateStatusPage(instanceID string, p *models.StatusPage) (*models.StatusPa
|
||||
return nil, err
|
||||
}
|
||||
if p.Title == "" {
|
||||
return nil, errors.New("title is required")
|
||||
return nil, fmt.Errorf("%w: title is required", ErrPageInvalid)
|
||||
}
|
||||
p.ID = bson.ObjectID{}
|
||||
p.InstanceID = instanceID
|
||||
@@ -169,7 +176,7 @@ func CreateStatusPage(instanceID string, p *models.StatusPage) (*models.StatusPa
|
||||
// The page id itself is immutable: it is a URL that has been handed out.
|
||||
func UpdateStatusPage(instanceID, pageID string, p *models.StatusPage) (*models.StatusPage, error) {
|
||||
if p.Title == "" {
|
||||
return nil, errors.New("title is required")
|
||||
return nil, fmt.Errorf("%w: title is required", ErrPageInvalid)
|
||||
}
|
||||
if p.Sections == nil {
|
||||
p.Sections = []models.StatusPageSection{}
|
||||
|
||||
Reference in New Issue
Block a user