Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2a660697c5 | ||
|
|
0c08dda635 | ||
|
|
22b99ff895 | ||
|
|
2fab784ba7 | ||
|
|
83cdf92575 | ||
|
|
aa1c8e4aa1 | ||
|
|
ac61015cc0 | ||
|
|
a0fbf5b9ba | ||
|
|
ddf0814803 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
`
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user