Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
a582b52
chore(fly): deploy the jp-1 Tokyo system agent
fclairamb Aug 4, 2026
7efff24
docs: add specs for batch 2026-08-05
fclairamb Aug 4, 2026
1d7bcef
chore: start implementing Per-status-page availability color thresholds
fclairamb Aug 4, 2026
30f19b3
docs: add implementation plan for Per-status-page availability color …
fclairamb Aug 4, 2026
c599cea
feat(statuspages): per-page availability color thresholds + small-buc…
fclairamb Aug 4, 2026
06522b4
chore(badges): point duplicated availability-color literals at shared…
fclairamb Aug 4, 2026
295515a
refactor(statuspages): satisfy lint (cyclop, varnamelen, nonamedreturns)
fclairamb Aug 5, 2026
9eff235
test(statuspages): availability thresholds — status mapping, calibrat…
fclairamb Aug 5, 2026
0286925
feat(dash0): availability thresholds inputs on the status page edit form
fclairamb Aug 5, 2026
fd0864e
test(e2e): availability thresholds round-trip on the status page edit…
fclairamb Aug 5, 2026
4105b0d
chore: all checks passing for Per-status-page availability color thre…
fclairamb Aug 5, 2026
1e0cbe9
docs: archive completed spec Per-status-page availability color thres…
fclairamb Aug 5, 2026
becb03b
fix(server): raise entity slug max length to 100 chars
fclairamb Aug 5, 2026
613f6df
fix(dash0): raise entity slug max length to 100 chars
fclairamb Aug 5, 2026
27dc792
fix(db): widen slug length CHECK constraints to 100 chars in migratio…
fclairamb Aug 5, 2026
c13893a
test: boundary coverage for the widened entity slug length (3-100 chars)
fclairamb Aug 5, 2026
c97a001
docs: correct spec's wrong "no migration needed" claim
fclairamb Aug 5, 2026
f19d8b9
fix(test): satisfy lint in the migration 009 slug-rebuild test
fclairamb Aug 5, 2026
5feae50
chore: all checks passing for Entity slug max length 100
fclairamb Aug 5, 2026
24d81a4
docs: archive completed spec Entity slug max length 100
fclairamb Aug 5, 2026
27b66ca
feat(dash0): sync checks search box to ?q= URL param
fclairamb Aug 5, 2026
2f5a7d7
feat(dash0): sync remaining list-page search boxes to ?q= URL param
fclairamb Aug 5, 2026
da6506e
chore: all checks passing for Search inputs sync to the URL as ?q=
fclairamb Aug 5, 2026
8be2445
docs: archive completed spec Search inputs sync to the URL as ?q=
fclairamb Aug 5, 2026
de691c7
feat(db): add ListAllAgents for fleet-wide agent view
fclairamb Aug 5, 2026
a2f02b1
feat(api): add GET /api/v1/system/agents fleet-wide endpoint
fclairamb Aug 5, 2026
d725b27
test(agents): cover ListAllAgents and the /system/agents auth gate
fclairamb Aug 5, 2026
29759e0
feat(dash0): add fleet-wide Agents tab to the server page
fclairamb Aug 5, 2026
ee04bbc
i18n(dash0): add agents fleet tab strings to all four locales
fclairamb Aug 5, 2026
fa2e938
docs(wiki): amend deported-agents for the fleet-wide Agents view
fclairamb Aug 5, 2026
e4f8086
chore: all checks passing for Fleet-wide Agents tab on the server page
fclairamb Aug 5, 2026
9bc5d36
docs: archive completed spec Fleet-wide Agents tab on the server page
fclairamb Aug 5, 2026
9de53ba
fix(dash0): stop the ?q= write-back from looping a logged-out deep link
fclairamb Aug 5, 2026
9bab7b0
test(e2e): wait for the escalation-policy PATCH instead of networkidle
fclairamb Aug 5, 2026
387a0b9
test(e2e): wait for server-side incident resolution before the live a…
fclairamb Aug 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 17 additions & 12 deletions deploy/fly/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ another way to staff a region you already have.
| `sjc` | San Jose, California | your US-West slug |
| `lhr` | London, UK | your UK slug, if you define one |
| `fra` | Frankfurt, Germany | your EU-Central slug, if you define one |
| `nrt` | Tokyo, Japan | `jp-1` on the k8xp dev deployment — see [`fly.nrt.toml`](fly.nrt.toml) |
| `sin` | Singapore | your APAC slug |
| `syd` | Sydney, Australia | your AU slug |
| `gru` | São Paulo, Brazil | your South-America slug |
Expand Down Expand Up @@ -113,20 +114,24 @@ fly deploy -c fly.cdg.toml
| `SP_NODE_ROLE` | `fly.toml` `[env]` | `agent` |
| `SP_AGENT_SERVER_URL` | fly secret | the API base URL the agent dials |
| `SP_AGENT_ENROLLMENT_TOKEN` | fly secret | the region's **multi-use system** token |
| `SP_AGENT_NAME` | runtime | `$FLY_MACHINE_ID` — see below |
| `SP_AGENT_NAME` | **unset — do not set it** | falls back to the machine ID, see below |
| `SP_AGENT_KEYS_FILE` | `fly.toml` `[env]` | `/data/agent-keys.json` (per-machine volume) |

`SP_AGENT_NAME` must be per-machine, and fly secrets are **app-wide**, so it
cannot be a secret. Set it from fly's own runtime variable, e.g. with a wrapper
entrypoint:

```dockerfile
ENTRYPOINT ["/bin/sh", "-c", "SP_AGENT_NAME=\"${FLY_MACHINE_ID}\" exec /solidping serve"]
```

or by templating it into the machine config at deploy time. The name is
cosmetic — identity is the keypair — but it is what makes the agents list line
up with `fly machine list`.
**Leave `SP_AGENT_NAME` unset.** The agent already defaults its name to
`os.Hostname()` ([`agentmode.go`](../../server/internal/agentmode/agentmode.go)),
which on a fly machine is the machine ID — so the agents list lines up with
`fly machine list` with no configuration at all. (The default is verified in
code; the resulting name is not directly observable — system agents are
`organization_uid = NULL` and the only listing route is the org-scoped
`/orgs/:org/agents`. Confirm platform-side if it matters.)

Setting it in `fly.toml` `[env]` (or as a secret) would actively make things
worse: both are **app-wide**, so every machine in the region would report the
same name. And the wrapper-entrypoint trick this README used to recommend
cannot work against the published image — it is built `FROM
gcr.io/distroless/base-debian13:nonroot`, which ships no `/bin/sh`.

The name is cosmetic in any case: identity is the keypair, not the name.

## The multi-machine story

Expand Down
85 changes: 85 additions & 0 deletions deploy/fly/fly.nrt.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# SolidPing platform ("system") check agent — Tokyo, Japan.
#
# Generated from fly.toml (the template). Serves the SolidPing region slug
# `jp-1` ("🇯🇵 JP1"), which is staffed ONLY by this app — there is no in-cluster
# checks Deployment for it, unlike `default`/`us-1`/`eu-2`. If these machines
# stop, jp-1 goes dark.
#
# Target API: https://solidping.k8xp.com (the dev deployment; prod is scaled to
# 0). The matching enrollment token lives in the k8xp repo at
# k8s/solidping/overlays/dev/secret.yaml under
# SP_SYSTEM_AGENT_ENROLLMENT_TOKENS, and the jp-1 slug must stay in that
# overlay's SP_REGIONS — enrollment validates the token's region against the
# catalog and refuses an unknown slug.
#
# See README.md and spec 2026-07-27-01.
#
# =============================================================================
# !! AUTO-STOP MUST STAY OFF. A STOPPED AGENT IS A REGION THAT GOES DARK. !!
# =============================================================================
# This app declares NO service block, so fly's proxy owns none of these
# machines and auto-stop can never apply. `[[restart]] policy = "always"` is
# the active counterpart. Read the block at the end of fly.toml before adding
# any [http_service].

app = "solidping-agent-nrt"
primary_region = "nrt"

[build]
# Pinned to the release the API is running (k8xp dev is on 0.7.1). Agent mode
# is a runtime role, not a separate artifact — same image the cluster runs.
image = "ghcr.io/fclairamb/solidping:0.7.1"

[env]
# The role switch. Everything else about the process follows from it.
SP_NODE_ROLE = "agent"
LOG_LEVEL = "info"
# NOTE: no SP_AGENT_KEYS_FILE and no volume — identity comes from the
# SP_AGENT_KEYS secret instead (see the mounts note below).
# NOTE: SP_AGENT_NAME is deliberately NOT set. The agent falls back to
# os.Hostname() (agentmode.go), and a fly machine's hostname is its machine
# ID — so the agents list lines up 1:1 with `fly machine list` for free.
# Setting it here would be WRONG: fly [env] is app-wide, so every machine
# would report the same name. (fly.toml has no per-machine templating, and
# the wrapper-ENTRYPOINT trick can't work either — the image is distroless
# and has no /bin/sh.)

# NO VOLUME — identity is pinned via the SP_AGENT_KEYS secret (base64 of the
# identity JSON: both keypairs + agentUid + region). agentmode.loadIdentity()
# checks SP_AGENT_KEYS FIRST and only then the keys file, so this wins outright.
#
# Why this beats the volume here:
# - The pinned identity is already enrolled (agentUid is set), so every boot
# takes the RECONNECT path, not the enroll path. `fly deploy` recreates
# machines, and with a volume each recreation enrolled a NEW agent, leaving
# rows for the 7-day agent_gc. Now the agent row is stable across deploys.
# - persistIdentity() — which logs the private keys in the clear at INFO —
# is only reached from the enrollment path (onIdentityChange, ws.go). A
# pre-enrolled identity therefore never re-logs its keys.
#
# !! THE TRADE-OFF, READ BEFORE SCALING !!
# fly secrets are APP-WIDE, so every machine in this app now shares ONE private
# key. That is acceptable at count=1 and NOT acceptable above it: the design
# requires one keypair per machine (see wiki/features/deported-agents.md).
# Before `fly scale count > 1`, go back to the volume model — restore the
# [[mounts]] block and SP_AGENT_KEYS_FILE, and unset the SP_AGENT_KEYS secret.
#
# 256mb is deliberate, and measured — not a guess. The in-cluster workers
# running this exact workload sit at 27 MiB (eu-2) and 93 MiB (us-1, the
# busiest with real checks), so this leaves ~2.7x headroom over observed peak.
# At nrt prices that is $2.21/mo vs $3.62/mo for 512mb.
#
# If this app ever OOMs, RAISE THIS FIRST and don't be clever about it: jp-1 has
# no in-cluster checks Deployment behind it, so an OOM-killed machine is a
# region that goes dark. The `[[restart]] policy = "always"` below brings it
# back, but a crash loop still means missed checks with nothing alerting on it.
[[vm]]
size = "shared-cpu-1x"
memory = "256mb"
cpus = 1

# ACTIVE always-on directive: a machine that exits for any reason comes back.
[[restart]]
policy = "always"

# NO SERVICE IS DECLARED — ON PURPOSE. See fly.toml's closing block.
72 changes: 72 additions & 0 deletions server/internal/app/openapi/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7716,6 +7716,7 @@ components:
- showResponseTime
- historyDays
- historyPeriod
- availabilityThresholds
properties:
uid:
type: string
Expand Down Expand Up @@ -7786,6 +7787,19 @@ components:
removed. Authenticated endpoints only.
items:
$ref: "#/components/schemas/DnsRecord"
settings:
$ref: "#/components/schemas/StatusPageSettings"
nullable: true
description: >-
Per-page display customization, mirroring the storage shape.
An unset section (e.g. availability absent) means "using the
default". Present on both admin and public payloads.
availabilityThresholds:
$ref: "#/components/schemas/AvailabilityThresholds"
description: >-
The RESOLVED effective availability thresholds (submitted value,
or the platform default when unset) — never null, so consumers
never need to know the defaults themselves.
sections:
type: array
items:
Expand All @@ -7798,6 +7812,46 @@ components:
type: string
format: date-time

AvailabilitySettings:
type: object
description: >-
Customizes the green/amber/red availability thresholds rendered on a
status page's bars. Omitted fields fall back to the platform defaults
(thresholdUp 99.9, thresholdDegraded 99.0).
properties:
thresholdUp:
type: number
format: double
description: Green floor — pct >= this renders "up". Default 99.9.
thresholdDegraded:
type: number
format: double
description: >-
Amber floor — pct >= this (and < thresholdUp) renders "degraded";
below it renders "down", subject to the small-bucket calibration
guard (a single failed sample never renders down). Default 99.0.

StatusPageSettings:
type: object
description: >-
Per-page display customization. Typed rather than a free-form map;
unknown keys are rejected on write (VALIDATION_ERROR).
properties:
availability:
$ref: "#/components/schemas/AvailabilitySettings"
nullable: true

AvailabilityThresholds:
type: object
required: [thresholdUp, thresholdDegraded]
properties:
thresholdUp:
type: number
format: double
thresholdDegraded:
type: number
format: double

DnsRecord:
type: object
required: [type, name, value]
Expand Down Expand Up @@ -7856,6 +7910,13 @@ components:
Optional customer-owned hostname to bind to the new page. A fresh
verification token is generated; verify it afterward via the
custom-domain verify endpoint.
settings:
$ref: "#/components/schemas/StatusPageSettings"
description: >-
Optional page settings (e.g. availability thresholds) at create
time. Unknown keys are rejected (VALIDATION_ERROR). The effective
thresholds (submitted or default) must satisfy
0 < thresholdDegraded < thresholdUp <= 100.

UpdateStatusPageRequest:
type: object
Expand Down Expand Up @@ -7895,6 +7956,17 @@ components:
Set/change the custom domain (non-empty), or clear it (null or "").
Omit the field to leave the domain unchanged. Setting a new domain
generates a fresh verification token and resets verification.
settings:
$ref: "#/components/schemas/StatusPageSettings"
nullable: true
description: >-
No deep merge. Omit the field to leave settings untouched entirely.
When present, each top-level section provided (e.g. availability)
replaces that section wholly (not a field-by-field merge); an
explicit `null` section resets it to defaults; a section not
mentioned is left untouched. Unknown keys are rejected
(VALIDATION_ERROR). The effective thresholds (submitted or
default) must satisfy 0 < thresholdDegraded < thresholdUp <= 100.

StatusPageSection:
type: object
Expand Down
4 changes: 4 additions & 0 deletions server/internal/app/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -1082,6 +1082,10 @@ func (s *Server) SetupRoutes(ctx context.Context) {
systemActions.POST("/email-inbox/sync", systemHandler.EmailInboxSync)
systemActions.GET("/activation", systemHandler.ListActivationFunnel)
systemActions.GET("/scheduling/lane-load", systemHandler.LaneLoad)
// Fleet-wide agent view (spec 2026-08-05-01): org agents are already
// listed per-org, but system agents (kind='system', no owning org) are
// otherwise visible nowhere short of querying the DB by hand.
systemActions.GET("/agents", agentsAdminHandler.ListAllAgents)

// Org entitlements routes. The handler does its own auth gating
// (service token preferred for SaaS billing service; admin user
Expand Down
17 changes: 13 additions & 4 deletions server/internal/db/models/status_page.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,14 @@ type StatusPage struct {
CustomDomainCheckedAt *time.Time `bun:"custom_domain_checked_at"`
// CustomDomainFailures counts consecutive re-verification failures. At 3 the
// verification is cleared (domain release/takeover protection).
CustomDomainFailures int `bun:"custom_domain_failures,notnull,default:0"`
CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp"`
UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp"`
DeletedAt *time.Time `bun:"deleted_at"`
CustomDomainFailures int `bun:"custom_domain_failures,notnull,default:0"`
// Settings holds per-page display customization (e.g. availability color
// thresholds), typed rather than a free-form map so keys stay
// discoverable (spec 2026-08-03-01). Column is NOT NULL DEFAULT '{}'.
Settings StatusPageSettings `bun:"settings,type:jsonb,notnull"`
CreatedAt time.Time `bun:"created_at,notnull,default:current_timestamp"`
UpdatedAt time.Time `bun:"updated_at,notnull,default:current_timestamp"`
DeletedAt *time.Time `bun:"deleted_at"`
}

// NewStatusPage creates a new status page with generated UID.
Expand Down Expand Up @@ -128,6 +132,11 @@ type StatusPageUpdate struct {
// string clears the column (the appearance editor's "empty textarea"), a
// nil pointer leaves it untouched.
CustomCSS *string
// Settings overwrites the whole settings column when non-nil (the caller
// — statuspages.Service — has already applied the no-deep-merge
// section-replace-or-reset semantics against the current value). A nil
// pointer leaves the column untouched.
Settings *StatusPageSettings
}

// StatusPageCustomDomainUpdate is the whole-lifecycle writer for a status
Expand Down
112 changes: 112 additions & 0 deletions server/internal/db/models/status_page_settings.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package models

import (
"database/sql/driver"
"encoding/json"
"errors"
"fmt"
)

// ErrStatusPageSettingsScanType is returned when StatusPageSettings.Scan
// receives a value of an unexpected SQL type.
var ErrStatusPageSettingsScanType = errors.New("unsupported type for StatusPageSettings.Scan")

// Default availability color thresholds (spec 2026-08-03-01), used whenever a
// page's settings (or a section within it) don't specify a value. Badges
// (internal/handlers/badges) intentionally stay on these same numeric
// defaults without reading page settings — see that package's Decisions.
const (
// DefaultAvailabilityThresholdUp is the green floor: pct >= this is "up".
DefaultAvailabilityThresholdUp = 99.9
// DefaultAvailabilityThresholdDegraded is the amber floor: pct >= this
// (and below the up threshold) is "degraded"; below it is "down".
DefaultAvailabilityThresholdDegraded = 99.0
)

// StatusPageSettings is the typed decode target for status_pages.settings
// (Postgres jsonb / SQLite text). It is the home for per-page display
// customization knobs — today just availability thresholds — added without a
// two-dialect migration each time. Keep it a typed struct, not a free-form
// map, so keys stay discoverable and validation lives in one place.
type StatusPageSettings struct {
Availability *AvailabilitySettings `json:"availability,omitempty"`
}

// AvailabilitySettings customizes the green/amber/red availability
// thresholds rendered on a status page's bars. Nil fields fall back to the
// package defaults (DefaultAvailabilityThresholdUp/Degraded).
type AvailabilitySettings struct {
// ThresholdUp is the green floor (pct >= this renders "up"). nil = 99.9.
ThresholdUp *float64 `json:"thresholdUp,omitempty"`
// ThresholdDegraded is the amber floor (pct >= this and < ThresholdUp
// renders "degraded"; below it renders "down", subject to the
// small-bucket calibration guard). nil = 99.0.
ThresholdDegraded *float64 `json:"thresholdDegraded,omitempty"`
}

// EffectiveThresholds resolves the up/degraded thresholds for a (possibly
// nil) AvailabilitySettings, falling back to the package defaults for a nil
// receiver or nil fields. Never returns a value that needs further nil
// checking by the caller.
func (a *AvailabilitySettings) EffectiveThresholds() (float64, float64) {
thresholdUp := DefaultAvailabilityThresholdUp
thresholdDegraded := DefaultAvailabilityThresholdDegraded

if a == nil {
return thresholdUp, thresholdDegraded
}

if a.ThresholdUp != nil {
thresholdUp = *a.ThresholdUp
}

if a.ThresholdDegraded != nil {
thresholdDegraded = *a.ThresholdDegraded
}

return thresholdUp, thresholdDegraded
}

// EffectiveThresholds resolves the page's effective availability thresholds,
// nil-safe on the Availability section (StatusPageSettings itself is always a
// value, never nil, since it is stored NOT NULL DEFAULT '{}').
func (s StatusPageSettings) EffectiveThresholds() (float64, float64) {
return s.Availability.EffectiveThresholds()
}

// Value implements driver.Valuer so StatusPageSettings persists as a JSON
// string on both Postgres (jsonb) and SQLite (text). The zero value marshals
// to "{}", matching the column's NOT NULL DEFAULT '{}'.
func (s StatusPageSettings) Value() (driver.Value, error) {
data, err := json.Marshal(s)
if err != nil {
return nil, fmt.Errorf("failed to marshal StatusPageSettings: %w", err)
}

return string(data), nil
}

// Scan implements sql.Scanner for reading the JSON blob back from either
// engine.
func (s *StatusPageSettings) Scan(value any) error {
if value == nil {
return nil
}

var data []byte

switch v := value.(type) {
case []byte:
data = v
case string:
data = []byte(v)
default:
return fmt.Errorf("%w: %T", ErrStatusPageSettingsScanType, value)
}

if len(data) == 0 {
return nil
}

return json.Unmarshal(data, s)
}
Loading