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:
2026-08-24 14:43:17 +00:00
parent 6ba54f690c
commit 3abbdc41d6
8 changed files with 1122 additions and 12 deletions
+766
View File
@@ -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": {
+2
View File
@@ -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)
}
}
+13
View File
@@ -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
+312
View File
@@ -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)
}
+9
View File
@@ -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"`
}