feat(api): metric monitor per-server states and server-scoped incidents

This commit is contained in:
2026-09-17 09:02:35 +00:00
parent 2017c95a7a
commit 3cb8463dc7
6 changed files with 314 additions and 7 deletions
+207
View File
@@ -1045,6 +1045,10 @@
"resolved_at": {
"type": "string"
},
"server_id": {
"description": "ServerID is set only for metric monitors, which keep one incident per\nbreaching server.",
"type": "string"
},
"started_at": {
"type": "string"
}
@@ -1114,6 +1118,10 @@
"reboot_required": {
"type": "boolean"
},
"reboot_required_since": {
"description": "RebootRequiredSince is when the host first reported a pending reboot.",
"type": "string"
},
"static_at": {
"type": "string"
},
@@ -1224,10 +1232,18 @@
"enabled": {
"type": "boolean"
},
"for_sec": {
"description": "ForSec is how long a metric condition must hold before a server is down.",
"type": "integer"
},
"group": {
"description": "Group is a display-only label. It buckets rows on the monitors page and\nhas no effect on scheduling, alerting or scope; an empty group means the\nmonitor is listed on its own under \"Ungrouped\".",
"type": "string"
},
"heartbeat_token": {
"description": "HeartbeatToken is the plaintext token, set only on the create response.",
"type": "string"
},
"instance_id": {
"type": "string"
},
@@ -1278,6 +1294,38 @@
},
"type": "object"
},
"models.MonitorServerState": {
"properties": {
"breach_since": {
"type": "string"
},
"hostname": {
"type": "string"
},
"instance_id": {
"type": "string"
},
"message": {
"type": "string"
},
"monitor_id": {
"type": "string"
},
"server_id": {
"type": "string"
},
"status": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"value": {
"type": "number"
}
},
"type": "object"
},
"models.MonitorState": {
"properties": {
"cert_expiry_at": {
@@ -1292,12 +1340,18 @@
"last_notified_at": {
"type": "string"
},
"last_ping_at": {
"type": "string"
},
"latency_ms": {
"type": "integer"
},
"message": {
"type": "string"
},
"started_at": {
"type": "string"
},
"status": {
"type": "string"
}
@@ -1309,6 +1363,9 @@
"expected_status": {
"type": "integer"
},
"grace_sec": {
"type": "integer"
},
"host": {
"type": "string"
},
@@ -1321,9 +1378,29 @@
"method": {
"type": "string"
},
"metric": {
"type": "string"
},
"mount": {
"type": "string"
},
"period_sec": {
"description": "Heartbeat monitors.",
"type": "integer"
},
"port": {
"type": "integer"
},
"selector": {
"additionalProperties": {
"type": "string"
},
"description": "Metric monitors.",
"type": "object"
},
"threshold": {
"type": "number"
},
"tls_warn_days": {
"type": "integer"
},
@@ -6402,6 +6479,9 @@
"enabled": {
"type": "boolean"
},
"for_sec": {
"type": "integer"
},
"group": {
"type": "string"
},
@@ -6535,6 +6615,71 @@
]
}
},
"/monitors/{id}/rotate-token": {
"post": {
"description": "Issues a new token and invalidates the old ping URL immediately. The token is returned only in this response.",
"parameters": [
{
"description": "Monitor ID",
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"properties": {
"heartbeat_token": {
"type": "string"
}
},
"type": "object"
}
}
},
"description": "OK"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Not Found"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"summary": "Rotate a heartbeat monitor's ping token",
"tags": [
"monitors"
]
}
},
"/monitors/{id}/samples": {
"get": {
"description": "Raw check results for the last `minutes` minutes, oldest first. Samples expire after 48 hours; use the uptime rollups for longer ranges.",
@@ -6606,6 +6751,68 @@
]
}
},
"/monitors/{id}/servers": {
"get": {
"parameters": [
{
"description": "Monitor ID",
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/models.MonitorServerState"
},
"type": "array"
}
}
},
"description": "OK"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Not Found"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/api.ErrorResponse"
}
}
},
"description": "Internal Server Error"
}
},
"security": [
{
"cookieAuth": []
},
{
"bearerAuth": []
}
],
"summary": "List a metric monitor's per-server states",
"tags": [
"monitors"
]
}
},
"/monitors/{id}/uptime": {
"get": {
"description": "Hourly rollups for the last 30 days.",
+55
View File
@@ -22,6 +22,7 @@ func registerMonitorRoutes(g *gin.RouterGroup) {
g.GET("/monitors/:id/incidents", getMonitorIncidents)
g.GET("/monitors/:id/uptime", getMonitorUptime)
g.GET("/monitors/:id/samples", getMonitorSamples)
g.GET("/monitors/:id/servers", getMonitorServers)
g.POST("/monitors/:id/rotate-token", rotateHeartbeatToken)
}
@@ -264,9 +265,63 @@ func getMonitorIncidents(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
visible, restricted, err := services.VisibleServerIDs(auth.InstanceID(c), auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
incidents = services.FilterByVisibleServer(incidents, func(i models.Incident) string { return i.ServerID }, visible, restricted)
c.JSON(http.StatusOK, incidents)
}
// getMonitorServers godoc
//
// @Summary List a metric monitor's per-server states
// @Tags monitors
// @Produce json
// @Param id path string true "Monitor ID"
// @Success 200 {array} models.MonitorServerState
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors/{id}/servers [get]
func getMonitorServers(c *gin.Context) {
instanceID := auth.InstanceID(c)
m, err := services.GetMonitor(instanceID, c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if m == nil || m.Type != models.MonitorMetric {
c.JSON(http.StatusNotFound, gin.H{"error": "metric monitor not found"})
return
}
states, err := services.ListMonitorServerStates(instanceID, m.MonitorID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
visible, restricted, err := services.VisibleServerIDs(instanceID, auth.ServerScope(c))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
states = services.FilterByVisibleServer(states, func(s models.MonitorServerState) string { return s.ServerID }, visible, restricted)
servers, err := services.ListServersFiltered(instanceID, m.Target.Selector)
if err == nil {
names := map[string]string{}
for _, s := range servers {
names[s.ServerID] = s.Hostname
}
for i := range states {
states[i].Hostname = names[states[i].ServerID]
}
}
c.JSON(http.StatusOK, states)
}
// getMonitorSamples godoc
//
// @Summary Get a monitor's individual check results
+1
View File
@@ -107,6 +107,7 @@ var routeScopes = map[string]string{
"GET /api/monitors/:id/incidents": "monitors:read",
"GET /api/monitors/:id/uptime": "monitors:read",
"GET /api/monitors/:id/samples": "monitors:read",
"GET /api/monitors/:id/servers": "monitors:read",
"POST /api/monitors/:id/rotate-token": "monitors:write",
// Channel routes, registered by registerChannelRoutes. Channels exist to
+16 -7
View File
@@ -295,13 +295,22 @@ var serverScopedRoutes = map[string]scopeDecl{
// Rotating a ping token touches no server and returns only the token.
"POST /api/monitors/:id/rotate-token": fleetWide,
// A monitor's incidents, uptime rollups and recent samples are all about
// the monitored endpoint - status, latency, timestamps - and carry no
// server identifier at all; the runner is a field of the monitor
// document, which these do not return.
"GET /api/monitors/:id/incidents": exempt,
"GET /api/monitors/:id/uptime": exempt,
"GET /api/monitors/:id/samples": exempt,
// Uptime rollups and recent samples are about the monitored endpoint -
// status, latency, timestamps - and carry no server identifier at all;
// the runner is a field of the monitor document, which these do not
// return.
"GET /api/monitors/:id/uptime": exempt,
"GET /api/monitors/:id/samples": exempt,
// A metric monitor's incidents carry server_id, so a restricted token
// must not see an incident for a server outside its scope. Handled by
// services.FilterByVisibleServer rather than by the route classification
// itself.
"GET /api/monitors/:id/incidents": scoped,
// Per-server metric state names servers, so it is filtered to the ones the
// credential can see.
"GET /api/monitors/:id/servers": scoped,
// ---- notification channels ----