diff --git a/server/internal/api/docs/openapi.json b/server/internal/api/docs/openapi.json index e4391cb..15354c7 100644 --- a/server/internal/api/docs/openapi.json +++ b/server/internal/api/docs/openapi.json @@ -1085,6 +1085,10 @@ "enabled": { "type": "boolean" }, + "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" + }, "instance_id": { "type": "string" }, @@ -4208,6 +4212,9 @@ "enabled": { "type": "boolean" }, + "group": { + "type": "string" + }, "interval_sec": { "type": "integer" }, diff --git a/server/internal/api/monitors.go b/server/internal/api/monitors.go index 32b3caa..2adc7a5 100644 --- a/server/internal/api/monitors.go +++ b/server/internal/api/monitors.go @@ -111,7 +111,7 @@ func getMonitor(c *gin.Context) { // @Accept json // @Produce json // @Param id path string true "Monitor ID" -// @Param body body object{name=string,type=string,target=models.MonitorTarget,interval_sec=int,runner=string,retries=int,enabled=bool,channel_ids=[]string} true "Fields to update" +// @Param body body object{name=string,group=string,type=string,target=models.MonitorTarget,interval_sec=int,runner=string,retries=int,enabled=bool,channel_ids=[]string} true "Fields to update" // @Success 204 // @Failure 400 {object} ErrorResponse // @Failure 500 {object} ErrorResponse @@ -121,6 +121,7 @@ func getMonitor(c *gin.Context) { func updateMonitor(c *gin.Context) { var body struct { Name *string `json:"name"` + Group *string `json:"group"` Type *string `json:"type"` Target *models.MonitorTarget `json:"target"` IntervalSec *int `json:"interval_sec"` @@ -137,6 +138,9 @@ func updateMonitor(c *gin.Context) { if body.Name != nil { upd["name"] = *body.Name } + if body.Group != nil { + upd["group"] = *body.Group + } if body.Type != nil { upd["type"] = *body.Type } diff --git a/server/internal/models/monitor.go b/server/internal/models/monitor.go index 940bad2..97e70ed 100644 --- a/server/internal/models/monitor.go +++ b/server/internal/models/monitor.go @@ -43,10 +43,14 @@ type MonitorState struct { } type Monitor struct { - ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` - InstanceID string `bson:"instance_id" json:"instance_id"` - MonitorID string `bson:"monitor_id" json:"monitor_id"` - Name string `bson:"name" json:"name"` + ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"` + InstanceID string `bson:"instance_id" json:"instance_id"` + MonitorID string `bson:"monitor_id" json:"monitor_id"` + Name string `bson:"name" json:"name"` + // Group is a display-only label. It buckets rows on the monitors page and + // has no effect on scheduling, alerting or scope; an empty group means the + // monitor is listed on its own under "Ungrouped". + Group string `bson:"group,omitempty" json:"group,omitempty"` Type string `bson:"type" json:"type"` Target MonitorTarget `bson:"target" json:"target"` IntervalSec int `bson:"interval_sec" json:"interval_sec"` diff --git a/server/internal/services/monitors.go b/server/internal/services/monitors.go index db9b5ec..6f06dea 100644 --- a/server/internal/services/monitors.go +++ b/server/internal/services/monitors.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "log" + "strings" "time" "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/checker" @@ -21,6 +22,22 @@ func monCtx() (context.Context, context.CancelFunc) { return context.WithTimeout(context.Background(), 5*time.Second) } +// MaxMonitorGroupLen bounds the display-only group label. It is a heading on +// the monitors page, not an identifier, so the cap is about the layout rather +// than storage. +const MaxMonitorGroupLen = 48 + +// normaliseGroup collapses the ways two people write the same group. Grouping +// is by exact string, so " Production " and "Production" must not become two +// headings. +func normaliseGroup(g string) (string, error) { + g = strings.Join(strings.Fields(g), " ") + if len([]rune(g)) > MaxMonitorGroupLen { + return "", fmt.Errorf("group must be %d characters or fewer", MaxMonitorGroupLen) + } + return g, nil +} + func SpecFor(m *models.Monitor) checker.Spec { return checker.Spec{ Type: m.Type, @@ -126,6 +143,11 @@ func CreateMonitor(instanceID string, m *models.Monitor) (*models.Monitor, error if err := validateRunner(instanceID, m.Runner); err != nil { return nil, err } + group, err := normaliseGroup(m.Group) + if err != nil { + return nil, err + } + m.Group = group m.InstanceID = instanceID m.MonitorID = uuid.NewString() m.CreatedAt = time.Now() @@ -158,6 +180,17 @@ func UpdateMonitor(instanceID, monitorID string, upd bson.M) error { return err } } + if raw, present := upd["group"]; present { + g, ok := raw.(string) + if !ok { + return fmt.Errorf("group must be a string") + } + group, err := normaliseGroup(g) + if err != nil { + return err + } + upd["group"] = group + } if raw, present := upd["runner"]; present { runner, ok := raw.(string) if !ok { diff --git a/web/app/(app)/monitors/[id]/page.tsx b/web/app/(app)/monitors/[id]/page.tsx index 604ac40..8da3ee9 100644 --- a/web/app/(app)/monitors/[id]/page.tsx +++ b/web/app/(app)/monitors/[id]/page.tsx @@ -16,6 +16,7 @@ import { formatMs, formatPct, relativeTime, + slotLabel, statusStripe, targetSummary, uptimePct, @@ -36,7 +37,55 @@ function niceCeiling(ms: number): number { return steps.find((s) => s >= ms) ?? Math.ceil(ms / 10000) * 10000; } +/* + * The bar readout. A native title attribute arrives a second late, cannot show + * the latency alongside the uptime, and is invisible to keyboard users — so the + * hovered hour gets a real popover, anchored to its own bar. + */ +function SlotPopover({ slot, index, count }: { slot: Slot; index: number; count: number }) { + const end = new Date(slot.at.getTime() + 3600_000); + const day = slot.at.toLocaleDateString(undefined, { weekday: "short", day: "numeric", month: "short" }); + const span = `${slot.at.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}–${end.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" })}`; + + /* Anchored to the bar, but the first and last few bars would push a centred + card off the chart, so the edges pin instead of centring. */ + const frac = count > 1 ? index / (count - 1) : 0.5; + const shift = frac < 0.14 ? "0%" : frac > 0.86 ? "-100%" : "-50%"; + + return ( +
{day}
+{span}
+ {slot.pct === null ? ( +No checks ran
+ ) : ( +{targetSummary(monitor)}
{monitor.state.message &&{monitor.state.message}
} diff --git a/web/app/(app)/monitors/page.tsx b/web/app/(app)/monitors/page.tsx index 4547db3..b1d8594 100644 --- a/web/app/(app)/monitors/page.tsx +++ b/web/app/(app)/monitors/page.tsx @@ -1,5 +1,6 @@ "use client"; +import { useCallback, useEffect, useState } from "react"; import { useQueries, useQuery } from "@tanstack/react-query"; import Link from "next/link"; import { api, Monitor, Rollup } from "@/lib/api"; @@ -27,6 +28,10 @@ import { * Uptime is per monitor, so the rollups are fetched per monitor. A fleet is * tens of checks, not thousands, and the alternative is a list endpoint that * embeds history for every row whether or not anyone looks at it. + * + * Groups are display only — a label on the monitor, nothing schedules or + * alerts by it. A fleet that never sets one sees the flat list it had before, + * with no "Ungrouped" heading over the whole page. */ const ROW = "grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1.15fr)_minmax(0,2fr)_170px] sm:gap-5"; @@ -68,6 +73,103 @@ function FleetMeter({ counts, uptime }: { counts: RecordUptime 24h · response
-