Compare commits

..
Author SHA1 Message Date
mrhid6 2a660697c5 docs: Updated troubleshotting doc
Chart Release / chart (push) Successful in 12s
Server Deploy / deploy (push) Successful in 1m4s
2026-08-24 12:50:44 +00:00
mrhid6 0c08dda635 feat: Useragent
Chart Release / chart (push) Successful in 14s
Server Deploy / deploy (push) Successful in 6m5s
2026-08-24 12:17:46 +00:00
mrhid6 22b99ff895 feat: Monitor grath zoom
Chart Release / chart (push) Successful in 13s
Server Deploy / deploy (push) Successful in 6m31s
2026-08-24 10:58:50 +00:00
mrhid6 2fab784ba7 feat: Monitor groups and chart information
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 6m35s
2026-08-24 10:30:23 +00:00
mrhid6 83cdf92575 feat: Updated edit monitor page
Chart Release / chart (push) Successful in 14s
Server Deploy / deploy (push) Successful in 43s
2026-08-24 09:25:43 +00:00
mrhid6 aa1c8e4aa1 feat: Hide secrets on api and channels
Chart Release / chart (push) Successful in 15s
Server Deploy / deploy (push) Successful in 8m5s
2026-08-14 12:23:36 +00:00
mrhid6 ac61015cc0 fix: Fixed incorrect openapi doc
Chart Release / chart (push) Canceled after 0s
Server Deploy / deploy (push) Successful in 9m51s
2026-08-13 13:04:44 +00:00
mrhid6 a0fbf5b9ba fix: Word and colour Windows workloads correctly across the UI
Server Deploy / deploy (push) Canceled after 0s
Chart Release / chart (push) Canceled after 0s
Agent Release / build (push) Successful in 14m1s
Agent Release / msi (push) Successful in 1m22s
2026-08-13 12:57:37 +00:00
mrhid6 ddf0814803 docs: Fix Windows package-inventory and poll-loop claims in CLAUDE.md 2026-08-13 12:28:51 +00:00
31 changed files with 1153 additions and 246 deletions
+11 -7
View File
@@ -383,9 +383,11 @@ one wire shape, worded per platform in the UI, which is the only layer that
knows the host's OS. The platform split lives entirely in the agent, as build
tags (`systemd_linux.go` / `services_windows.go` and the matching `control_`
and `logs_` pairs); the control plane is OS-blind and needed no changes.
Windows collection runs PowerShell through `agent/internal/winexec`, and every
script emits JSON that a build-tag-free parser reads, so the parsers are tested
on Linux — the agent module has no Windows CI.
Windows collection runs PowerShell through `agent/internal/winexec`. Every
script that reports data emits JSON that a build-tag-free parser reads, so
those parsers are tested on Linux — the agent module has no Windows CI. The
control verbs and `serviceDisplayName` emit no JSON and have no parser; they
are exercised only by running the agent on Windows.
**Not gated by licence**: this reads as core fleet management, so v1 ships
everywhere with no `HasFeature` check. If that changes the check belongs at
@@ -895,7 +897,7 @@ tls: true
```
1. SyncKeys(server_id, agent_token, agent_version)
2. Non-Linux hosts stop here — Windows agents register and heartbeat only
2. Non-Linux hosts stop here — the key-management steps below are Linux-only; a Windows agent's other work (workflow steps, inventory, OS updates, workloads) runs from the goroutines started above, not from this loop
3. Diff desired keys against /root/.ssh/authorized_keys; unchanged → no write
4. Changed → write .tmp, os.Rename() over the real file, chmod 0600
```
@@ -1171,9 +1173,11 @@ git push origin main # server + web deploy
- **Windows agents cover the fleet-management path** — register, heartbeat, run
steps, report inventory, OS updates through the Windows Update COM API, and
workloads (services plus containers, with control and logs). They still do no
`authorized_keys` management, and no package inventory or CVE matching: the
vulnerability feeds this project uses carry no Windows data, so a Windows host
correctly reports `unsupported` rather than a clean bill of health.
`authorized_keys` management, and no package inventory or CVE matching: a
Windows agent never calls `ReportPackages`, so no `server_packages` document
exists for it and it reports no package inventory at all — a different,
earlier state than the `unsupported` a Linux distribution reaches when its
family has no security feed.
- **Both `server` and `web` scale horizontally** — see "Running more than one server replica" below. `web` holds nothing; `server` holds per-agent state that is routed between replicas over Redis rather than duplicated.
- **Deletion lives in the control plane** — admin sends the warnings because it knows the billing address; the control plane performs the delete because it is the only service that knows which collections carry `instance_id`. Mirroring that list into admin would drift, and a drift there deletes the wrong rows.
+5
View File
@@ -18,6 +18,10 @@ const (
TypeTCP = "tcp"
TypeICMP = "icmp"
TypeTLS = "tls"
// UserAgent identifies Vantage monitor traffic so a WAF rule can single it
// out. Match on a prefix, not equality: the version moves.
UserAgent = "Vantage-Monitor/1.0 (+https://vantage.hostxtra.co.uk)"
)
@@ -84,6 +88,7 @@ func runHTTP(ctx context.Context, s Spec) Result {
if err != nil {
return Result{Message: err.Error()}
}
req.Header.Set("User-Agent", UserAgent)
resp, err := client.Do(req)
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
+7 -3
View File
@@ -19,12 +19,16 @@ func Run(ctx context.Context, script string) (string, error) {
out, err := cmd.Output()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr)))
}
// Checked before the ExitError/stderr branch: CommandContext kills the
// process on timeout, and that kill can itself produce an ExitError
// carrying stderr text, so a genuine timeout would otherwise surface
// as that stderr instead of the "timed out" message callers match on.
if ctx.Err() == context.DeadlineExceeded {
return "", fmt.Errorf("powershell: timed out")
}
if ee, ok := err.(*exec.ExitError); ok && len(ee.Stderr) > 0 {
return "", fmt.Errorf("powershell: %s", strings.TrimSpace(string(ee.Stderr)))
}
return "", fmt.Errorf("powershell: %w", err)
}
return string(out), nil
+10 -2
View File
@@ -12,8 +12,16 @@ const servicesTimeout = 60 * time.Second
const servicesScript = `
$ErrorActionPreference = 'Stop'
$svcs = Get-CimInstance Win32_Service |
Select-Object Name,DisplayName,State,StartMode,PathName,ExitCode
$svcs = Get-CimInstance Win32_Service | ForEach-Object {
[pscustomobject]@{
Name = $_.Name
DisplayName = $_.DisplayName
State = $_.State
StartMode = $_.StartMode
PathName = $_.PathName
ExitCode = $_.ExitCode
}
}
ConvertTo-Json -InputObject @($svcs) -Depth 3 -Compress
`
+11 -3
View File
@@ -113,10 +113,15 @@ func parseServices(jsonText, systemRoot string) ([]Workload, error) {
continue
}
state := "stopped"
// The wire shape is shared with the systemd collector — both report
// under kind "unit" — so the state word has to be too, or the UI
// (which colours and filters on it, and does so before it knows
// which platform sent the row) needs two vocabularies for one kind.
// running/stopped/failed become active/inactive/failed to match.
state := "inactive"
switch {
case running:
state = "running"
state = "active"
case failed:
state = "failed"
}
@@ -189,7 +194,10 @@ func parseEvents(jsonText, serviceName, displayName string, tail int) (string, e
continue
}
}
msg := strings.TrimSpace(strings.ReplaceAll(e.M, "\r\n", " "))
// Collapse every newline form, not just "\r\n": a message containing a
// bare "\n" would otherwise still break the one-line-per-event shape
// this renders for the log dialog, and undercount the tail trim above.
msg := strings.TrimSpace(strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(e.M))
lines = append(lines, e.T+" "+e.L+" "+msg)
}
+25 -6
View File
@@ -59,12 +59,12 @@ func TestParseServicesFilters(t *testing.T) {
t.Fatalf("got %d workloads, want 3: %+v", len(got), got)
}
if w := byID["Contoso"]; w.Kind != "unit" || w.Name != "Contoso Broker" || w.State != "running" {
if w := byID["Contoso"]; w.Kind != "unit" || w.Name != "Contoso Broker" || w.State != "active" {
t.Errorf("Contoso = %+v", w)
}
// Enabled but not running is exactly the row worth seeing.
if byID["Fabrikam"].State != "stopped" {
t.Errorf("Fabrikam state = %q, want stopped", byID["Fabrikam"].State)
if byID["Fabrikam"].State != "inactive" {
t.Errorf("Fabrikam state = %q, want inactive", byID["Fabrikam"].State)
}
// A non-zero exit code on a stopped service is a crash, not a clean stop.
if byID["Crashed"].State != "failed" {
@@ -80,15 +80,15 @@ func TestParseServicesExitCode1077(t *testing.T) {
if err != nil {
t.Fatalf("parseServices: %v", err)
}
if len(got) != 1 || got[0].State != "stopped" {
t.Fatalf("got %+v, want one stopped workload", got)
if len(got) != 1 || got[0].State != "inactive" {
t.Fatalf("got %+v, want one inactive workload", got)
}
}
func TestParseServicesSingleObjectAndEmpty(t *testing.T) {
one := `{"Name":"Solo","DisplayName":"Solo","State":"Running","StartMode":"Auto","PathName":"C:\\Solo\\s.exe","ExitCode":0}`
got, err := parseServices(one, `C:\WINDOWS`)
if err != nil || len(got) != 1 {
if err != nil || len(got) != 1 || got[0].State != "active" {
t.Fatalf("single object: got %+v, err %v", got, err)
}
@@ -130,6 +130,25 @@ func TestParseEventsFormatsAndOrders(t *testing.T) {
}
}
// A message containing a bare "\n" (no carriage return) must still collapse to
// one line, or it silently multiplies into several output lines and throws
// off the tail trim's count.
func TestParseEventsCollapsesBareLF(t *testing.T) {
in := `[{"t":"2026-08-13T10:00:00Z","l":"Error","p":"Contoso","m":"broker died\nstack trace here"}]`
got, err := parseEvents(in, "Contoso", "Contoso Broker", 500)
if err != nil {
t.Fatalf("parseEvents: %v", err)
}
if strings.Count(got, "\n") != 0 {
t.Fatalf("parseEvents did not collapse bare LF into one line: %q", got)
}
want := "2026-08-13T10:00:00Z Error broker died stack trace here"
if got != want {
t.Fatalf("parseEvents =\n%q\nwant\n%q", got, want)
}
}
// Service Control Manager logs every service on the host under one provider, so
// its rows must be filtered down to the target or the log is somebody else's.
func TestParseEventsFiltersOtherServicesSCM(t *testing.T) {
+47
View File
@@ -95,6 +95,53 @@ instantaneous.
- The keyword no longer appears in the response body.
- Retries are `0`, so a single dropped packet flips the state.
### The check gets a 403, 429 or a CAPTCHA page
The endpoint is fine and answers a browser normally, but the monitor records a
status it never sees by hand. Something between Vantage and the service is
blocking automated traffic: a CDN, a WAF, a bot-protection product, a reverse
proxy rule, or a rate limiter. The response usually comes from that layer and
never reaches the origin at all, so nothing appears in the application's own
logs.
Two things make it hard to spot. The check runs from the control plane's or the
agent's address rather than yours, and those addresses are often datacenter
ranges that bot protection scores badly. And a browser test proves nothing,
because a browser is exactly what the blocking layer is willing to serve.
Every HTTP check Vantage makes identifies itself:
```
User-Agent: Vantage-Monitor/1.0 (+https://vantage.hostxtra.co.uk)
```
That string is the hook to allow the check through. In whichever product is
doing the blocking, add a rule that skips bot protection, managed rules and rate
limiting for requests carrying it — Cloudflare, AWS WAF, Azure Front Door,
Akamai, Fastly, Imperva, Sucuri, ModSecurity, nginx and HAProxy all match on a
request header. The shape of the rule is the same everywhere:
> If the host is *yours*, the path is *the one being monitored*, and the
> User-Agent contains `Vantage-Monitor`, then skip the protection.
Three details are worth getting right:
- **Match on `contains`, not equality.** The version in the string moves. An
exact match breaks silently on an upgrade, and the symptom is a monitor that
goes down on deploy day.
- **Keep the rule narrow.** Scope it to the specific host and path being
monitored. A User-Agent is not a secret — anyone can send it — so a rule that
skips protection site-wide on that string alone is a bypass you have
published.
- **Allow the source address too, where you can.** Combining the User-Agent with
the checker's IP is stronger than either alone. Find the address in your
blocking product's own event log; it is whichever client IP was blocked on the
monitored path.
If the endpoint genuinely needs authentication rather than an exception, monitor
a purpose-built health path that does not, and leave the protected paths
protected.
## Notifications are not arriving
Use the channel **Test** button. It goes through the real delivery path, so a
@@ -64,6 +64,15 @@ Posts the alert as message content.
Port `465` uses implicit TLS; anything else uses STARTTLS.
### Credentials are never read back
The SMTP `password`, the Telegram `token` and the webhook, Slack and Discord
`url`s come back from `GET /api/channels` as `••••••••` — a webhook URL is the
authorisation to post to that channel, so it is treated as a credential like
the rest. Writing that value back unchanged keeps the stored one, which is what
lets you rename a channel without retyping its password. Anything else you send
is written as given, so clearing the field clears the credential.
Alert emails look like the rest of the mail Vantage sends you.
## The message
+8 -4
View File
@@ -51,10 +51,10 @@ import (
// @name Authorization
// @description An API token, sent as "Bearer vt_…". Scoped and optionally expiring.
// @securityDefinitions.apikey esoAuth
// @in header
// @name Authorization
// @description The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another.
// @securityDefinitions.apikey esoAuth
// @in header
// @name Authorization
// @description The External Secrets read token, rotated under Settings. It reaches /api/secrets/{group}/values and nothing else. It is a different credential from an API token, and the two must never be substituted for one another.
func main() {
mongoURI := getEnv("MONGO_URI", "mongodb://localhost:27017")
@@ -162,6 +162,10 @@ func runSchemaSetup() {
log.Printf("warning: failed to ensure workflow indexes: %v", err)
}
if err := services.EnsureMonitorSampleIndexes(); err != nil {
log.Printf("warning: failed to ensure monitor sample indexes: %v", err)
}
if err := services.EnsureVulnIndexes(); err != nil {
log.Printf("warning: failed to ensure vuln indexes: %v", err)
}
+9 -2
View File
@@ -34,7 +34,14 @@ func listChannels(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, channels)
// Redacted here rather than in the service: the dispatchers read the same
// documents and need the real credentials, so the masking belongs to the
// boundary that hands them to a client.
out := make([]models.NotificationChannel, 0, len(channels))
for _, ch := range channels {
out = append(out, ch.Redacted())
}
c.JSON(http.StatusOK, out)
}
// createChannel godoc
@@ -69,7 +76,7 @@ func createChannel(c *gin.Context) {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, created)
c.JSON(http.StatusCreated, created.Redacted())
}
// updateChannel godoc
+101
View File
@@ -1003,6 +1003,9 @@
"type": "array",
"uniqueItems": false
},
"reboot_required": {
"type": "boolean"
},
"static_at": {
"type": "string"
},
@@ -1082,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"
},
@@ -1112,6 +1119,26 @@
},
"type": "object"
},
"models.MonitorSample": {
"properties": {
"at": {
"type": "string"
},
"instance_id": {
"type": "string"
},
"latency_ms": {
"type": "integer"
},
"monitor_id": {
"type": "string"
},
"up": {
"type": "boolean"
}
},
"type": "object"
},
"models.MonitorState": {
"properties": {
"cert_expiry_at": {
@@ -4205,6 +4232,9 @@
"enabled": {
"type": "boolean"
},
"group": {
"type": "string"
},
"interval_sec": {
"type": "integer"
},
@@ -4335,6 +4365,77 @@
]
}
},
"/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.",
"parameters": [
{
"description": "Monitor ID",
"in": "path",
"name": "id",
"required": true,
"schema": {
"type": "string"
}
},
{
"description": "Window in minutes (default 60, max 2880)",
"in": "query",
"name": "minutes",
"schema": {
"type": "integer"
}
}
],
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"items": {
"$ref": "#/components/schemas/models.MonitorSample"
},
"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": "Get a monitor's individual check results",
"tags": [
"monitors"
]
}
},
"/monitors/{id}/uptime": {
"get": {
"description": "Hourly rollups for the last 30 days.",
+50 -1
View File
@@ -2,6 +2,7 @@ package api
import (
"net/http"
"strconv"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/auth"
@@ -19,6 +20,7 @@ func registerMonitorRoutes(g *gin.RouterGroup) {
g.DELETE("/monitors/:id", deleteMonitor)
g.GET("/monitors/:id/incidents", getMonitorIncidents)
g.GET("/monitors/:id/uptime", getMonitorUptime)
g.GET("/monitors/:id/samples", getMonitorSamples)
}
// listMonitors godoc
@@ -111,7 +113,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 +123,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 +140,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
}
@@ -217,6 +223,49 @@ func getMonitorIncidents(c *gin.Context) {
c.JSON(http.StatusOK, incidents)
}
// getMonitorSamples godoc
//
// @Summary Get a monitor's individual check results
// @Description Raw check results for the last `minutes` minutes, oldest first. Samples expire after 48 hours; use the uptime rollups for longer ranges.
// @Tags monitors
// @Produce json
// @Param id path string true "Monitor ID"
// @Param minutes query int false "Window in minutes (default 60, max 2880)"
// @Success 200 {array} models.MonitorSample
// @Failure 404 {object} ErrorResponse
// @Failure 500 {object} ErrorResponse
// @Security cookieAuth
// @Security bearerAuth
// @Router /monitors/{id}/samples [get]
func getMonitorSamples(c *gin.Context) {
m, err := services.GetMonitor(auth.InstanceID(c), c.Param("id"))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if m == nil {
c.JSON(http.StatusNotFound, gin.H{"error": "monitor not found"})
return
}
// Clamped rather than rejected: the window is a view setting, and the only
// honest answer past the TTL is the shorter window anyway.
minutes := 60
if raw := c.Query("minutes"); raw != "" {
if n, convErr := strconv.Atoi(raw); convErr == nil && n > 0 {
minutes = n
}
}
if max := int(services.MonitorSampleTTL.Minutes()); minutes > max {
minutes = max
}
samples, err := services.MonitorSamples(auth.InstanceID(c), c.Param("id"), time.Now().Add(-time.Duration(minutes)*time.Minute))
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, samples)
}
// getMonitorUptime godoc
//
// @Summary Get a monitor's uptime rollups
+1
View File
@@ -100,6 +100,7 @@ var routeScopes = map[string]string{
"DELETE /api/monitors/:id": "monitors:write",
"GET /api/monitors/:id/incidents": "monitors:read",
"GET /api/monitors/:id/uptime": "monitors:read",
"GET /api/monitors/:id/samples": "monitors:read",
// Channel routes, registered by registerChannelRoutes. Channels exist to
// serve alerts, so they share the monitors scope rather than getting their
+5
View File
@@ -17,6 +17,10 @@ const (
TypeTCP = "tcp"
TypeICMP = "icmp"
TypeTLS = "tls"
// UserAgent identifies Vantage monitor traffic so a WAF rule can single it
// out. Match on a prefix, not equality: the version moves.
UserAgent = "Vantage-Monitor/1.0 (+https://vantage.hostxtra.co.uk)"
)
type Spec struct {
@@ -80,6 +84,7 @@ func runHTTP(ctx context.Context, s Spec) Result {
if err != nil {
return Result{Message: err.Error()}
}
req.Header.Set("User-Agent", UserAgent)
resp, err := client.Do(req)
if err != nil {
return Result{LatencyMs: msSince(start), Message: err.Error()}
+44
View File
@@ -14,6 +14,28 @@ const (
ChannelTelegram = "telegram"
)
// RedactedSecret is what a channel's secret config values read as over the API.
// It is a sentinel and not merely a mask: a client may write it straight back,
// and the value it stood for is preserved. See NotificationChannel.Redacted.
const RedactedSecret = "••••••••"
// channelSecretKeys names, per channel type, the config entries that are
// credentials rather than settings. A Slack or Discord webhook URL is on this
// list because possession of the URL *is* the authorisation to post to that
// channel — there is nothing else to steal.
var channelSecretKeys = map[string][]string{
ChannelWebhook: {"url"},
ChannelSlack: {"url"},
ChannelDiscord: {"url"},
ChannelTelegram: {"token"},
ChannelSMTP: {"password"},
}
// ChannelSecretKeys reports which config keys of a channel type are secret.
func ChannelSecretKeys(channelType string) []string {
return channelSecretKeys[channelType]
}
type NotificationChannel struct {
ID bson.ObjectID `bson:"_id,omitempty" json:"_id,omitempty"`
InstanceID string `bson:"instance_id" json:"instance_id"`
@@ -24,3 +46,25 @@ type NotificationChannel struct {
Enabled bool `bson:"enabled" json:"enabled"`
CreatedAt time.Time `bson:"created_at" json:"created_at"`
}
// Redacted returns a copy with every secret config value replaced by
// RedactedSecret, for handing to a client. Nothing internal uses it: the
// dispatchers read the stored document through GetChannel/GetChannels, so the
// redaction is a property of the API boundary and cannot break delivery.
//
// A set-but-secret key keeps its key, so a caller can still tell configured
// from absent; an empty value is left empty rather than being dressed up as a
// credential that is not there.
func (c NotificationChannel) Redacted() NotificationChannel {
out := c
out.Config = make(map[string]string, len(c.Config))
for k, v := range c.Config {
out.Config[k] = v
}
for _, k := range ChannelSecretKeys(c.Type) {
if out.Config[k] != "" {
out.Config[k] = RedactedSecret
}
}
return out
}
+23 -4
View File
@@ -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"`
@@ -67,6 +71,21 @@ type Incident struct {
Cause string `bson:"cause,omitempty" json:"cause,omitempty"`
}
// MonitorSample is one check result, kept only long enough to draw the
// sub-hour views of the history chart. Rollup remains the durable record: a
// sample expires by TTL, a rollup does not.
//
// It carries no message. The failure text is on the incident, and a document
// per check is the one place in this schema where a few bytes multiply by the
// check rate.
type MonitorSample struct {
InstanceID string `bson:"instance_id" json:"instance_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
At time.Time `bson:"at" json:"at"`
Up bool `bson:"up" json:"up"`
LatencyMs int `bson:"latency_ms" json:"latency_ms"`
}
type Rollup struct {
InstanceID string `bson:"instance_id" json:"instance_id"`
MonitorID string `bson:"monitor_id" json:"monitor_id"`
+45
View File
@@ -90,12 +90,57 @@ func CreateChannel(instanceID string, ch *models.NotificationChannel) (*models.N
}
func UpdateChannel(instanceID, channelID string, upd bson.M) error {
if cfg, ok := upd["config"].(map[string]string); ok {
merged, err := mergeChannelSecrets(instanceID, channelID, upd, cfg)
if err != nil {
return err
}
upd["config"] = merged
}
ctx, cancel := monCtx()
defer cancel()
_, err := db.Col("notification_channels").UpdateOne(ctx, bson.M{"channel_id": channelID, "instance_id": instanceID}, bson.M{"$set": upd})
return err
}
// mergeChannelSecrets resolves models.RedactedSecret back to what it stood for.
//
// The API hands out a sentinel rather than the credential, and the UI's edit
// form round-trips whatever it was given, so an ordinary "rename this channel"
// save arrives carrying the sentinel in place of the password. Writing it
// through would replace the credential with eight bullet characters and break
// delivery on the next alert. A value that is not the sentinel is written
// verbatim — including the empty string, which is how a credential is cleared.
func mergeChannelSecrets(instanceID, channelID string, upd bson.M, cfg map[string]string) (map[string]string, error) {
stored, err := GetChannel(instanceID, channelID)
if err != nil {
return nil, err
}
if stored == nil {
return cfg, nil
}
// The secret keys are the ones of the type being saved, which the same
// request may be changing.
channelType := stored.Type
if t, ok := upd["type"].(string); ok && t != "" {
channelType = t
}
out := make(map[string]string, len(cfg))
for k, v := range cfg {
out[k] = v
}
for _, k := range models.ChannelSecretKeys(channelType) {
if out[k] == models.RedactedSecret {
if prev, ok := stored.Config[k]; ok {
out[k] = prev
} else {
delete(out, k)
}
}
}
return out, nil
}
func DeleteChannel(instanceID, channelID string) error {
ctx, cancel := monCtx()
defer cancel()
@@ -36,6 +36,7 @@ var ScopedCollections = []string{
"monitors",
"incidents",
"monitor_rollups",
"monitor_samples",
"notification_channels",
"console_sessions",
"audit_logs",
+70
View File
@@ -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 {
@@ -188,6 +221,7 @@ func DeleteMonitor(instanceID, monitorID string) error {
}
db.Col("incidents").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
db.Col("monitor_rollups").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
db.Col("monitor_samples").DeleteMany(ctx, bson.M{"monitor_id": monitorID, "instance_id": instanceID})
return nil
}
@@ -225,6 +259,31 @@ func UptimeRollups(instanceID, monitorID string, since time.Time) ([]models.Roll
return out, nil
}
// MaxMonitorSamples bounds one range read. At the 10s floor, 48h is 17,280
// checks; the chart buckets them anyway, so a cap costs nothing visible and
// stops one monitor pulling a megabyte of JSON per poll.
const MaxMonitorSamples = 6000
// MonitorSamples returns individual check results since a point in time,
// oldest first. Samples older than MonitorSampleTTL have expired, so an early
// `since` silently returns a shorter window rather than an error — the caller
// draws the gap.
func MonitorSamples(instanceID, monitorID string, since time.Time) ([]models.MonitorSample, error) {
ctx, cancel := monCtx()
defer cancel()
cur, err := db.Col("monitor_samples").Find(ctx,
bson.M{"monitor_id": monitorID, "instance_id": instanceID, "at": bson.M{"$gte": since}},
options.Find().SetSort(bson.M{"at": 1}).SetLimit(MaxMonitorSamples))
if err != nil {
return nil, err
}
var out []models.MonitorSample
if err := cur.All(ctx, &out); err != nil {
return nil, err
}
return out, nil
}
func IngestResult(instanceID, runner, monitorID string, res checker.Result) error {
if instanceID == "" {
return errors.New("instance id required")
@@ -295,6 +354,17 @@ func ingestResult(instanceID, runner, monitorID string, res checker.Result) erro
up = 1
}
/* The sample is the same result at full resolution, expiring by TTL. It is
written next to the rollup rather than instead of it: the rollup is what
survives, the sample is what the sub-hour views read. */
db.Col("monitor_samples").InsertOne(ctx, models.MonitorSample{
InstanceID: m.InstanceID,
MonitorID: monitorID,
At: now,
Up: res.Up,
LatencyMs: res.LatencyMs,
})
db.Col("monitor_rollups").UpdateOne(ctx,
bson.M{"monitor_id": monitorID, "period_start": bucket},
bson.M{
@@ -0,0 +1,42 @@
package services
import (
"context"
"log"
"time"
"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"
"go.mongodb.org/mongo-driver/v2/bson"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
// MonitorSampleTTL is how long an individual check result is kept.
//
// It matches the longest range the chart draws from samples rather than the
// longest range it draws at all: 24h and 48h come from the hourly rollups,
// which are permanent. Keeping samples past the window that reads them would
// only grow the collection.
const MonitorSampleTTL = 48 * time.Hour
// EnsureMonitorSampleIndexes declares the sample range index and its TTL.
//
// Warn rather than fatal, like the other history indexes — but note the TTL is
// not an optimisation: without it nothing ever removes a sample, and the
// collection grows at the fleet's total check rate forever. A boot that logs
// this warning needs following up.
func EnsureMonitorSampleIndexes() error {
ctx := context.Background()
idx := []mongo.IndexModel{
// Every read is a range scan over this key.
{Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "monitor_id", Value: 1}, {Key: "at", Value: 1}}},
// Expiry is Mongo's job: a sweeper would be another leader-scoped loop
// doing what the server already does for free.
{Keys: bson.D{{Key: "at", Value: 1}}, Options: options.Index().SetExpireAfterSeconds(int32(MonitorSampleTTL.Seconds()))},
}
if _, err := db.Col("monitor_samples").Indexes().CreateMany(ctx, idx); err != nil {
log.Printf("warning: monitor_samples indexes: %v", err)
}
return nil
}
+184 -16
View File
@@ -10,12 +10,14 @@ import {
Slot,
StatusChip,
avgLatency,
buildSampleSlots,
buildSlots,
displayStatus,
formatDuration,
formatMs,
formatPct,
relativeTime,
slotLabel,
statusStripe,
targetSummary,
uptimePct,
@@ -30,13 +32,118 @@ import {
const CHART_W = 480;
const CHART_H = 158;
/*
* The ranges. 24h and 48h are drawn from the hourly rollups, which are the
* permanent record; anything shorter than an hour cannot be, so the three
* short ranges read individual check results instead. Those expire after 48
* hours, which is why no range longer than that is offered from samples.
*
* Bucket sizes are chosen to land near 50-70 bars, so the tape has the same
* texture whichever range is selected.
*/
interface Range {
label: string;
minutes: number;
/** "rollups" is hourly and permanent; "samples" is per check and expires. */
source: "rollups" | "samples";
bucketMs: number;
}
const RANGES: Range[] = [
{ label: "48h", minutes: 48 * 60, source: "rollups", bucketMs: 3600_000 },
{ label: "24h", minutes: 24 * 60, source: "rollups", bucketMs: 3600_000 },
{ label: "12h", minutes: 12 * 60, source: "samples", bucketMs: 600_000 },
{ label: "8h", minutes: 8 * 60, source: "samples", bucketMs: 600_000 },
{ label: "1h", minutes: 60, source: "samples", bucketMs: 60_000 },
];
function rangeTitle(r: Range): string {
const hours = r.minutes / 60;
return hours === 1 ? "Last hour" : `Last ${hours} hours`;
}
function bucketLabel(bucketMs: number): string {
if (bucketMs >= 3600_000) return "1 hour per bar";
return `${Math.round(bucketMs / 60_000)} min per bar`;
}
function RangePicker({ value, onChange }: { value: Range; onChange: (r: Range) => void }) {
return (
<div className="flex overflow-hidden rounded-sm border border-border" role="group" aria-label="Chart range">
{RANGES.map((r) => (
<button
key={r.label}
type="button"
onClick={() => onChange(r)}
aria-pressed={r.label === value.label}
className={`border-l border-border px-2.5 py-1 font-mono text-[11px] uppercase tracking-[0.08em] transition-colors first:border-l-0 focus:outline-none focus-visible:ring-1 focus-visible:ring-inset focus-visible:ring-accent ${
r.label === value.label
? "bg-accent/15 text-accent"
: "bg-surface-2 text-text-secondary hover:bg-surface hover:text-text-primary"
}`}
>
{r.label}
</button>
))}
</div>
);
}
function niceCeiling(ms: number): number {
if (ms <= 0) return 100;
const steps = [50, 100, 250, 500, 1000, 2500, 5000, 10000, 30000];
return steps.find((s) => s >= ms) ?? Math.ceil(ms / 10000) * 10000;
}
function History({ slots }: { slots: Slot[] }) {
/*
* 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() + slot.spanMs);
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 (
<div
role="tooltip"
/* Anchored at the top of the plot rather than above it: the chart
box clips its overflow, so a card floated outside would vanish. */
className="pointer-events-none absolute top-0 z-20 w-max"
style={{ left: `${frac * 100}%`, transform: `translateX(${shift})` }}
>
<div className="rounded-sm border border-border bg-surface/95 px-3 py-2 shadow-lg backdrop-blur-sm">
<p className="font-mono text-[10px] uppercase tracking-[0.14em] text-text-tertiary">{day}</p>
<p className="mt-0.5 font-mono text-[11.5px] tabular-nums text-text-primary">{span}</p>
{slot.pct === null ? (
<p className="mt-1.5 text-[11.5px] text-text-secondary">No checks ran</p>
) : (
<dl className="mt-1.5 grid grid-cols-[auto_auto] gap-x-3 gap-y-0.5 text-[11.5px]">
<dt className="text-text-tertiary">Uptime</dt>
<dd
className={`text-right font-mono tabular-nums ${slot.pct >= 99.5 ? "text-success" : slot.pct >= 80 ? "text-warning" : "text-danger"}`}
>
{slot.pct.toFixed(1)}%
</dd>
<dt className="text-text-tertiary">Response</dt>
<dd className="text-right font-mono tabular-nums text-text-primary">{formatMs(slot.latency)}</dd>
<dt className="text-text-tertiary">Checks</dt>
<dd className="text-right font-mono tabular-nums text-text-primary">{slot.checks}</dd>
</dl>
)}
</div>
</div>
);
}
function History({ slots, note }: { slots: Slot[]; note?: string }) {
const [hovered, setHovered] = useState<number | null>(null);
const latencies = slots.map((s) => s.latency).filter((v): v is number => v !== null);
const scale = niceCeiling(Math.max(...latencies, 0) * 1.15);
@@ -61,7 +168,16 @@ function History({ slots }: { slots: Slot[] }) {
const firstAt = slots[0]?.at;
const midAt = slots[Math.floor(slots.length / 2)]?.at;
const tick = (d?: Date) => (d ? d.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" }) : "");
/* A weekday on a one-hour window is noise: every bar is the same day. */
const spanned = slots.length * (slots[0]?.spanMs ?? 3600_000);
const tick = (d?: Date) =>
d
? d.toLocaleString(undefined, {
...(spanned > 6 * 3600_000 ? { weekday: "short" as const } : {}),
hour: "2-digit",
minute: "2-digit",
})
: "";
return (
<div>
@@ -72,20 +188,39 @@ function History({ slots }: { slots: Slot[] }) {
</div>
))}
<div className="absolute inset-x-2.5 bottom-6 top-2.5 flex items-end gap-0.5">
{slots.map((s) => (
<div
{/* z-10 so the bars sit above the trace and stay hoverable. */}
<div
className="absolute inset-x-2.5 bottom-6 top-2.5 z-10 flex items-end gap-0.5"
onMouseLeave={() => setHovered(null)}
>
{slots.map((s, i) => (
<button
key={s.at.getTime()}
className="h-full flex-1"
title={s.pct === null ? "no checks ran" : `${s.pct.toFixed(1)}% up`}
style={{ display: "flex", alignItems: "flex-end" }}
type="button"
className="flex h-full flex-1 items-end focus:outline-none"
onMouseEnter={() => setHovered(i)}
onFocus={() => setHovered(i)}
onBlur={() => setHovered(null)}
aria-label={slotLabel(s)}
>
<div
className={`w-full rounded-[1px] ${s.pct === null ? "bg-border-soft" : s.pct >= 99.5 ? "bg-success/60" : s.pct >= 80 ? "bg-warning/70" : "bg-danger/80"}`}
<span
className={`w-full rounded-[1px] transition-opacity ${
hovered !== null && hovered !== i ? "opacity-50" : ""
} ${s.pct === null ? "bg-border-soft" : s.pct >= 99.5 ? "bg-success/60" : s.pct >= 80 ? "bg-warning/70" : "bg-danger/80"}`}
style={{ height: s.pct === null ? "18%" : `${Math.max(s.pct, 12)}%` }}
/>
</div>
</button>
))}
{hovered !== null && slots[hovered] && (
<>
<span
className="pointer-events-none absolute inset-y-0 w-px bg-text-tertiary/40"
style={{ left: `${(hovered / Math.max(slots.length - 1, 1)) * 100}%` }}
aria-hidden
/>
<SlotPopover slot={slots[hovered]} index={hovered} count={slots.length} />
</>
)}
</div>
<svg
@@ -113,7 +248,7 @@ function History({ slots }: { slots: Slot[] }) {
<span className="flex items-center gap-2">
<span className="block h-0.5 w-3.5 bg-accent" /> Response time
</span>
<span className="text-text-tertiary">Gaps mean no checks ran</span>
<span className="text-text-tertiary">{note ?? "Gaps mean no checks ran"}</span>
</div>
</div>
);
@@ -182,6 +317,7 @@ export default function MonitorDetailPage() {
const queryClient = useQueryClient();
const monitorId = params.id as string;
const [confirmDelete, setConfirmDelete] = useState(false);
const [range, setRange] = useState<Range>(RANGES[0]);
const toast = useToast();
const { data: monitor, isLoading } = useQuery({
@@ -196,6 +332,17 @@ export default function MonitorDetailPage() {
refetchInterval: 60_000,
});
/* Only fetched for the ranges that read it the 24h and 48h views are
served by the rollups the page already holds. The 1h view refreshes on
the check interval's order rather than the rollup's: at one minute per
bar, a 60s poll is the difference between live and a bar behind. */
const { data: samples } = useQuery({
queryKey: ["monitors", monitorId, "samples", range.minutes],
queryFn: () => api.getMonitorSamples(monitorId, range.minutes),
enabled: range.source === "samples",
refetchInterval: range.minutes <= 60 ? 15_000 : 30_000,
});
const { data: incidents } = useQuery({
queryKey: ["monitors", monitorId, "incidents"],
queryFn: () => api.getMonitorIncidents(monitorId),
@@ -261,7 +408,13 @@ export default function MonitorDetailPage() {
}
const all: Rollup[] = rollups ?? [];
const slots = buildSlots(all);
const slots =
range.source === "rollups"
? buildSlots(all, Math.round(range.minutes / 60))
: buildSampleSlots(samples ?? [], range.minutes * 60_000, range.bucketMs);
/* Samples expire after 48h and only start accruing once a check runs, so an
empty short range is a real answer and not a failure to load. */
const emptyRange = range.source === "samples" && (samples ?? []).length === 0;
const status = displayStatus(monitor);
const pct24 = uptimePct(all.slice(-24));
const pct30d = uptimePct(all);
@@ -290,6 +443,11 @@ export default function MonitorDetailPage() {
<span className="rounded-sm border border-border px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-secondary">
{monitor.type}
</span>
{monitor.group && (
<span className="rounded-sm border border-border-soft bg-surface-2 px-1.5 font-mono text-[10px] uppercase tracking-[0.1em] text-text-tertiary">
{monitor.group}
</span>
)}
</div>
<p className="mt-1.5 break-all font-mono text-xs text-text-tertiary">{targetSummary(monitor)}</p>
{monitor.state.message && <p className="mt-1.5 text-sm text-text-secondary">{monitor.state.message}</p>}
@@ -336,11 +494,21 @@ export default function MonitorDetailPage() {
<div className="flex flex-col gap-5">
<div className="rounded-lg border border-border bg-surface">
<div className="flex items-baseline justify-between gap-3 border-b border-border-soft px-5 py-3.5">
<h2 className="text-[15px] font-semibold text-text-primary">Last 48 hours</h2>
<span className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">1 hour per bar</span>
<h2 className="text-[15px] font-semibold text-text-primary">
{rangeTitle(range)}
</h2>
<div className="flex items-center gap-3">
<span className="hidden font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary sm:inline">
{bucketLabel(range.bucketMs)}
</span>
<RangePicker value={range} onChange={setRange} />
</div>
</div>
<div className="p-5">
<History slots={slots} />
<History
slots={slots}
note={emptyRange ? "No check results recorded in this window yet" : undefined}
/>
</div>
<div className="grid grid-cols-2 border-t border-border-soft sm:grid-cols-4">
<Figure
+130 -5
View File
@@ -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: Record<DisplayStatus, number>;
);
}
const COLLAPSE_KEY = "vantage.monitors.collapsedGroups";
const UNGROUPED = "Ungrouped";
interface MonitorGroup {
name: string;
rows: { monitor: Monitor; rollups: Rollup[] }[];
}
/** Alphabetical, with the ungrouped remainder last so it reads as a leftover. */
function groupMonitors(rows: { monitor: Monitor; rollups: Rollup[] }[]): MonitorGroup[] {
const byName = new Map<string, MonitorGroup["rows"]>();
for (const row of rows) {
const name = row.monitor.group?.trim() || UNGROUPED;
const bucket = byName.get(name);
if (bucket) bucket.push(row);
else byName.set(name, [row]);
}
return [...byName.entries()]
.map(([name, groupRows]) => ({ name, rows: groupRows }))
.sort((a, b) => {
if (a.name === UNGROUPED) return 1;
if (b.name === UNGROUPED) return -1;
return a.name.localeCompare(b.name);
});
}
/* Collapse is per browser, not per account: it is which sections this person
has folded away, and a round trip to store it would be a write on every
click. */
function useCollapsedGroups() {
const [collapsed, setCollapsed] = useState<string[]>([]);
useEffect(() => {
try {
const raw = window.localStorage.getItem(COLLAPSE_KEY);
if (raw) setCollapsed(JSON.parse(raw) as string[]);
} catch {
/* A malformed or unavailable store just means nothing is folded. */
}
}, []);
const toggle = useCallback((name: string) => {
setCollapsed((prev) => {
const next = prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name];
try {
window.localStorage.setItem(COLLAPSE_KEY, JSON.stringify(next));
} catch {
/* Not being able to remember it is not a reason to refuse the click. */
}
return next;
});
}, []);
return { collapsed, toggle };
}
function GroupHeader({
group,
collapsed,
onToggle,
}: {
group: MonitorGroup;
collapsed: boolean;
onToggle: () => void;
}) {
const counts: Record<DisplayStatus, number> = { up: 0, down: 0, pending: 0, paused: 0 };
for (const { monitor } of group.rows) counts[displayStatus(monitor)] += 1;
const pct = uptimePct(group.rows.flatMap(({ rollups }) => rollups.slice(-24)));
return (
<button
type="button"
onClick={onToggle}
aria-expanded={!collapsed}
className="flex w-full items-center gap-3 border-b border-border-soft bg-surface-2 px-4 py-2.5 text-left transition-colors hover:bg-surface focus:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-accent sm:px-5"
>
<span className={`font-mono text-[10px] text-text-tertiary transition-transform ${collapsed ? "" : "rotate-90"}`} aria-hidden>
</span>
<span className="truncate font-mono text-[11px] uppercase tracking-[0.16em] text-text-secondary">{group.name}</span>
<span className="font-mono text-[11px] tabular-nums text-text-tertiary">
{group.rows.length} {group.rows.length === 1 ? "check" : "checks"}
</span>
{counts.down > 0 && (
<span className="rounded-sm border border-danger/40 bg-danger/10 px-1.5 font-mono text-[10px] uppercase tracking-[0.08em] text-danger">
{counts.down} down
</span>
)}
<span className="ml-auto font-mono text-[11px] tabular-nums text-text-secondary">
{formatPct(pct)}
{pct !== null && <span className="text-text-tertiary">%</span>}
</span>
</button>
);
}
function MonitorRow({ monitor, rollups }: { monitor: Monitor; rollups: Rollup[] }) {
const status = displayStatus(monitor);
const slots = buildSlots(rollups);
@@ -122,6 +224,12 @@ export default function MonitorsPage() {
})),
});
const { collapsed, toggle } = useCollapsedGroups();
const rows = (monitors ?? []).map((m, i) => ({ monitor: m, rollups: uptimeQueries[i]?.data ?? [] }));
const grouped = rows.some(({ monitor }) => !!monitor.group?.trim());
const groups = groupMonitors(rows);
const counts: Record<DisplayStatus, number> = { up: 0, down: 0, pending: 0, paused: 0 };
for (const m of monitors ?? []) counts[displayStatus(m)] += 1;
@@ -176,11 +284,28 @@ export default function MonitorsPage() {
<p className="text-right font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">Uptime 24h · response</p>
</div>
<div className="overflow-hidden rounded-lg border border-border bg-surface">
{monitors.map((m, i) => (
<MonitorRow key={m.monitor_id} monitor={m} rollups={uptimeQueries[i]?.data ?? []} />
))}
</div>
{grouped ? (
<div className="flex flex-col gap-3">
{groups.map((group) => {
const isCollapsed = collapsed.includes(group.name);
return (
<div key={group.name} className="overflow-hidden rounded-lg border border-border bg-surface">
<GroupHeader group={group} collapsed={isCollapsed} onToggle={() => toggle(group.name)} />
{!isCollapsed &&
group.rows.map(({ monitor, rollups }) => (
<MonitorRow key={monitor.monitor_id} monitor={monitor} rollups={rollups} />
))}
</div>
);
})}
</div>
) : (
<div className="overflow-hidden rounded-lg border border-border bg-surface">
{rows.map(({ monitor, rollups }) => (
<MonitorRow key={monitor.monitor_id} monitor={monitor} rollups={rollups} />
))}
</div>
)}
</>
)}
</div>
+30 -12
View File
@@ -3,7 +3,14 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { api, ChannelInput, ChannelType, NotificationChannel } from "@/lib/api";
import {
api,
CHANNEL_SECRET_FIELDS,
ChannelInput,
ChannelType,
NotificationChannel,
REDACTED_SECRET,
} from "@/lib/api";
import { Badge, Button, Card, ConfirmDialog, friendlyMessage, useToast } from "@/components/ui";
import { VulnAlertRulesCard } from "@/components/vulnerabilities/VulnAlertRulesCard";
@@ -67,17 +74,28 @@ function ChannelForm({ initial, onDone }: { initial?: NotificationChannel; onDon
))}
</select>
</div>
{CONFIG_FIELDS[type].map((field) => (
<div key={field}>
<label className={labelClass}>{field}</label>
<input
className={inputClass}
type={field === "password" ? "password" : "text"}
value={config[field] ?? ""}
onChange={(e) => setConfig({ ...config, [field]: e.target.value })}
/>
</div>
))}
{CONFIG_FIELDS[type].map((field) => {
// A secret comes back from the API as the sentinel, never as itself.
// The field renders empty rather than showing bullets in a URL box, and
// the sentinel is left sitting in state so an untouched save preserves
// the credential. Typing replaces it; clearing the field back to empty
// is how a credential is removed.
const secret = CHANNEL_SECRET_FIELDS[type].includes(field);
const value = config[field] ?? "";
const unchanged = secret && value === REDACTED_SECRET;
return (
<div key={field}>
<label className={labelClass}>{field}</label>
<input
className={inputClass}
type={field === "password" ? "password" : "text"}
value={unchanged ? "" : value}
placeholder={unchanged ? "unchanged — type to replace" : undefined}
onChange={(e) => setConfig({ ...config, [field]: e.target.value })}
/>
</div>
);
})}
{error && <p className="text-sm text-danger">{(error as Error).message}</p>}
<div className="flex gap-3">
<Button type="submit" variant="primary" loading={isPending}>
+1 -1
View File
@@ -41,7 +41,7 @@ export default function WorkloadsPage() {
<div className="p-4 sm:p-6 lg:p-8">
<div className="mb-6">
<h1 className="text-2xl font-bold text-text-primary">Workloads</h1>
<p className="mt-1 text-sm text-text-secondary">Containers and systemd services across the fleet, as last reported by each agent.</p>
<p className="mt-1 text-sm text-text-secondary">Containers and services across the fleet, as last reported by each agent.</p>
</div>
<Card className="mb-6">
+199 -172
View File
@@ -91,6 +91,7 @@ export function MonitorForm({
error?: Error | null;
}) {
const [name, setName] = useState(initial?.name ?? "");
const [group, setGroup] = useState(initial?.group ?? "");
const [type, setType] = useState<MonitorType>(initial?.type ?? "http");
const [url, setUrl] = useState(initial?.target.url ?? "");
const [host, setHost] = useState(initial?.target.host ?? "");
@@ -107,6 +108,11 @@ export function MonitorForm({
const [channelIds, setChannelIds] = useState<string[]>(initial?.channel_ids ?? []);
const { data: servers } = useQuery({ queryKey: ["servers"], queryFn: () => api.listServers() });
/* The group is free text, so the existing groups are offered as suggestions
rather than a fixed list grouping is a label people invent, and a
select would mean adding one before it could be used. */
const { data: allMonitors } = useQuery({ queryKey: ["monitors"], queryFn: () => api.listMonitors() });
const knownGroups = Array.from(new Set((allMonitors ?? []).map((m) => m.group).filter((g): g is string => !!g))).sort();
const { data: channels } = useQuery({ queryKey: ["channels"], queryFn: () => api.listChannels() });
function handleSubmit(e: React.FormEvent) {
@@ -128,7 +134,7 @@ export function MonitorForm({
target.host = host;
target.port = port;
}
onSubmit({ name, type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds });
onSubmit({ name, group: group.trim(), type, target, interval_sec: intervalSec, retries, runner, enabled, channel_ids: channelIds });
}
const runnerName = runner === "server" ? "the control plane" : servers?.find((s) => s.server_id === runner)?.hostname || "an agent";
@@ -138,193 +144,214 @@ export function MonitorForm({
const downAfter = formatDuration(new Date(Date.now() - intervalSec * Math.max(retries, 1) * 1000).toISOString());
return (
<form onSubmit={handleSubmit} className="flex max-w-3xl flex-col gap-5">
<Section title="Check" hint={typeCopy[type].target}>
<Field label="Name" help="Shown in the fleet list and in every alert this check sends.">
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="Billing API" required />
</Field>
<div>
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Kind</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{(Object.keys(typeCopy) as MonitorType[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setType(t)}
aria-pressed={type === t}
className={`rounded-lg border px-3 py-2.5 text-left transition-colors ${
type === t
? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-accent/40"
}`}
>
<span
className={`block font-mono text-[11px] uppercase tracking-[0.1em] ${type === t ? "text-accent" : "text-text-secondary"}`}
>
{typeCopy[t].title}
</span>
<span className="mt-1 block text-[11px] leading-snug text-text-tertiary">{typeCopy[t].blurb}</span>
</button>
))}
</div>
</div>
{type === "http" && (
<>
<Field label="URL">
<form onSubmit={handleSubmit} className="flex flex-col gap-5">
<div className="grid grid-cols-1 items-start gap-5 lg:grid-cols-[minmax(0,1fr)_minmax(320px,400px)]">
<Section title="Check" hint={typeCopy[type].target}>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
<Field label="Name" help="Shown in the fleet list and in every alert this check sends.">
<input className={inputClass} value={name} onChange={(e) => setName(e.target.value)} placeholder="Billing API" required />
</Field>
<Field label="Group" help="Optional heading on the monitors page. Nothing else reads it.">
<input
className={`${inputClass} font-mono`}
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/healthz"
required
className={inputClass}
value={group}
onChange={(e) => setGroup(e.target.value)}
placeholder="Production"
list="monitor-groups"
maxLength={48}
/>
<datalist id="monitor-groups">
{knownGroups.map((g) => (
<option key={g} value={g} />
))}
</datalist>
</Field>
</div>
<div>
<span className="mb-1.5 block text-sm font-medium text-text-secondary">Kind</span>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{(Object.keys(typeCopy) as MonitorType[]).map((t) => (
<button
key={t}
type="button"
onClick={() => setType(t)}
aria-pressed={type === t}
className={`rounded-lg border px-3 py-2.5 text-left transition-colors ${
type === t
? "border-accent bg-accent/10"
: "border-border bg-surface-2 hover:border-accent/40"
}`}
>
<span
className={`block font-mono text-[11px] uppercase tracking-[0.1em] ${type === t ? "text-accent" : "text-text-secondary"}`}
>
{typeCopy[t].title}
</span>
<span className="mt-1 block text-[11px] leading-snug text-text-tertiary">{typeCopy[t].blurb}</span>
</button>
))}
</div>
</div>
{type === "http" && (
<>
<Field label="URL">
<input
className={`${inputClass} font-mono`}
value={url}
onChange={(e) => setUrl(e.target.value)}
placeholder="https://example.com/healthz"
required
/>
</Field>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Method">
<select className={inputClass} value={method} onChange={(e) => setMethod(e.target.value)}>
<option>GET</option>
<option>HEAD</option>
</select>
</Field>
<Field label="Expected status">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={expectedStatus}
onChange={(e) => setExpectedStatus(Number(e.target.value))}
/>
</Field>
</div>
<Field label="Body must contain" help="Optional. The check fails if the response body is missing this text.">
<input className={inputClass} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="ok" />
</Field>
<Check
checked={insecure}
onChange={setInsecure}
title="Accept any certificate"
detail="Use for self-signed or expired certificates. The check stops reporting TLS problems."
/>
</>
)}
{(type === "tcp" || type === "tls" || type === "icmp") && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Host">
<input
className={`${inputClass} font-mono`}
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="example.com"
required
/>
</Field>
{type !== "icmp" && (
<Field label="Port">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={port}
onChange={(e) => setPort(Number(e.target.value))}
/>
</Field>
)}
</div>
)}
{type === "tls" && (
<Field label="Warn this many days before expiry">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={tlsWarnDays}
onChange={(e) => setTlsWarnDays(Number(e.target.value))}
min={1}
/>
</Field>
)}
</Section>
<div className="flex flex-col gap-5">
<Section title="Schedule">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Method">
<select className={inputClass} value={method} onChange={(e) => setMethod(e.target.value)}>
<option>GET</option>
<option>HEAD</option>
</select>
</Field>
<Field label="Expected status">
<Field label="Run every" help="Seconds between checks. Minimum 10.">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={expectedStatus}
onChange={(e) => setExpectedStatus(Number(e.target.value))}
value={intervalSec}
onChange={(e) => setIntervalSec(Number(e.target.value))}
min={10}
/>
</Field>
<Field label="Fails after" help="Consecutive failures before an incident opens.">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={retries}
onChange={(e) => setRetries(Number(e.target.value))}
min={1}
/>
</Field>
</div>
<Field label="Body must contain" help="Optional. The check fails if the response body is missing this text.">
<input className={inputClass} value={keyword} onChange={(e) => setKeyword(e.target.value)} placeholder="ok" />
</Field>
<Check
checked={insecure}
onChange={setInsecure}
title="Accept any certificate"
detail="Use for self-signed or expired certificates. The check stops reporting TLS problems."
/>
</>
)}
{(type === "tcp" || type === "tls" || type === "icmp") && (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Host">
<input
className={`${inputClass} font-mono`}
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder="example.com"
required
/>
<Field
label="Runs from"
help="Pick an agent for anything only reachable from inside that network. Everything else runs centrally."
>
<select className={inputClass} value={runner} onChange={(e) => setRunner(e.target.value)}>
<option value="server">Control plane</option>
{servers?.map((s) => (
<option key={s.server_id} value={s.server_id}>
Agent · {s.hostname}
</option>
))}
</select>
</Field>
{type !== "icmp" && (
<Field label="Port">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={port}
onChange={(e) => setPort(Number(e.target.value))}
/>
</Field>
<p className="rounded-lg bg-well px-4 py-3 font-mono text-[11.5px] leading-relaxed text-text-secondary">
Checked every {intervalSec} s from {runnerName}. Reported down after {retries}{" "}
{retries === 1 ? "failure" : "consecutive failures"} roughly {downAfter}.
</p>
</Section>
<Section title="Alerts" hint={channelIds.length > 0 ? `${channelIds.length} selected` : undefined}>
{!channels || channels.length === 0 ? (
<p className="text-sm text-text-secondary">
No channels exist yet, so nobody will be told when this check fails.{" "}
<Link href="/settings/notifications" className="text-accent hover:underline">
Add a channel
</Link>
.
</p>
) : (
<div className="flex flex-col gap-2">
{channels.map((ch) => (
<Check
key={ch.channel_id}
checked={channelIds.includes(ch.channel_id)}
onChange={(v) =>
setChannelIds((prev) => (v ? [...prev, ch.channel_id] : prev.filter((id) => id !== ch.channel_id)))
}
title={ch.name}
detail={
<span className="font-mono uppercase tracking-[0.1em]">
{ch.type}
{!ch.enabled && " · disabled, sends nothing"}
</span>
}
/>
))}
</div>
)}
</div>
)}
{type === "tls" && (
<Field label="Warn this many days before expiry">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={tlsWarnDays}
onChange={(e) => setTlsWarnDays(Number(e.target.value))}
min={1}
<Check
checked={enabled}
onChange={setEnabled}
title="Start checking straight away"
detail="Turn this off to save the monitor without running it. You can resume it any time."
/>
</Field>
)}
</Section>
<Section title="Schedule">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Field label="Run every" help="Seconds between checks. Minimum 10.">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={intervalSec}
onChange={(e) => setIntervalSec(Number(e.target.value))}
min={10}
/>
</Field>
<Field label="Fails after" help="Consecutive failures before an incident opens.">
<input
type="number"
className={`${inputClass} font-mono tabular-nums`}
value={retries}
onChange={(e) => setRetries(Number(e.target.value))}
min={1}
/>
</Field>
</Section>
</div>
<Field
label="Runs from"
help="Pick an agent for anything only reachable from inside that network. Everything else runs centrally."
>
<select className={inputClass} value={runner} onChange={(e) => setRunner(e.target.value)}>
<option value="server">Control plane</option>
{servers?.map((s) => (
<option key={s.server_id} value={s.server_id}>
Agent · {s.hostname}
</option>
))}
</select>
</Field>
<p className="rounded-lg bg-well px-4 py-3 font-mono text-[11.5px] leading-relaxed text-text-secondary">
Checked every {intervalSec} s from {runnerName}. Reported down after {retries}{" "}
{retries === 1 ? "failure" : "consecutive failures"} roughly {downAfter}.
</p>
</Section>
<Section title="Alerts" hint={channelIds.length > 0 ? `${channelIds.length} selected` : undefined}>
{!channels || channels.length === 0 ? (
<p className="text-sm text-text-secondary">
No channels exist yet, so nobody will be told when this check fails.{" "}
<Link href="/settings/notifications" className="text-accent hover:underline">
Add a channel
</Link>
.
</p>
) : (
<div className="flex flex-col gap-2">
{channels.map((ch) => (
<Check
key={ch.channel_id}
checked={channelIds.includes(ch.channel_id)}
onChange={(v) =>
setChannelIds((prev) => (v ? [...prev, ch.channel_id] : prev.filter((id) => id !== ch.channel_id)))
}
title={ch.name}
detail={
<span className="font-mono uppercase tracking-[0.1em]">
{ch.type}
{!ch.enabled && " · disabled, sends nothing"}
</span>
}
/>
))}
</div>
)}
<Check
checked={enabled}
onChange={setEnabled}
title="Start checking straight away"
detail="Turn this off to save the monitor without running it. You can resume it any time."
/>
</Section>
</div>
{error && (
<p className="rounded-lg border border-danger/30 bg-danger/10 px-4 py-3 text-sm text-danger">{error.message}</p>
+44 -4
View File
@@ -1,6 +1,6 @@
"use client";
import { Monitor, MonitorStatus, Rollup } from "@/lib/api";
import { Monitor, MonitorSample, MonitorStatus, Rollup } from "@/lib/api";
/*
* Shared vocabulary for the monitors screens.
@@ -109,8 +109,10 @@ export function formatDuration(fromIso: string, toIso?: string): string {
/* ------------------------------------------------------------------- tape */
export interface Slot {
/** Start of the hour this slot covers. */
/** Start of the period this slot covers. */
at: Date;
/** Length of that period. An hour for a rollup slot, less for a sample bucket. */
spanMs: number;
/** Percentage of checks that passed, or null when no check ran. */
pct: number | null;
checks: number;
@@ -141,6 +143,7 @@ export function buildSlots(rollups: Rollup[], hours = 48): Slot[] {
const r = byHour.get(at.getTime());
slots.push({
at,
spanMs: 3600_000,
checks: r?.checks ?? 0,
pct: r && r.checks > 0 ? (r.up_count / r.checks) * 100 : null,
latency: r && r.checks > 0 ? r.sum_latency / r.checks : null,
@@ -149,6 +152,41 @@ export function buildSlots(rollups: Rollup[], hours = 48): Slot[] {
return slots;
}
/**
* The same tape, bucketed from individual check results rather than hourly
* rollups this is what the sub-hour ranges are drawn from, because an hourly
* rollup cannot say anything about a window shorter than an hour.
*
* Buckets are built from the clock like buildSlots, for the same reason: a
* window with no checks in it has to read as a gap and not shorten the tape.
* `endAt` is passed rather than read from the clock so every series on one
* screen shares an edge.
*/
export function buildSampleSlots(samples: MonitorSample[], windowMs: number, bucketMs: number, endAt = Date.now()): Slot[] {
const count = Math.max(Math.round(windowMs / bucketMs), 1);
const end = Math.floor(endAt / bucketMs) * bucketMs + bucketMs;
const start = end - count * bucketMs;
const totals = Array.from({ length: count }, () => ({ checks: 0, up: 0, latency: 0 }));
for (const sample of samples) {
const t = new Date(sample.at).getTime();
if (t < start || t >= end) continue;
const bucket = totals[Math.floor((t - start) / bucketMs)];
if (!bucket) continue;
bucket.checks += 1;
if (sample.up) bucket.up += 1;
bucket.latency += sample.latency_ms;
}
return totals.map((bucket, i) => ({
at: new Date(start + i * bucketMs),
spanMs: bucketMs,
checks: bucket.checks,
pct: bucket.checks > 0 ? (bucket.up / bucket.checks) * 100 : null,
latency: bucket.checks > 0 ? bucket.latency / bucket.checks : null,
}));
}
function slotColor(s: Slot): string {
if (s.pct === null) return "bg-border-soft";
if (s.pct >= 99.5) return "bg-success";
@@ -162,7 +200,9 @@ function slotHeight(s: Slot): number {
return 55 + (s.pct - 80) * 2.2;
}
function slotTitle(s: Slot): string {
/** One line of plain text for a slot the tape's tooltip and the chart's
* accessible name for a bar, so both read the same hour the same way. */
export function slotLabel(s: Slot): string {
const when = s.at.toLocaleString(undefined, { weekday: "short", hour: "2-digit", minute: "2-digit" });
if (s.pct === null) return `${when} · no checks ran`;
return `${when} · ${s.pct.toFixed(1)}% up · ${s.checks} checks`;
@@ -179,7 +219,7 @@ export function Tape({ slots, height = "h-9", live = true }: { slots: Slot[]; he
return (
<div className={`relative flex ${height} items-end gap-px rounded-sm bg-well p-[3px]`}>
{slots.map((s) => (
<div key={s.at.getTime()} className="flex h-full flex-1 items-end" title={slotTitle(s)}>
<div key={s.at.getTime()} className="flex h-full flex-1 items-end" title={slotLabel(s)}>
<div className={`w-full rounded-[1px] ${slotColor(s)}`} style={{ height: `${slotHeight(s)}%` }} />
</div>
))}
@@ -82,7 +82,7 @@ export function MaintenanceTab({
<span className="font-mono text-xs text-text-secondary">{u.current_version || "n/a"}</span>
</Td>
<Td label={isWindows ? "KB" : "Available"}>
<span className="font-mono text-xs text-success">{u.new_version}</span>
<span className="font-mono text-xs text-success">{u.new_version || "n/a"}</span>
</Td>
</Tr>
))}
@@ -94,6 +94,7 @@ export function WorkloadList({ serverId, canControl, isWindows }: { serverId: st
workload={w}
canControl={canControl}
busy={control.isPending}
isWindows={isWindows}
onAction={(action) => control.mutate({ w, action })}
onLogs={() => setLogTarget(w)}
/>
+3 -1
View File
@@ -45,12 +45,14 @@ export function WorkloadRow({
workload,
canControl,
busy,
isWindows,
onAction,
onLogs,
}: {
workload: Workload;
canControl: boolean;
busy: boolean;
isWindows: boolean;
onAction: (action: WorkloadAction) => void;
onLogs: () => void;
}) {
@@ -66,7 +68,7 @@ export function WorkloadRow({
{!!w.restarts && w.restarts > 0 && <Badge variant="warning">{w.restarts} restarts</Badge>}
</div>
<p className="mt-1 truncate text-xs text-text-secondary">
{w.kind === "container" ? w.image || "no image" : "systemd unit"}
{w.kind === "container" ? w.image || "no image" : isWindows ? "Windows service" : "systemd unit"}
{w.ports && w.ports.length > 0 && <span className="ml-2 font-mono">{w.ports.join(" ")}</span>}
</p>
</div>
+35 -1
View File
@@ -62,6 +62,8 @@ export interface MonitorState {
export interface Monitor {
monitor_id: string;
name: string;
/** Display-only heading on the monitors page. Empty means ungrouped. */
group?: string;
type: MonitorType;
target: MonitorTarget;
interval_sec: number;
@@ -75,6 +77,7 @@ export interface Monitor {
export interface MonitorInput {
name: string;
group?: string;
type: MonitorType;
target: MonitorTarget;
interval_sec: number;
@@ -92,6 +95,14 @@ export interface Incident {
cause?: string;
}
/** One check result. Kept for 48 hours, which is what the sub-hour views read. */
export interface MonitorSample {
monitor_id: string;
at: string;
up: boolean;
latency_ms: number;
}
export interface Rollup {
monitor_id: string;
period_start: string;
@@ -102,6 +113,25 @@ export interface Rollup {
export type ChannelType = "webhook" | "smtp" | "discord" | "slack" | "telegram";
/**
* What a channel's secret config values read as over the API. Writing it back
* unchanged preserves the stored credential; anything else, including "", is
* written verbatim.
*
* Mirrors `models.RedactedSecret` and `models.channelSecretKeys` in
* `server/internal/models/channel.go` change both in the same commit, the
* same hazard as the mirrored token blocks.
*/
export const REDACTED_SECRET = "••••••••";
export const CHANNEL_SECRET_FIELDS: Record<ChannelType, string[]> = {
webhook: ["url"],
slack: ["url"],
discord: ["url"],
telegram: ["token"],
smtp: ["password"],
};
export interface NotificationChannel {
channel_id: string;
name: string;
@@ -648,6 +678,10 @@ export const api = {
return request<Rollup[]>(`/monitors/${monitorId}/uptime`);
},
getMonitorSamples(monitorId: string, minutes: number): Promise<MonitorSample[]> {
return request<MonitorSample[]>(`/monitors/${monitorId}/samples?minutes=${minutes}`);
},
listChannels(): Promise<NotificationChannel[]> {
return request<NotificationChannel[]>("/channels");
},
@@ -1122,7 +1156,7 @@ export const vulnerabilities = {
export type WorkloadKind = "container" | "unit";
export type WorkloadAction = "start" | "stop" | "restart";
/** One container or one systemd unit.
/** One Docker container, one systemd unit, or one Windows service.
*
* `state` is deliberately not a shared vocabulary across the two kinds:
* containers report running/exited/paused/restarting/created, units report
File diff suppressed because one or more lines are too long