Skip to content

feat: onboarding checklist, default email alerts, and one-step status pages - #282

Merged
fclairamb merged 139 commits into
mainfrom
batch/2026-08-28-2
Aug 29, 2026
Merged

fclairamb merged 139 commits into
mainfrom
batch/2026-08-28-2

Conversation

@fclairamb

@fclairamb fclairamb commented Aug 28, 2026 •

Copy link
Copy Markdown
Owner

Batch branch covering 17 specs. Every spec was implemented, gated on its own scoped build/lint/test run, and then cleared by an independent completeness audit before being archived to specs/done/.

Note on the changelog: the repo squash-merges with the PR title as the commit subject, so this whole branch lands as the single changelog line above. The per-spec detail lives here and in specs/done/2026/08/.

⚠️ Behaviour changes worth a reviewer's attention

  • New orgs now send email by default. CreateOrg seeds an isDefault email integration addressed to the owner and an org-wide weekly uptime report. Existing orgs are untouched (no migration, no retro-seeding), and the bootstrap default org and the test-mode org are excluded structurally — neither goes through CreateOrg, which is what keeps thousands of self-hosted instances from mailing admin@solidping.io. Seeding is best-effort: a failure logs a WARN and never fails signup.
  • POST /checks/validate now rejects payloads it previously accepted. That is the fix — it was answering {"valid": true} for bodies that POST /checks then 422'd. Create's 422 shape, field names, messages and status codes are unchanged.
  • A new status page is no longer created empty. Every page gets a default "Services" section (renamable/deletable like any other). Tests that pinned "a fresh page has zero sections" were updated to the new truth, not weakened.

Features

  • Getting-started checklist on the org dashboard — five steps derived entirely from real resources, never stored per-step flags. Replaces the one-shot FirstResultCelebration banner and its localStorage key. Dismissal moves to new per-user GET/PUT/DELETE /api/v1/me/ui-state/:key endpoints (key allowlist, 4 KiB cap, per-user isolation), so it works across devices and can be re-enabled from the account page.
  • One-step status page publishing — "Publish on a status page" on the check detail page routes to a prefilled create form; CreateStatusPage accepts checkUids and lands page + section + resources atomically in one transaction across both DB dialects.
  • Default email alerts and a weekly report for new orgs (see above).
  • JSONPath assertions are editable in the UI. The HTTP check form previously rebuilt its config from scratch on save, silently deleting stored assertions. A ready-made editor component existed but was imported nowhere.
  • SFTP keyboard-interactive auth, uptime-report emails linking to checks and SLOs, and unacknowledgement fan-out with escalation resume.

Fixes

  • Register/accept-invite with a short password returned 500 and paged Sentry for a user typo — now 400 VALIDATION_ERROR. A sweep of the sibling error helpers found three more unmapped sentinels of the same class.
  • Org logo upload broke the whole settings form. The upload's relative /pub/assets/… path landed in a type="url" input, so native validation blocked every save, including unrelated name/slug edits. Upload and external-URL are now distinct sources.
  • A real race, found via a "flaky" test. Root-causing it (17/20 reproductions with retries disabled) showed a late /auth/me refresh clobbering in-flight name/slug edits — edit the slug right after saving the name and your edit silently reverted. Fixed with touched-field guards.
  • Responsive tables across checks, incidents, members, SLOs and integrations; dashboard issues-banner links; incidents list showing the check name instead of its slug; chart active-dot failing-status rendering; system-agent supersede on enroll; check multi-picker UUID chips.

Verification

Backend build/lint/test, dash0 build/lint, 1121 unit tests, and scoped Playwright E2E per spec. New user-facing strings are in all four locales with real translations.

fclairamb and others added 18 commits August 28, 2026 11:08
…e specs

Tracked so each archive step can git mv rather than leaving a stale
duplicate in specs/todos/.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Go's x/crypto/ssh client never attempts an auth method the server
doesn't advertise. Servers that offer only keyboard-interactive
(a common sshd ChallengeResponseAuthentication configuration, e.g.
test.rebex.net as of 2026-08-25) reject a client offering only
ssh.Password, having attempted nothing.

Add a shared internal/sshauth.PasswordMethods(password) helper that
returns both ssh.Password and ssh.KeyboardInteractive (answering every
prompt with the password, empty slice for zero-question banners) and
wire it into the three password-auth call sites: checksftp, checkssh's
executeWithAuth, and sshtunnel. checkssh's verifyFingerprint probe
(deliberate empty-password handshake to capture a host key fingerprint)
is untouched.
…sshtunnel

Adds, for each of the three fixed call sites, an in-process x/crypto/ssh
server whose ServerConfig sets ONLY KeyboardInteractiveCallback (no
PasswordCallback), asserting successful auth with a configured password;
a companion password-only server as a positive control; and a wrong-
password negative test proving no silent success.

- internal/sshauth: unit tests on PasswordMethods' method-list shape plus
  its keyboard-interactive callback's answer-count contract (zero
  questions -> empty slice, N questions -> N copies of the password),
  and SSH-layer handshake tests against both server shapes.
- internal/checkers/checkssh, internal/checkers/checksftp: same three
  cases driven through the real Execute() entry point (checksftp wires a
  minimal github.com/pkg/sftp server-side subsystem so sftp.NewClient
  completes).
- internal/integrations/sshtunnel/sshtunneltest: new
  StartKeyboardInteractiveOnly bastion variant, reusing the existing
  Start() scaffolding, exercised through the real sshtunnel.Dial /
  Resolve path in sshtunnel_test.go.
…ainst servers that only offer keyboard-interactive SSH auth
…inst servers that only offer keyboard-interactive SSH auth
The single- and multi-series activeDot renderers hardcoded
payload.status === "down" (missing error/timeout), and the non-active
dot renderers duplicated isFailingStatus's own logic inline instead of
calling it. Route all four through the shared isFailingStatus() helper
so dot color always agrees with the line gradient.
Consolidate the four inline isFailingStatus(status) ? DOWN : neutral
ternaries (single/multi-series x dot/activeDot) into one exported
dotFillColor() helper, so tests can assert against the exact function
every renderer calls instead of a re-implementation of it.
…degraded

Asserts dotFillColor() renders down/error/timeout/unknown red (matching
the line gradient) and warning/degraded/abandoned/up neutral, plus a
source guard that no fill decision in response-time-chart.tsx bypasses
dotFillColor() with a re-implemented payload.status comparison.
…ders error/timeout points green on a red line
…ers error/timeout points green on a red line
… rows until the 7-day GC — supersede them at enrollment
…uplicate rows until the 7-day GC — supersede them at enrollment
A system agent booting without a pinned identity enrolls anew every time,
so every pod restart or redeploy left the previous row behind until the
agent_gc job's 7-day silence window (seven rows for one deployment, in
production). Retire the predecessors inside the enrollment transaction
instead: same kind='system', region and name, and only when last_seen_at
proves the row is not a live fleet peer (15-minute disconnect window).
Org agents are never candidates.

db.RetireAgentWorkerRows is the factored-out cleanupWorkerRow the GC and
this path now share.
rescheduleSelf passed a nil config, so an operator override of
StaleAfterSeconds / RevokedPurgeAfterSeconds survived exactly one sweep
and then silently reverted to the defaults. Also routes the worker-row
cleanup through the shared db.RetireAgentWorkerRow.
…g reschedule

The supersede matrix runs one body against in-memory SQLite and embedded
PostgreSQL: stale same-(region,name) predecessor retired with its worker
row, a same-name row with a FRESH last_seen_at survives (the positive
control for the disconnect guard), different name / different region /
org agent all survive, and an org enrollment supersedes nothing.

Both new tests were confirmed to fail against the pre-fix code.
…cate rows until the 7-day GC — supersede them at enrollment
…ate rows until the 7-day GC — supersede them at enrollment
@fclairamb fclairamb changed the title feat(agents): supersede replaced system agents at enrollment fix(agents): supersede replaced system agents at enrollment Aug 28, 2026
fclairamb and others added 11 commits August 28, 2026 12:57
…t, so everyone still believes the first responder has it

Three questions blocked the batch. Recorded in the spec itself, since the
implementer reads the file and never sees the conversation.

- Unack resumes escalation from the step the ack interrupted, option (c) —
  NOT the spec's own suggested (a) default. The notice must therefore say
  escalation resumes, not that paging has stopped. If the interrupted step
  index is not persisted today, persisting it is part of the work.
- Comments forward per-comment and immediately, accepting the notification
  noise the original scope call avoided.
- The Discord message id question is answered from the source rather than
  left open: discord.go already persists it (:32, :211, :247, :317), so A.1
  needs no storage change for Discord.

Also tracks the three new specs so each archive step can git mv them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… search page

Chips for selected checks that fall outside the current 25-result search page
rendered raw UUIDs instead of names — the common case on an edit form loading
a persisted selection with more than ~25 org checks. Add useChecksByUids
(hooks.ts), which resolves each unmatched uid individually via useQueries,
sharing useCheck's ["check", org, uid] cache key/fetch. CheckMultiPicker now
keeps an accumulating sticky label map so a name, once resolved, never
regresses to "…" or a UUID as the user narrows their search — mirroring
CheckPicker's single-value fallback (name || slug || uid, "…" while
resolving, uid only on a genuine fetch error). Only wired for kind="checks";
groups are unaffected (fetched whole already).
Unit tests for the fix in the previous commit: a selected check outside the
current search page resolves its name via the individual fetch (with "…"
while pending and uid-on-error fallbacks), a name already resolved stays
sticky across a narrower search that drops the check from checkMatches, and
kind="groups" never triggers per-uid fetches.
…h page

Playwright regression for spec 2026-08-28-05: seeds a target check plus 30
newer checks (default listing is created_at DESC, so the target is
guaranteed to fall outside the 25-result default page), selects it on a
report schedule and a maintenance window via the API, then asserts the edit
page's chip shows the check's name with a negative control that no chip text
matches a UUID pattern.

Authored but not run locally: the :4000 devloop is not SP_RUNMODE=test, which
this suite requires (per web/dash0/CLAUDE.md / make test-dash).
… ramp

CheckRow and SLORow gained a PascalCase-tagged URL field (dash0 detail page,
built the same way job_escalation_step.go does), and CheckRow/Data gained
AvailabilityColor — a hex color piecewise-interpolated across the product's
existing 99.9/99.0/98.0 thresholds rather than linearly over 0-100, so 99.0
and 99.9 no longer render as the same shade of green. SLO rows deliberately
get no color: they already carry a target-driven Healthy/At-risk/Breached
badge, which would disagree with an absolute gradient.
…me report

The per-check and per-objective tables now link the name to its dash0 page
when a URL is available (falling back to plain text otherwise, never a
half-built href=""), and the per-check + headline availability figures carry
an inline color driven by the new AvailabilityColor field. The plain-text
part appends the URL as a trailing line per row; color stays HTML-only.
- availabilityTextColor: pin the anchor colors (>=99.9, 99.0, <=95), prove a
  midpoint actually interpolates, and sweep the full range to confirm a
  higher percentage never renders redder.
- render_test.go: assert check/objective names link to their dash0 pages in
  both HTML and text, that an empty base URL renders plain names with no
  href="", and that only HasData rows get the colored value span.
…names checks and objectives without linking to them, and renders every availability number in the same neutral color

Gate run by the coordinator: the implementing subagent ended its turn
while still waiting on a backgrounded run and never landed this marker,
twice over. Verified independently instead — make build-backend OK,
make lint-back '0 issues.', and go test ./internal/uptimereport/...
./internal/email/... green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ames checks and objectives without linking to them, and renders every availability number in the same neutral color
… user to alerting, weekly reports, or a status page
…es a new user to alerting, weekly reports, or a status page
… new user to alerting, weekly reports, or a status page
…new user to alerting, weekly reports, or a status page
…n errors

POST /auth/register and AcceptInvite with a too-short password (and
CreateInvitation with an invalid role) fell through to WriteInternalError,
answering 500 INTERNAL_ERROR for a user typo and paging Sentry as if it
were a server fault. handleRegistrationError and handleInvitationError now
map ErrInvalidCredentials to 400 VALIDATION_ERROR, mirroring the existing
handleChangePasswordError/handlePasswordResetError pattern.

Sweep of the other handle*Error helpers in this package found three more
instances of the same gap and fixed them the same way:
- handleAuthError (Login/SwitchOrg): SwitchOrg's ErrUserNotFound now folds
  into the existing anti-enumeration case.
- handleRefreshError: Refresh's ErrUserNotFound (user deleted after token
  issuance) now maps like any other invalid refresh token.
- handle2FAError: completeLoginAfter2FA's ErrOrganizationNotFound (org
  deleted/renamed mid 2FA-challenge) is now mapped instead of 500ing.
…ndings

Route-level tests for POST /auth/register and AcceptInvite with a
too-short password (400 VALIDATION_ERROR, detail mentions the 8-character
minimum), plus direct unit tests of handleAuthError, handleRefreshError
and handle2FAError with the three sentinels found during the handler
sweep (ErrUserNotFound, ErrUserNotFound, ErrOrganizationNotFound).
…rns 500 INTERNAL_ERROR instead of 400 VALIDATION_ERROR
…ns 500 INTERNAL_ERROR instead of 400 VALIDATION_ERROR
@fclairamb fclairamb changed the title feat(incidents): announce unacknowledgement and resume escalation feat: onboarding checklist, default email alerts, and one-step status pages Aug 28, 2026
PageHeader renders the SLO name as an <h1>, and the status card's
CardTitle (an <h3>) echoes it too — but only once the SLO has a computed
attainment window. A brand-new SLO has none, so the ambiguous
getByRole("heading", { name }) locator only ever resolved to one element
by accident. This batch's longer E2E run lets the seeded check accumulate
results before slos.spec.ts runs, so the status card renders and the
locator hits Playwright's strict-mode violation. Scope to level: 1 so the
assertion holds whether or not the status card is present.
The Command Menu's status-page/escalation-policy/SLO search shares its
query keys (["statusPages", org], etc.) with other consumers, and the app's
default staleTime is 60s. The dashboard's getting-started checklist
(OnboardingChecklist) renders on every dashboard load and calls
useStatusPages before the Command Menu ever opens, seeding that cache
entry. When a user then creates a status page (via the API, another tab,
or the create flow itself) and immediately searches for it with ⌘K, React
Query serves the checklist's stale snapshot instead of refetching — the
new page is invisible in search for up to a minute even though it already
exists server-side.

Give useStatusPages/useEscalationPolicies/useSlos an opt-in `staleTime`
(mirroring the existing ListQueryOptions pattern used by useIntegrations,
useReportSchedules, useMembers, etc.), and have CommandMenu request
staleTime: 0 for all three entity-search queries so opening the palette
and searching always reflects the latest data, regardless of what another
component happened to cache.

Reproduced by running command-menu.spec.ts's "searches status pages and
navigates to the matching one" test in isolation against unmodified code —
it fails deterministically because the dashboard's checklist always primes
the cache before the test creates its status page, not because of leakage
from other spec files.
…from-check.spec

This spec created a status page and a check via the UI flow but never
deleted either, leaking them into the shared "test" org on every run. A
leaking spec makes the suite order-dependent by construction; wrap the
body in try/finally and delete both, mirroring the cleanup pattern already
used by command-menu.spec.ts and the other status-page specs.
…it form

The edit route fetches the window's own fields (useMaintenanceWindow) and
its check/group associations (useMaintenanceWindowChecks) as two
independent queries, but only gated the loading skeleton on the first one.
MaintenanceWindowForm seeds its checkUids/checkGroupUids state from
`initialChecks` with a plain useState initializer, which only runs once at
mount and is never resynced when the prop changes later. So whenever the
checks query was still in flight when the window query resolved, the form
mounted with an empty selection and permanently dropped the persisted
checks from the picker — surfaced as a flake in
check-multi-picker-uuid-chips.spec.ts's maintenance-window test (observed
once during a full-suite run, most likely under the elevated latency right
after that test's 31 concurrent check creations; not reproducible in
isolation across several clean re-runs). Gate isLoading on both queries so
the form never mounts before both have resolved.
@fclairamb
fclairamb merged commit 289478e into main Aug 29, 2026
12 checks passed
@fclairamb
fclairamb deleted the batch/2026-08-28-2 branch August 29, 2026 05:05
fclairamb added a commit that referenced this pull request Aug 29, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.20.0](v0.19.1...v0.20.0)
(2026-08-29)


### Features

* onboarding checklist, default email alerts, and one-step status pages
([#282](#282))
([289478e](289478e))


### Bug Fixes

* **deps:** update go dependencies (non-major)
([#279](#279))
([4cbcc29](4cbcc29))
* **deps:** update go dependencies (non-major)
([#283](#283))
([9709152](9709152))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).
This was referenced Sep 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant