feat(api): metric monitor per-server states and server-scoped incidents
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ----
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"
|
||||
)
|
||||
|
||||
func TestFilterByVisibleServer(t *testing.T) {
|
||||
incs := []models.Incident{{IncidentID: "a"}, {IncidentID: "b", ServerID: "s1"}, {IncidentID: "c", ServerID: "s2"}}
|
||||
id := func(i models.Incident) string { return i.ServerID }
|
||||
|
||||
if got := FilterByVisibleServer(incs, id, nil, false); len(got) != 3 {
|
||||
t.Fatalf("unrestricted keeps all, got %d", len(got))
|
||||
}
|
||||
got := FilterByVisibleServer(incs, id, map[string]bool{"s1": true}, true)
|
||||
if len(got) != 2 || got[0].IncidentID != "a" || got[1].IncidentID != "b" {
|
||||
t.Fatalf("restricted keeps serverless and visible only, got %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -105,3 +105,18 @@ func SelectorNarrowerOrEqual(child, parent map[string]string) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// FilterByVisibleServer drops items tied to a server the credential cannot
|
||||
// see. Items with no server (every non-metric incident) are always kept.
|
||||
func FilterByVisibleServer[T any](items []T, serverID func(T) string, visible map[string]bool, restricted bool) []T {
|
||||
if !restricted {
|
||||
return items
|
||||
}
|
||||
out := items[:0:0]
|
||||
for _, it := range items {
|
||||
if id := serverID(it); id == "" || visible[id] {
|
||||
out = append(out, it)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user