From 1078e39ef8c8c2d978010e963495cb5602e1e20c Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Fri, 13 Nov 2026 00:40:58 +0100 Subject: [PATCH 01/23] docs(specs): incident-insights-page Co-Authored-By: Claude Sonnet 5.5 --- .../2026-11-13-01-incident-insights-page.md | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 specs/todos/2026-11-13-01-incident-insights-page.md diff --git a/specs/todos/2026-11-13-01-incident-insights-page.md b/specs/todos/2026-11-13-01-incident-insights-page.md new file mode 100644 index 000000000..00bec618f --- /dev/null +++ b/specs/todos/2026-11-13-01-incident-insights-page.md @@ -0,0 +1,108 @@ +--- +model: sonnet +effort: medium +pr: batch +automerge: false +--- + +# Incident insights page: top failing checks, time to acknowledge, time to resolve + +## Problem + +The dashboard lists incidents one by one but answers none of the questions a team asks after a month of on-call: + +- Which checks fail the most ? +- How long does it take us to acknowledge an incident ? +- How long do incidents last, and how many recover on their own ? + +The data is already on the `incidents` row (`server/internal/db/models/incident.go:87-105`): `started_at`, `acknowledged_at`, `resolved_at`, `resolution_type`, `relapse_count`, `last_reopened_at`. Nothing aggregates it. No migration is needed. + +## Proposal + +### 1. Pure computation package + +New package `server/internal/incidentinsights`, pure functions over `[]*models.Incident` (no DB access), so the uptime report can reuse it (spec `2026-11-13-02`). + +`Compute(incidents, windowStart, windowEnd, now) Insights` returns: + +- **`summary`** + - `incidentCount` + - `acknowledgedCount` and `ackRate` (0 to 1) + - `timeToAcknowledge`: `{ medianSeconds, p90Seconds, sampleCount }` + - `timeToResolve`: same shape, over resolved incidents + - `byResolution`: one entry for `auto`, one for `manual`, one for `other`, each with `count`, `medianSeconds`, `p90Seconds` +- **`checks`**: one row per check, with `checkUid`, `incidentCount`, `downtimeSeconds`, `relapseCount` (sum), `lastIncidentAt` + +Rules: + +- **Time to acknowledge** = `acknowledged_at` minus the incident's current onset. The onset is `last_reopened_at` when it is after `started_at`, else `started_at`. Reopening clears `acknowledged_at` (`server/internal/handlers/incidents/service.go:1769-1773`), so the surviving ack belongs to the last episode. Same rule as `incidentClockFloor` (`service.go:1173`). Only incidents with a non-null `acknowledged_at` are in the sample. +- **Ack rate** = acknowledged incidents / all incidents in the window. It is always returned next to the ack time, because most incidents recover before anyone acks and the ack time alone flatters. +- **Time to resolve** = `resolved_at` minus `started_at`, resolved incidents only. +- **Resolution split**: `auto` = `ResolutionTypeAuto`, `manual` = `ResolutionTypeManual`, `other` = every other value (`incident.go:23-44`: expired, escalated, disabled, check_deleted, check_disabled). `other` is counted but left out of the overall `timeToResolve` median, since those closures say nothing about recovery. +- **Median and p90**, never the mean (one 6 hour outage wrecks a mean). Nearest-rank on the sorted sample. An empty sample returns `sampleCount: 0` and null durations, not zero. +- **Downtime per check**: reuse `availability.IncidentBlock` (`server/internal/handlers/availability/service.go:332`), which clips an incident to the window and counts an open one up to `now`. +- No per-person breakdown. `acknowledged_by` is not read. + +### 2. API endpoint + +`GET /api/v1/orgs/:org/incidents/insights?period=7d|30d|90d` (default `30d`), registered next to the other incident routes in `server/internal/app/server.go:1594`, before `/:uid`. + +- Same read permission as `ListIncidents`. +- Loads incidents with `ListIncidentsFilter` (`incident.go:210`): `OrganizationUID`, `Since` = window start, `Kinds` = check outages only (no SLO burn alerts, no degraded incidents). +- Rolled-up (paging-suppressed) incidents count in the checks table and in time to resolve. They are left out of the acknowledge sample and of the ack rate, since nobody was paged. +- Page through the list. Stop at 5,000 incidents and return `truncated: true`. +- Checks table: top 20 by `incidentCount`, ties broken by `downtimeSeconds`. Each row carries the check name and slug so the page needs no second request. +- Unknown `period` answers 400 `VALIDATION_ERROR`. +- Add the endpoint to `server/internal/app/openapi/openapi.yaml`. + +### 3. Dashboard page + +New route `web/dash0/src/routes/orgs/$org/incidents.insights.tsx`, reached from an "Insights" button in the header of the incidents list (`web/dash0/src/routes/orgs/$org/incidents.index.tsx`). + +- Period selector (7, 30, 90 days), stored in the URL search params. +- **Top failing checks** first: a table with check (link to the check page), incidents, downtime, relapses, last incident. Sortable client-side on the three numeric columns. Each incident count links to the incidents list filtered on that check. +- **Response times** below: three cards. + - Time to acknowledge: median, p90, and "12 of 40 acknowledged". + - Time to resolve: median, p90. + - Resolution: count and median for auto-recovered and for manually resolved, count only for other. +- A card with an empty sample shows a dash and the reason ("No acknowledged incident in this period"), never `0s`. +- Empty state when the period has no incident. +- Truncation notice when `truncated` is true. +- Reuse the primitives of the design reference (`web/dash0/src/routes/orgs/$org/design-reference.tsx`). Usable on mobile: the table scrolls or stacks, no fixed widths. +- `data-testid` on the period selector, each card, each table row and the empty state. +- Durations are formatted with the helper the incident pages already use. + +### 4. Docs and changelog + +- A short section in the incidents page of `web/docs/docs/` describing the three numbers and how each is computed. +- A `CHANGELOG.md` entry (conventions: `wiki/conventions/changelog.md`). + +## Tests + +- `server/internal/incidentinsights/insights_test.go`, table-driven: + - median and p90 on odd, even and single-element samples + - empty input: zero counts, null durations + - no acknowledged incident: `ackRate` 0, `timeToAcknowledge.sampleCount` 0 + - reopened incident: ack time is measured from `last_reopened_at`, not `started_at` + - open incident: counted, absent from the resolve sample, downtime runs to `now` + - incident started before the window: downtime clipped to the window + - resolution split: `expired` and `check_deleted` land in `other` and stay out of the overall median + - checks rows: sums of incidents, downtime and relapses per check, order and tie-break +- `server/internal/handlers/incidents/`: handler test for the endpoint + - 401 without a token + - 400 `VALIDATION_ERROR` on `period=1y` + - incidents of another org are not counted + - an SLO burn incident is not counted (positive control: the same test counts a check outage) + - a rolled-up incident counts in the checks table and not in the ack rate +- `web/dash0/e2e/incident-insights.spec.ts`: + - the page shows the failing check and its incident count after an incident is created + - switching the period updates the URL and the numbers + - an org with no incident shows the empty state + - a mobile viewport has no horizontal page scroll + +## To verify + +- The exact name of the kind constant for check outages (`IncidentKindCheck` ?) near `incident.go:71`. +- How a group incident (`check_group_uid` set, `incident.go:108`) should be attributed: its `check_uid`, or each member check. Default to `check_uid`. +- Whether the incidents list already accepts a check filter in its URL search params, for the count links. +- Whether dash0 strings are translated (see `locale_pages_test.go`), and add the keys if so. From 47beeb5738fb1b1abd47950b167ebb13ed3554da Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Fri, 13 Nov 2026 00:41:05 +0100 Subject: [PATCH 02/23] docs(specs): uptime-report-incident-insights Co-Authored-By: Claude Sonnet 5.5 --- ...1-13-02-uptime-report-incident-insights.md | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 specs/todos/2026-11-13-02-uptime-report-incident-insights.md diff --git a/specs/todos/2026-11-13-02-uptime-report-incident-insights.md b/specs/todos/2026-11-13-02-uptime-report-incident-insights.md new file mode 100644 index 000000000..4c8ee26b4 --- /dev/null +++ b/specs/todos/2026-11-13-02-uptime-report-incident-insights.md @@ -0,0 +1,53 @@ +--- +model: sonnet +effort: medium +pr: batch +automerge: false +--- + +# Add response times and most-failing checks to the scheduled uptime report + +## Problem + +The scheduled uptime report (`server/internal/uptimereport/report.go`) gives the incident count and the total downtime (`report.go:159-165`), and sorts its check table by availability (`report.go:520`). It does not say how fast incidents were acknowledged or resolved, nor which checks opened the most incidents. A check that flaps ten times for 30 seconds each keeps a good availability and stays at the bottom of the table. + +Spec `2026-11-13-01` adds these numbers to the dashboard. The report should carry the same ones, computed by the same code. + +Depends on `2026-11-13-01-incident-insights-page` (package `server/internal/incidentinsights`). + +## Proposal + +1. In `Builder.measure` (`report.go:334`), call `incidentinsights.Compute` on the incidents of the report window, restricted to the checks in scope (`scopeChecks`, `report.go:602`). Reuse the incidents the builder already loads for `incidentBlock` (`report.go:659`) if it has them, no second query. +2. Extend `Data` (`report.go:140`) with: + - `MedianTimeToResolve` and `MedianTimeToAcknowledge`, formatted like `TotalDowntime` + - `AcknowledgedCount` (shown as "12 of 40 acknowledged") + - `TopFailingChecks`: up to 5 rows with name, link (`checkReportURL`, `report.go:791`), incident count and relapse count, ordered by incident count +3. Render them in the email (`write.go` and the report template): + - one line under the existing incident figures: median time to resolve, median time to acknowledge, acknowledged count + - a "Most incidents" block listing the 5 rows +4. Hide rules: + - no incident in the period: neither the line nor the block is rendered + - no acknowledged incident: the acknowledge figure is omitted, the acknowledged count still shows "0 of N" + - a single check in scope: the "Most incidents" block is omitted (the table already says it) +5. Add the strings to every report locale, and the new fields to the preview fixtures (`server/internal/handlers/emailpreview/fixtures_report.go`). +6. Keep the email inside the size budget checked by `size_test.go`. +7. `CHANGELOG.md` entry, and a line in the report docs page of `web/docs/docs/`. + +## Tests + +- `server/internal/uptimereport/builder_test.go`: + - a window with acknowledged and resolved incidents fills the three figures and orders `TopFailingChecks` by incident count + - a window with no incident leaves them empty + - an incident on a check outside the schedule's scope is not counted +- `server/internal/uptimereport/render_test.go`: + - the line and the block are rendered when there are incidents + - neither is rendered with zero incidents (positive control: the same test renders them with one incident) + - the acknowledge figure is absent and "0 of N acknowledged" present when nothing was acknowledged + - the block is absent with a single check in scope +- `locale_report_test.go`: the new strings exist in every locale. +- `size_test.go`: still passes with 5 top rows and the longest locale. + +## To verify + +- Whether `incidentBlock` (`report.go:659`) keeps the incident slice or only the aggregate. If only the aggregate, have it return the slice. +- The template file the report is rendered from, and the locale files it reads. From 67b4671050ea708cb99413a6a30b910639709a3f Mon Sep 17 00:00:00 2001 From: fclairamb Date: Fri, 13 Nov 2026 00:44:32 +0100 Subject: [PATCH 03/23] feat(incidents): insights endpoint and pure aggregation package Co-Authored-By: Claude Sonnet 5.5 --- .../app/incident_insights_route_test.go | 26 ++ server/internal/app/openapi/openapi.yaml | 123 +++++++++ server/internal/app/server.go | 2 + .../internal/handlers/incidents/insights.go | 177 ++++++++++++ .../handlers/incidents/insights_test.go | 142 ++++++++++ server/internal/incidentinsights/insights.go | 260 ++++++++++++++++++ .../incidentinsights/insights_test.go | 201 ++++++++++++++ 7 files changed, 931 insertions(+) create mode 100644 server/internal/app/incident_insights_route_test.go create mode 100644 server/internal/handlers/incidents/insights.go create mode 100644 server/internal/handlers/incidents/insights_test.go create mode 100644 server/internal/incidentinsights/insights.go create mode 100644 server/internal/incidentinsights/insights_test.go diff --git a/server/internal/app/incident_insights_route_test.go b/server/internal/app/incident_insights_route_test.go new file mode 100644 index 000000000..5136a9150 --- /dev/null +++ b/server/internal/app/incident_insights_route_test.go @@ -0,0 +1,26 @@ +package app + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestIncidentInsightsRoute walks the real router: the insights route needs a +// token, and with one it is served by the insights handler rather than by +// the `/:uid` route (which would answer 404 INCIDENT_NOT_FOUND). +func TestIncidentInsightsRoute(t *testing.T) { + t.Parallel() + r := require.New(t) + + env := newMCPTestEnv(t) + path := "/api/v1/orgs/" + mcpE2EOrg + "/incidents/insights" + + anon := env.get(t, path, nil) + r.Equal(http.StatusUnauthorized, anon.status, string(anon.body)) + + authed := env.get(t, path+"?period=7d", map[string]string{"Authorization": "Bearer " + env.login(t)}) + r.Equal(http.StatusOK, authed.status, string(authed.body)) + r.Contains(string(authed.body), `"period":"7d"`) +} diff --git a/server/internal/app/openapi/openapi.yaml b/server/internal/app/openapi/openapi.yaml index 154f0b4d8..16e8be3d6 100644 --- a/server/internal/app/openapi/openapi.yaml +++ b/server/internal/app/openapi/openapi.yaml @@ -4045,6 +4045,103 @@ paths: "404": $ref: "#/components/responses/NotFound" + /api/v1/orgs/{org}/incidents/insights: + get: + tags: [Incidents] + summary: Incident insights + description: >- + Aggregates the check outages of a trailing window: the checks that fail + the most, the time to acknowledge, the time to resolve and how incidents + were resolved. Medians and nearest-rank p90, never means. Rolled-up + (paging-suppressed) incidents count in the checks table and the resolve + time but not in the acknowledge sample or rate. SLO burn and degraded + incidents are not counted. At most 5000 incidents are read, then + `truncated` is true. + operationId: getIncidentInsights + security: + - bearerAuth: [] + parameters: + - $ref: "#/components/parameters/OrgPath" + - name: period + in: query + description: Trailing window, one of 7d, 30d (default), 90d. Anything else is a 400 VALIDATION_ERROR. + schema: + type: string + enum: [7d, 30d, 90d] + default: 30d + responses: + "200": + description: Incident insights + content: + application/json: + schema: + type: object + required: [period, windowStart, windowEnd, truncated, summary, checks] + properties: + period: + type: string + windowStart: + type: string + format: date-time + windowEnd: + type: string + format: date-time + truncated: + type: boolean + summary: + type: object + properties: + incidentCount: + type: integer + pagedCount: + type: integer + description: Incidents that were not rolled up, the base of ackRate. + acknowledgedCount: + type: integer + ackRate: + type: number + description: 0 to 1 + timeToAcknowledge: + $ref: "#/components/schemas/IncidentInsightsDuration" + timeToResolve: + $ref: "#/components/schemas/IncidentInsightsDuration" + byResolution: + type: object + properties: + auto: + $ref: "#/components/schemas/IncidentInsightsResolution" + manual: + $ref: "#/components/schemas/IncidentInsightsResolution" + other: + $ref: "#/components/schemas/IncidentInsightsResolution" + checks: + type: array + description: Top 20 checks by incident count, ties broken by downtime. + items: + type: object + properties: + checkUid: + type: string + checkName: + type: string + checkSlug: + type: string + incidentCount: + type: integer + downtimeSeconds: + type: integer + format: int64 + relapseCount: + type: integer + lastIncidentAt: + type: string + format: date-time + nullable: true + "400": + $ref: "#/components/responses/ValidationError" + "401": + $ref: "#/components/responses/Unauthorized" + /api/v1/orgs/{org}/incidents/{uid}: get: tags: [Incidents] @@ -10635,6 +10732,32 @@ components: $ref: "#/components/schemas/Error" schemas: + IncidentInsightsDuration: + type: object + properties: + medianSeconds: + type: integer + format: int64 + nullable: true + p90Seconds: + type: integer + format: int64 + nullable: true + sampleCount: + type: integer + IncidentInsightsResolution: + type: object + properties: + count: + type: integer + medianSeconds: + type: integer + format: int64 + nullable: true + p90Seconds: + type: integer + format: int64 + nullable: true SupportReplyWindow: type: object description: > diff --git a/server/internal/app/server.go b/server/internal/app/server.go index 694e044b8..9c5213736 100644 --- a/server/internal/app/server.go +++ b/server/internal/app/server.go @@ -1620,6 +1620,8 @@ func (s *Server) SetupRoutes(ctx context.Context) { incidentsHandler := incidents.NewHandler(incidentsService, s.config) orgIncidents := orgGroup("/orgs/:org/incidents") orgIncidents.GET("", incidentsHandler.ListIncidents) + // Registered before "/:uid" so "insights" is not read as an incident uid. + orgIncidents.GET("/insights", incidentsHandler.GetInsights) orgIncidents.GET("/:uid", incidentsHandler.GetIncident) orgIncidents.POST("/:uid/ack", incidentsHandler.AcknowledgeIncident) orgIncidents.POST("/:uid/unack", incidentsHandler.UnacknowledgeIncident) diff --git a/server/internal/handlers/incidents/insights.go b/server/internal/handlers/incidents/insights.go new file mode 100644 index 000000000..8b66c2281 --- /dev/null +++ b/server/internal/handlers/incidents/insights.go @@ -0,0 +1,177 @@ +package incidents + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/fclairamb/solidping/server/internal/db/models" + "github.com/fclairamb/solidping/server/internal/handlers/base" + "github.com/fclairamb/solidping/server/internal/httpx" + "github.com/fclairamb/solidping/server/internal/incidentinsights" +) + +const ( + insightsPageSize = 500 + insightsMaxLoaded = 5000 + insightsTopChecks = 20 + insightsDefaultDays = 30 +) + +var errInvalidInsightsPeriod = errors.New("invalid period: must be one of 7d, 30d, 90d") + +// InsightsCheckRow is one row of the "top failing checks" table. +type InsightsCheckRow struct { + incidentinsights.CheckRow + CheckName string `json:"checkName"` + CheckSlug string `json:"checkSlug"` +} + +// InsightsResponse is the body of GET /orgs/:org/incidents/insights. +type InsightsResponse struct { + Period string `json:"period"` + WindowStart time.Time `json:"windowStart"` + WindowEnd time.Time `json:"windowEnd"` + Truncated bool `json:"truncated"` + Summary incidentinsights.Summary `json:"summary"` + Checks []InsightsCheckRow `json:"checks"` +} + +func parseInsightsPeriod(raw string) (string, int, error) { + switch raw { + case "", "30d": + return "30d", insightsDefaultDays, nil + case "7d": + return "7d", 7, nil + case "90d": + return "90d", 90, nil + default: + return "", 0, errInvalidInsightsPeriod + } +} + +// GetInsights handles GET /api/v1/orgs/:org/incidents/insights. +func (h *Handler) GetInsights(writer http.ResponseWriter, req *http.Request) error { + period, days, err := parseInsightsPeriod(req.URL.Query().Get("period")) + if err != nil { + return h.WriteError(writer, http.StatusBadRequest, base.ErrorCodeValidationError, err.Error()) + } + + response, err := h.svc.GetInsights(req.Context(), httpx.Param(req, "org"), period, days) + if err != nil { + return h.handleError(writer, req, err) + } + + return h.WriteJSON(writer, http.StatusOK, response) +} + +// GetInsights aggregates the check outages of the trailing window. +func (s *Service) GetInsights(ctx context.Context, orgSlug, period string, days int) (*InsightsResponse, error) { + org, err := s.db.GetOrganizationBySlug(ctx, orgSlug) + if err != nil { + return nil, ErrOrganizationNotFound + } + + now := s.clock.Now() + windowStart := now.AddDate(0, 0, -days) + + incidents, truncated, err := s.loadInsightsIncidents(ctx, org.UID, windowStart) + if err != nil { + return nil, err + } + + computed := incidentinsights.Compute(incidents, windowStart, now, now) + + rows := computed.Checks + if len(rows) > insightsTopChecks { + rows = rows[:insightsTopChecks] + } + + response := &InsightsResponse{ + Period: period, + WindowStart: windowStart, + WindowEnd: now, + Truncated: truncated, + Summary: computed.Summary, + Checks: make([]InsightsCheckRow, 0, len(rows)), + } + + checkUIDs := make([]string, 0, len(rows)) + for _, row := range rows { + checkUIDs = append(checkUIDs, row.CheckUID) + } + + checks := map[string]*models.Check{} + if len(checkUIDs) > 0 { + checks, err = s.db.GetChecksByUIDs(ctx, org.UID, checkUIDs) + if err != nil { + return nil, fmt.Errorf("failed to get checks: %w", err) + } + } + + for _, row := range rows { + out := InsightsCheckRow{CheckRow: row} + + if check := checks[row.CheckUID]; check != nil { + if check.Name != nil { + out.CheckName = *check.Name + } + + if check.Slug != nil { + out.CheckSlug = *check.Slug + } + } + + response.Checks = append(response.Checks, out) + } + + return response, nil +} + +// loadInsightsIncidents pages through the window's check outages, stopping at +// insightsMaxLoaded rows (truncated is then true). +func (s *Service) loadInsightsIncidents( + ctx context.Context, orgUID string, since time.Time, +) ([]*models.Incident, bool, error) { + var all []*models.Incident + + var ( + cursorTS *time.Time + cursorUID *string + ) + + for { + page, _, err := s.db.ListIncidents(ctx, &models.ListIncidentsFilter{ + OrganizationUID: orgUID, + Kinds: []string{models.IncidentKindCheck}, + Since: &since, + CursorTimestamp: cursorTS, + CursorUID: cursorUID, + Limit: insightsPageSize + 1, + }) + if err != nil { + return nil, false, fmt.Errorf("failed to list incidents: %w", err) + } + + hasMore := len(page) > insightsPageSize + if hasMore { + page = page[:insightsPageSize] + } + + all = append(all, page...) + + if !hasMore { + return all, false, nil + } + + if len(all) >= insightsMaxLoaded { + return all, true, nil + } + + last := page[len(page)-1] + ts, uid := last.StartedAt, last.UID + cursorTS, cursorUID = &ts, &uid + } +} diff --git a/server/internal/handlers/incidents/insights_test.go b/server/internal/handlers/incidents/insights_test.go new file mode 100644 index 000000000..ab3f00e2d --- /dev/null +++ b/server/internal/handlers/incidents/insights_test.go @@ -0,0 +1,142 @@ +package incidents_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/fclairamb/solidping/server/internal/config" + "github.com/fclairamb/solidping/server/internal/db/models" + "github.com/fclairamb/solidping/server/internal/db/sqlite" + "github.com/fclairamb/solidping/server/internal/handlers/incidents" + "github.com/fclairamb/solidping/server/internal/httpx" + "github.com/fclairamb/solidping/server/internal/jobs/jobsvc" + "github.com/fclairamb/solidping/server/internal/notifier" + "github.com/fclairamb/solidping/server/internal/utils/clock" +) + +func newInsightsRouter(dbSvc *sqlite.Service) *httpx.Router { + jobs := jobsvc.NewService(dbSvc.DB(), dbSvc, notifier.NewLocalEventNotifier(), nil) + svc := incidents.NewService(dbSvc, jobs, clock.Real{}, nil) + handler := incidents.NewHandler(svc, &config.Config{}) + + router := httpx.New() + router.GET("/orgs/:org/incidents/insights", handler.GetInsights) + + return router +} + +func fetchInsights( + t *testing.T, router *httpx.Router, org, query string, +) (*httptest.ResponseRecorder, incidents.InsightsResponse) { + t.Helper() + + req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/orgs/"+org+"/incidents/insights"+query, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + + var out incidents.InsightsResponse + if rec.Code == http.StatusOK { + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out)) + } + + return rec, out +} + +func TestGetInsights(t *testing.T) { + t.Parallel() + + ctx := t.Context() + r := require.New(t) + + dbSvc, err := sqlite.New(ctx, sqlite.Config{InMemory: true}) + r.NoError(err) + t.Cleanup(func() { _ = dbSvc.Close() }) + r.NoError(dbSvc.Initialize(ctx)) + + org := models.NewOrganization("insights-org", "") + r.NoError(dbSvc.CreateOrganization(ctx, org)) + other := models.NewOrganization("insights-other", "") + r.NoError(dbSvc.CreateOrganization(ctx, other)) + empty := models.NewOrganization("insights-empty", "") + r.NoError(dbSvc.CreateOrganization(ctx, empty)) + + check := models.NewCheck(org.UID, "acme-api", "http") + r.NoError(dbSvc.CreateCheck(ctx, check)) + otherCheck := models.NewCheck(other.UID, "acme-other", "http") + r.NoError(dbSvc.CreateCheck(ctx, otherCheck)) + + now := time.Now() + seed := func(orgUID, checkUID, kind string, mod func(*models.Incident)) { + inc := models.NewIncident(orgUID, checkUID, now.Add(-2*time.Hour), "incident") + inc.Kind = kind + if mod != nil { + mod(inc) + } + r.NoError(dbSvc.CreateIncident(ctx, inc)) + } + + ackedAt := now.Add(-2*time.Hour + 5*time.Minute) + seed(org.UID, check.UID, models.IncidentKindCheck, func(i *models.Incident) { i.AcknowledgedAt = &ackedAt }) + // Rolled up: counted in the checks table, left out of the ack rate. + seed(org.UID, check.UID, models.IncidentKindCheck, func(i *models.Incident) { i.PagingSuppressed = true }) + // Not counted: another kind, and another org. + seed(org.UID, check.UID, models.IncidentKindSLOBurn, nil) + seed(other.UID, otherCheck.UID, models.IncidentKindCheck, nil) + + router := newInsightsRouter(dbSvc) + + t.Run("counts check outages of this org only", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + rec, got := fetchInsights(t, router, org.Slug, "") + r.Equal(http.StatusOK, rec.Code, rec.Body.String()) + r.Equal("30d", got.Period) + r.Equal(2, got.Summary.IncidentCount) + r.Len(got.Checks, 1) + r.Equal(2, got.Checks[0].IncidentCount) + r.Equal("acme-api", got.Checks[0].CheckSlug) + r.False(got.Truncated) + }) + + t.Run("rolled-up incident is not in the ack rate", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + _, got := fetchInsights(t, router, org.Slug, "?period=7d") + r.Equal(1, got.Summary.PagedCount) + r.Equal(1, got.Summary.AcknowledgedCount) + r.InDelta(1.0, got.Summary.AckRate, 0) + r.Equal(int64(300), *got.Summary.TimeToAcknowledge.MedianSeconds) + }) + + t.Run("period validation", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + rec, _ := fetchInsights(t, router, org.Slug, "?period=1y") + r.Equal(http.StatusBadRequest, rec.Code) + + var body struct { + Code string `json:"code"` + } + r.NoError(json.Unmarshal(rec.Body.Bytes(), &body)) + r.Equal("VALIDATION_ERROR", body.Code) + }) + + t.Run("empty org", func(t *testing.T) { + t.Parallel() + r := require.New(t) + + rec, got := fetchInsights(t, router, empty.Slug, "?period=90d") + r.Equal(http.StatusOK, rec.Code) + r.Zero(got.Summary.IncidentCount) + r.Empty(got.Checks) + r.Nil(got.Summary.TimeToAcknowledge.MedianSeconds) + }) +} diff --git a/server/internal/incidentinsights/insights.go b/server/internal/incidentinsights/insights.go new file mode 100644 index 000000000..e36b91aa6 --- /dev/null +++ b/server/internal/incidentinsights/insights.go @@ -0,0 +1,260 @@ +// Package incidentinsights aggregates incident rows into the numbers a team +// asks for after a month of on-call: which checks fail the most, how long it +// takes to acknowledge, how long incidents last. Everything here is a pure +// function over already-loaded incidents so that the insights endpoint and the +// uptime report share one definition. +package incidentinsights + +import ( + "math" + "sort" + "time" + + "github.com/fclairamb/solidping/server/internal/db/models" +) + +// DurationStats is the median and p90 of a sample of durations. The durations +// are nil, not zero, when the sample is empty. +type DurationStats struct { + MedianSeconds *int64 `json:"medianSeconds"` + P90Seconds *int64 `json:"p90Seconds"` + SampleCount int `json:"sampleCount"` +} + +// ResolutionStats is the DurationStats of one resolution bucket plus its count. +type ResolutionStats struct { + Count int `json:"count"` + MedianSeconds *int64 `json:"medianSeconds"` + P90Seconds *int64 `json:"p90Seconds"` +} + +// ByResolution splits resolved incidents by how they were closed. +type ByResolution struct { + Auto ResolutionStats `json:"auto"` + Manual ResolutionStats `json:"manual"` + Other ResolutionStats `json:"other"` +} + +// Summary is the window-wide part of the insights. +type Summary struct { + IncidentCount int `json:"incidentCount"` + // PagedCount is the incidents somebody could have acknowledged: every one + // except the rolled-up (paging-suppressed) ones. It is the ackRate base. + PagedCount int `json:"pagedCount"` + AcknowledgedCount int `json:"acknowledgedCount"` + AckRate float64 `json:"ackRate"` + TimeToAcknowledge DurationStats `json:"timeToAcknowledge"` + TimeToResolve DurationStats `json:"timeToResolve"` + ByResolution ByResolution `json:"byResolution"` +} + +// CheckRow aggregates the incidents of one check. +type CheckRow struct { + CheckUID string `json:"checkUid"` + IncidentCount int `json:"incidentCount"` + DowntimeSeconds int64 `json:"downtimeSeconds"` + RelapseCount int `json:"relapseCount"` + LastIncidentAt *time.Time `json:"lastIncidentAt"` +} + +// Insights is the result of Compute. +type Insights struct { + Summary Summary `json:"summary"` + Checks []CheckRow `json:"checks"` +} + +// Compute aggregates incidents over [windowStart, windowEnd). An open incident +// runs to now for downtime. Incidents entirely outside the window are ignored. +func Compute(incidents []*models.Incident, windowStart, windowEnd, now time.Time) Insights { + var ( + ackSample []int64 + resolveSample []int64 + autoSample []int64 + manualSample []int64 + otherSample []int64 + summary Summary + ) + + rows := map[string]*CheckRow{} + + for _, inc := range incidents { + downtime, inWindow := clippedDowntime(inc, windowStart, windowEnd, now) + if !inWindow { + continue + } + + summary.IncidentCount++ + + row := rows[inc.CheckUID] + if row == nil { + row = &CheckRow{CheckUID: inc.CheckUID} + rows[inc.CheckUID] = row + } + + row.IncidentCount++ + row.DowntimeSeconds += downtime + row.RelapseCount += inc.RelapseCount + + if row.LastIncidentAt == nil || inc.StartedAt.After(*row.LastIncidentAt) { + startedAt := inc.StartedAt + row.LastIncidentAt = &startedAt + } + + if !inc.PagingSuppressed { + summary.PagedCount++ + + if inc.AcknowledgedAt != nil { + summary.AcknowledgedCount++ + ackSample = append(ackSample, nonNegative(inc.AcknowledgedAt.Sub(onset(inc)))) + } + } + + if inc.ResolvedAt == nil { + continue + } + + dur := nonNegative(inc.ResolvedAt.Sub(inc.StartedAt)) + + switch resolutionBucket(inc) { + case models.ResolutionTypeAuto: + autoSample = append(autoSample, dur) + resolveSample = append(resolveSample, dur) + case models.ResolutionTypeManual: + manualSample = append(manualSample, dur) + resolveSample = append(resolveSample, dur) + default: + otherSample = append(otherSample, dur) + } + } + + if summary.PagedCount > 0 { + summary.AckRate = float64(summary.AcknowledgedCount) / float64(summary.PagedCount) + } + + summary.TimeToAcknowledge = stats(ackSample) + summary.TimeToResolve = stats(resolveSample) + summary.ByResolution = ByResolution{ + Auto: resolutionStats(autoSample), + Manual: resolutionStats(manualSample), + Other: resolutionStats(otherSample), + } + + return Insights{Summary: summary, Checks: sortedRows(rows)} +} + +// onset is the start of the incident's current episode: reopening clears the +// acknowledgement, so the surviving one belongs to the last episode (same rule +// as incidentClockFloor in the incidents service). +func onset(inc *models.Incident) time.Time { + if inc.LastReopenedAt != nil && inc.LastReopenedAt.After(inc.StartedAt) { + return *inc.LastReopenedAt + } + + return inc.StartedAt +} + +// resolutionBucket maps a resolution type to auto, manual or "other". +func resolutionBucket(inc *models.Incident) string { + if inc.ResolutionType == nil { + return "other" + } + + switch *inc.ResolutionType { + case models.ResolutionTypeAuto, models.ResolutionTypeManual: + return *inc.ResolutionType + default: + return "other" + } +} + +// clippedDowntime is the incident's wall-clock outage clamped to the window, +// an open incident running to now. The bool is false when it does not overlap +// the window at all. (availability.IncidentBlock drops incidents that started +// before the window, which would lose the tail of one that straddles it.) +func clippedDowntime(inc *models.Incident, windowStart, windowEnd, now time.Time) (int64, bool) { + start := inc.StartedAt + end := now + + if inc.ResolvedAt != nil { + end = *inc.ResolvedAt + } + + if !start.Before(windowEnd) || end.Before(windowStart) { + return 0, false + } + + if start.Before(windowStart) { + start = windowStart + } + + if end.After(windowEnd) { + end = windowEnd + } + + return nonNegative(end.Sub(start)), true +} + +func nonNegative(d time.Duration) int64 { + if d < 0 { + return 0 + } + + return int64(d.Seconds()) +} + +func sortedRows(rows map[string]*CheckRow) []CheckRow { + out := make([]CheckRow, 0, len(rows)) + for _, row := range rows { + out = append(out, *row) + } + + sort.Slice(out, func(i, j int) bool { + if out[i].IncidentCount != out[j].IncidentCount { + return out[i].IncidentCount > out[j].IncidentCount + } + + if out[i].DowntimeSeconds != out[j].DowntimeSeconds { + return out[i].DowntimeSeconds > out[j].DowntimeSeconds + } + + return out[i].CheckUID < out[j].CheckUID + }) + + return out +} + +func stats(sample []int64) DurationStats { + median, p90 := percentiles(sample) + + return DurationStats{MedianSeconds: median, P90Seconds: p90, SampleCount: len(sample)} +} + +func resolutionStats(sample []int64) ResolutionStats { + median, p90 := percentiles(sample) + + return ResolutionStats{Count: len(sample), MedianSeconds: median, P90Seconds: p90} +} + +func percentiles(sample []int64) (*int64, *int64) { + if len(sample) == 0 { + return nil, nil + } + + sorted := append([]int64(nil), sample...) + sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) + + median := nearestRank(sorted, 0.5) + p90 := nearestRank(sorted, 0.9) + + return &median, &p90 +} + +// nearestRank returns the nearest-rank percentile of an ascending sample. +func nearestRank(sorted []int64, pct float64) int64 { + rank := int(math.Ceil(pct * float64(len(sorted)))) + if rank < 1 { + rank = 1 + } + + return sorted[rank-1] +} diff --git a/server/internal/incidentinsights/insights_test.go b/server/internal/incidentinsights/insights_test.go new file mode 100644 index 000000000..d17f24d66 --- /dev/null +++ b/server/internal/incidentinsights/insights_test.go @@ -0,0 +1,201 @@ +package incidentinsights_test + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/fclairamb/solidping/server/internal/db/models" + "github.com/fclairamb/solidping/server/internal/incidentinsights" +) + +var ( + winStart = time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC) + winEnd = time.Date(2026, 11, 11, 0, 0, 0, 0, time.UTC) + now = winEnd +) + +func at(minutes int) time.Time { return winStart.Add(time.Duration(minutes) * time.Minute) } + +func tp(v time.Time) *time.Time { return &v } + +func sp(v string) *string { return &v } + +func inc(check string, startMin int) *models.Incident { + return &models.Incident{UID: fmt.Sprintf("%s-%d", check, startMin), CheckUID: check, StartedAt: at(startMin)} +} + +func resolved(i *models.Incident, endMin int, kind string) *models.Incident { + i.ResolvedAt = tp(at(endMin)) + i.ResolutionType = sp(kind) + + return i +} + +func acked(i *models.Incident, minute int) *models.Incident { + i.AcknowledgedAt = tp(at(minute)) + + return i +} + +func TestPercentiles(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + minutes []int + median, p90 int64 + wantNoSample bool + }{ + {name: "single", minutes: []int{5}, median: 300, p90: 300}, + {name: "odd", minutes: []int{1, 2, 3, 4, 5}, median: 180, p90: 300}, + {name: "even", minutes: []int{4, 1, 3, 2}, median: 120, p90: 240}, + {name: "empty", wantNoSample: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + var list []*models.Incident + for i, m := range tc.minutes { + list = append(list, acked(inc("c", i*100), i*100+m)) + } + + got := incidentinsights.Compute(list, winStart, winEnd, now).Summary.TimeToAcknowledge + r.Equal(len(tc.minutes), got.SampleCount) + + if tc.wantNoSample { + r.Nil(got.MedianSeconds) + r.Nil(got.P90Seconds) + + return + } + + r.Equal(tc.median, *got.MedianSeconds) + r.Equal(tc.p90, *got.P90Seconds) + }) + } +} + +func TestCompute_EmptyInput(t *testing.T) { + t.Parallel() + r := require.New(t) + + got := incidentinsights.Compute(nil, winStart, winEnd, now) + r.Zero(got.Summary.IncidentCount) + r.Zero(got.Summary.AckRate) + r.Nil(got.Summary.TimeToAcknowledge.MedianSeconds) + r.Nil(got.Summary.TimeToResolve.MedianSeconds) + r.Empty(got.Checks) +} + +func TestCompute_NoAcknowledged(t *testing.T) { + t.Parallel() + r := require.New(t) + + got := incidentinsights.Compute( + []*models.Incident{resolved(inc("c", 0), 10, models.ResolutionTypeAuto)}, winStart, winEnd, now) + r.Zero(got.Summary.AckRate) + r.Zero(got.Summary.TimeToAcknowledge.SampleCount) + r.Nil(got.Summary.TimeToAcknowledge.MedianSeconds) + r.Equal(1, got.Summary.IncidentCount) +} + +func TestCompute_ReopenedAckMeasuredFromReopen(t *testing.T) { + t.Parallel() + r := require.New(t) + + i := acked(inc("c", 0), 130) + i.LastReopenedAt = tp(at(120)) + + got := incidentinsights.Compute([]*models.Incident{i}, winStart, winEnd, now) + r.Equal(int64(600), *got.Summary.TimeToAcknowledge.MedianSeconds) + r.InDelta(1.0, got.Summary.AckRate, 0) +} + +func TestCompute_OpenIncident(t *testing.T) { + t.Parallel() + r := require.New(t) + + i := inc("c", 0) + i.StartedAt = winEnd.Add(-time.Hour) + + got := incidentinsights.Compute([]*models.Incident{i}, winStart, winEnd, now) + r.Equal(1, got.Summary.IncidentCount) + r.Zero(got.Summary.TimeToResolve.SampleCount) + r.Equal(int64(3600), got.Checks[0].DowntimeSeconds) +} + +func TestCompute_ClippedToWindow(t *testing.T) { + t.Parallel() + r := require.New(t) + + i := inc("c", 0) + i.StartedAt = winStart.Add(-time.Hour) + resolved(i, 30, models.ResolutionTypeAuto) + + got := incidentinsights.Compute([]*models.Incident{i}, winStart, winEnd, now) + r.Equal(int64(1800), got.Checks[0].DowntimeSeconds) +} + +func TestCompute_ResolutionSplit(t *testing.T) { + t.Parallel() + r := require.New(t) + + list := []*models.Incident{ + resolved(inc("a", 0), 10, models.ResolutionTypeAuto), + resolved(inc("a", 100), 130, models.ResolutionTypeManual), + resolved(inc("a", 200), 1200, models.ResolutionTypeExpired), + resolved(inc("a", 300), 1300, models.ResolutionTypeCheckDeleted), + } + + got := incidentinsights.Compute(list, winStart, winEnd, now).Summary + r.Equal(1, got.ByResolution.Auto.Count) + r.Equal(1, got.ByResolution.Manual.Count) + r.Equal(2, got.ByResolution.Other.Count) + r.Equal(2, got.TimeToResolve.SampleCount) + r.Equal(int64(600), *got.TimeToResolve.MedianSeconds) +} + +func TestCompute_SuppressedLeftOutOfAck(t *testing.T) { + t.Parallel() + r := require.New(t) + + rolled := resolved(inc("c", 0), 10, models.ResolutionTypeAuto) + rolled.PagingSuppressed = true + paged := acked(inc("c", 100), 105) + + got := incidentinsights.Compute([]*models.Incident{rolled, paged}, winStart, winEnd, now).Summary + r.Equal(2, got.IncidentCount) + r.Equal(1, got.PagedCount) + r.InDelta(1.0, got.AckRate, 0) + r.Equal(1, got.TimeToResolve.SampleCount) +} + +func TestCompute_CheckRows(t *testing.T) { + t.Parallel() + r := require.New(t) + + a1 := resolved(inc("a", 0), 10, models.ResolutionTypeAuto) + a1.RelapseCount = 2 + a2 := resolved(inc("a", 100), 120, models.ResolutionTypeAuto) + a2.RelapseCount = 1 + b1 := resolved(inc("b", 50), 250, models.ResolutionTypeAuto) + b2 := resolved(inc("b", 300), 301, models.ResolutionTypeAuto) + c1 := resolved(inc("c", 10), 11, models.ResolutionTypeAuto) + + got := incidentinsights.Compute([]*models.Incident{c1, a1, b1, a2, b2}, winStart, winEnd, now).Checks + r.Len(got, 3) + // a and b tie on 2 incidents: b has more downtime and ranks first. + r.Equal("b", got[0].CheckUID) + r.Equal(int64(200*60+60), got[0].DowntimeSeconds) + r.Equal("a", got[1].CheckUID) + r.Equal(3, got[1].RelapseCount) + r.Equal(int64(30*60), got[1].DowntimeSeconds) + r.Equal(at(100), *got[1].LastIncidentAt) + r.Equal("c", got[2].CheckUID) +} From 6193efa46d179121f6fbc38d57d52c3ddd7e62fd Mon Sep 17 00:00:00 2001 From: fclairamb Date: Fri, 13 Nov 2026 00:51:26 +0100 Subject: [PATCH 04/23] feat(dash0): incident insights page, docs and e2e No CHANGELOG edit: release-please owns it. Co-Authored-By: Claude Sonnet 5.5 --- .../internal/handlers/incidents/insights.go | 9 +- .../incidentinsights/insights_test.go | 32 +- web/dash0/e2e/incident-insights.spec.ts | 120 +++++++ web/dash0/src/api/hooks.ts | 52 +++ web/dash0/src/locales/de/incidents.json | 33 ++ web/dash0/src/locales/en/incidents.json | 33 ++ web/dash0/src/locales/es/incidents.json | 33 ++ web/dash0/src/locales/fr/incidents.json | 33 ++ web/dash0/src/locales/identical-allowlist.ts | 4 + web/dash0/src/routeTree.gen.ts | 22 ++ .../src/routes/orgs/$org/incidents.index.tsx | 20 ++ .../routes/orgs/$org/incidents.insights.tsx | 321 ++++++++++++++++++ web/docs/docs/features/incidents.md | 10 + 13 files changed, 701 insertions(+), 21 deletions(-) create mode 100644 web/dash0/e2e/incident-insights.spec.ts create mode 100644 web/dash0/src/routes/orgs/$org/incidents.insights.tsx diff --git a/server/internal/handlers/incidents/insights.go b/server/internal/handlers/incidents/insights.go index 8b66c2281..19e71de66 100644 --- a/server/internal/handlers/incidents/insights.go +++ b/server/internal/handlers/incidents/insights.go @@ -99,8 +99,8 @@ func (s *Service) GetInsights(ctx context.Context, orgSlug, period string, days } checkUIDs := make([]string, 0, len(rows)) - for _, row := range rows { - checkUIDs = append(checkUIDs, row.CheckUID) + for idx := range rows { + checkUIDs = append(checkUIDs, rows[idx].CheckUID) } checks := map[string]*models.Check{} @@ -111,8 +111,9 @@ func (s *Service) GetInsights(ctx context.Context, orgSlug, period string, days } } - for _, row := range rows { - out := InsightsCheckRow{CheckRow: row} + for idx := range rows { + row := &rows[idx] + out := InsightsCheckRow{CheckRow: *row} if check := checks[row.CheckUID]; check != nil { if check.Name != nil { diff --git a/server/internal/incidentinsights/insights_test.go b/server/internal/incidentinsights/insights_test.go index d17f24d66..9cc292263 100644 --- a/server/internal/incidentinsights/insights_test.go +++ b/server/internal/incidentinsights/insights_test.go @@ -11,13 +11,11 @@ import ( "github.com/fclairamb/solidping/server/internal/incidentinsights" ) -var ( - winStart = time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC) - winEnd = time.Date(2026, 11, 11, 0, 0, 0, 0, time.UTC) - now = winEnd -) +func winStart() time.Time { return time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC) } +func winEnd() time.Time { return time.Date(2026, 11, 11, 0, 0, 0, 0, time.UTC) } +func now() time.Time { return winEnd() } -func at(minutes int) time.Time { return winStart.Add(time.Duration(minutes) * time.Minute) } +func at(minutes int) time.Time { return winStart().Add(time.Duration(minutes) * time.Minute) } func tp(v time.Time) *time.Time { return &v } @@ -65,7 +63,7 @@ func TestPercentiles(t *testing.T) { list = append(list, acked(inc("c", i*100), i*100+m)) } - got := incidentinsights.Compute(list, winStart, winEnd, now).Summary.TimeToAcknowledge + got := incidentinsights.Compute(list, winStart(), winEnd(), now()).Summary.TimeToAcknowledge r.Equal(len(tc.minutes), got.SampleCount) if tc.wantNoSample { @@ -85,7 +83,7 @@ func TestCompute_EmptyInput(t *testing.T) { t.Parallel() r := require.New(t) - got := incidentinsights.Compute(nil, winStart, winEnd, now) + got := incidentinsights.Compute(nil, winStart(), winEnd(), now()) r.Zero(got.Summary.IncidentCount) r.Zero(got.Summary.AckRate) r.Nil(got.Summary.TimeToAcknowledge.MedianSeconds) @@ -98,7 +96,7 @@ func TestCompute_NoAcknowledged(t *testing.T) { r := require.New(t) got := incidentinsights.Compute( - []*models.Incident{resolved(inc("c", 0), 10, models.ResolutionTypeAuto)}, winStart, winEnd, now) + []*models.Incident{resolved(inc("c", 0), 10, models.ResolutionTypeAuto)}, winStart(), winEnd(), now()) r.Zero(got.Summary.AckRate) r.Zero(got.Summary.TimeToAcknowledge.SampleCount) r.Nil(got.Summary.TimeToAcknowledge.MedianSeconds) @@ -112,7 +110,7 @@ func TestCompute_ReopenedAckMeasuredFromReopen(t *testing.T) { i := acked(inc("c", 0), 130) i.LastReopenedAt = tp(at(120)) - got := incidentinsights.Compute([]*models.Incident{i}, winStart, winEnd, now) + got := incidentinsights.Compute([]*models.Incident{i}, winStart(), winEnd(), now()) r.Equal(int64(600), *got.Summary.TimeToAcknowledge.MedianSeconds) r.InDelta(1.0, got.Summary.AckRate, 0) } @@ -122,9 +120,9 @@ func TestCompute_OpenIncident(t *testing.T) { r := require.New(t) i := inc("c", 0) - i.StartedAt = winEnd.Add(-time.Hour) + i.StartedAt = winEnd().Add(-time.Hour) - got := incidentinsights.Compute([]*models.Incident{i}, winStart, winEnd, now) + got := incidentinsights.Compute([]*models.Incident{i}, winStart(), winEnd(), now()) r.Equal(1, got.Summary.IncidentCount) r.Zero(got.Summary.TimeToResolve.SampleCount) r.Equal(int64(3600), got.Checks[0].DowntimeSeconds) @@ -135,10 +133,10 @@ func TestCompute_ClippedToWindow(t *testing.T) { r := require.New(t) i := inc("c", 0) - i.StartedAt = winStart.Add(-time.Hour) + i.StartedAt = winStart().Add(-time.Hour) resolved(i, 30, models.ResolutionTypeAuto) - got := incidentinsights.Compute([]*models.Incident{i}, winStart, winEnd, now) + got := incidentinsights.Compute([]*models.Incident{i}, winStart(), winEnd(), now()) r.Equal(int64(1800), got.Checks[0].DowntimeSeconds) } @@ -153,7 +151,7 @@ func TestCompute_ResolutionSplit(t *testing.T) { resolved(inc("a", 300), 1300, models.ResolutionTypeCheckDeleted), } - got := incidentinsights.Compute(list, winStart, winEnd, now).Summary + got := incidentinsights.Compute(list, winStart(), winEnd(), now()).Summary r.Equal(1, got.ByResolution.Auto.Count) r.Equal(1, got.ByResolution.Manual.Count) r.Equal(2, got.ByResolution.Other.Count) @@ -169,7 +167,7 @@ func TestCompute_SuppressedLeftOutOfAck(t *testing.T) { rolled.PagingSuppressed = true paged := acked(inc("c", 100), 105) - got := incidentinsights.Compute([]*models.Incident{rolled, paged}, winStart, winEnd, now).Summary + got := incidentinsights.Compute([]*models.Incident{rolled, paged}, winStart(), winEnd(), now()).Summary r.Equal(2, got.IncidentCount) r.Equal(1, got.PagedCount) r.InDelta(1.0, got.AckRate, 0) @@ -188,7 +186,7 @@ func TestCompute_CheckRows(t *testing.T) { b2 := resolved(inc("b", 300), 301, models.ResolutionTypeAuto) c1 := resolved(inc("c", 10), 11, models.ResolutionTypeAuto) - got := incidentinsights.Compute([]*models.Incident{c1, a1, b1, a2, b2}, winStart, winEnd, now).Checks + got := incidentinsights.Compute([]*models.Incident{c1, a1, b1, a2, b2}, winStart(), winEnd(), now()).Checks r.Len(got, 3) // a and b tie on 2 incidents: b has more downtime and ranks first. r.Equal("b", got[0].CheckUID) diff --git a/web/dash0/e2e/incident-insights.spec.ts b/web/dash0/e2e/incident-insights.spec.ts new file mode 100644 index 000000000..27a0ad49e --- /dev/null +++ b/web/dash0/e2e/incident-insights.spec.ts @@ -0,0 +1,120 @@ +import type { Page } from "@playwright/test"; + +import { test, expect, DASH_BASE } from "./fixtures"; + +/** + * Spec 2026-11-13-01: the incident insights page (top failing checks, time to + * acknowledge, time to resolve), reached from the incidents list. + * + * The first test runs against the real seeded org. The others mock the + * insights endpoint so they can assert exact numbers and the empty state. + */ + +const emptyDuration = { medianSeconds: null, p90Seconds: null, sampleCount: 0 }; +const emptyResolution = { count: 0, medianSeconds: null, p90Seconds: null }; + +function insightsBody(period: string, incidentCount: number) { + return { + period, + windowStart: new Date(Date.now() - 86_400_000).toISOString(), + windowEnd: new Date().toISOString(), + truncated: false, + summary: { + incidentCount, + pagedCount: incidentCount, + acknowledgedCount: 0, + ackRate: 0, + timeToAcknowledge: emptyDuration, + timeToResolve: + incidentCount > 0 + ? { medianSeconds: 600, p90Seconds: 900, sampleCount: incidentCount } + : emptyDuration, + byResolution: { + auto: incidentCount > 0 ? { count: incidentCount, medianSeconds: 600, p90Seconds: 900 } : emptyResolution, + manual: emptyResolution, + other: emptyResolution, + }, + }, + checks: + incidentCount > 0 + ? [ + { + checkUid: "99999999-9999-9999-9999-999999999991", + checkName: "Acme API", + checkSlug: "acme-api", + incidentCount, + downtimeSeconds: 1200, + relapseCount: 1, + lastIncidentAt: new Date().toISOString(), + }, + ] + : [], + }; +} + +async function mockInsights(page: Page, countFor: (period: string) => number) { + await page.route("**/api/v1/orgs/*/incidents/insights*", async (route) => { + const period = new URL(route.request().url()).searchParams.get("period") ?? "30d"; + await route.fulfill({ json: insightsBody(period, countFor(period)) }); + }); +} + +test.describe("Incident insights", () => { + test("lists a failing check with its incident count, reached from the incidents list", async ({ + authenticatedPage, + }) => { + const page = authenticatedPage; + + await page.goto(`${DASH_BASE}/orgs/test/incidents`); + await page.getByTestId("incidents-insights-button").click(); + await expect(page).toHaveURL(/\/incidents\/insights/); + + await page.getByTestId("insights-period-90d").click(); + await expect(page).toHaveURL(/period=90d/); + + const row = page.getByTestId("insights-check-row").first(); + await expect(row).toBeVisible({ timeout: 15000 }); + const count = Number(await row.getByTestId("insights-incident-count").innerText()); + expect(count).toBeGreaterThanOrEqual(1); + await expect(page.getByTestId("insights-card-ack")).toBeVisible(); + await expect(page.getByTestId("insights-card-resolve")).toBeVisible(); + await expect(page.getByTestId("insights-card-resolution")).toBeVisible(); + }); + + test("switching the period updates the URL and the numbers", async ({ authenticatedPage }) => { + const page = authenticatedPage; + await mockInsights(page, (period) => (period === "7d" ? 2 : 5)); + + await page.goto(`${DASH_BASE}/orgs/test/incidents/insights`); + await expect(page.getByTestId("insights-incident-count")).toHaveText("5"); + // No acknowledged incident: a dash and the reason, never "0s". + await expect(page.getByTestId("insights-card-ack")).toContainText("No acknowledged incident"); + + await page.getByTestId("insights-period-7d").click(); + await expect(page).toHaveURL(/period=7d/); + await expect(page.getByTestId("insights-incident-count")).toHaveText("2"); + }); + + test("an org with no incident shows the empty state", async ({ authenticatedPage }) => { + const page = authenticatedPage; + await mockInsights(page, () => 0); + + await page.goto(`${DASH_BASE}/orgs/test/incidents/insights`); + await expect(page.getByTestId("insights-empty")).toBeVisible(); + await expect(page.getByTestId("insights-check-row")).toHaveCount(0); + }); + + test("a phone viewport has no horizontal page scroll", async ({ authenticatedPage }) => { + const page = authenticatedPage; + await page.setViewportSize({ width: 375, height: 812 }); + await mockInsights(page, () => 5); + + await page.goto(`${DASH_BASE}/orgs/test/incidents/insights`); + await expect(page.getByTestId("insights-check-row")).toBeVisible(); + + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + ); + expect(overflow).toBe(false); + }); +}); diff --git a/web/dash0/src/api/hooks.ts b/web/dash0/src/api/hooks.ts index e6cc45948..17e1a78b4 100644 --- a/web/dash0/src/api/hooks.ts +++ b/web/dash0/src/api/hooks.ts @@ -2667,6 +2667,58 @@ export function useIncidents( }); } +export type InsightsPeriod = "7d" | "30d" | "90d"; + +export interface InsightsDuration { + medianSeconds: number | null; + p90Seconds: number | null; + sampleCount: number; +} + +export interface InsightsResolution { + count: number; + medianSeconds: number | null; + p90Seconds: number | null; +} + +export interface IncidentInsights { + period: InsightsPeriod; + truncated: boolean; + summary: { + incidentCount: number; + pagedCount: number; + acknowledgedCount: number; + ackRate: number; + timeToAcknowledge: InsightsDuration; + timeToResolve: InsightsDuration; + byResolution: { + auto: InsightsResolution; + manual: InsightsResolution; + other: InsightsResolution; + }; + }; + checks: { + checkUid: string; + checkName: string; + checkSlug: string; + incidentCount: number; + downtimeSeconds: number; + relapseCount: number; + lastIncidentAt: string | null; + }[]; +} + +export function useIncidentInsights(org: string, period: InsightsPeriod) { + return useQuery({ + queryKey: ["incident-insights", org, period], + queryFn: () => + apiFetch( + `/api/v1/orgs/${org}/incidents/insights?period=${period}`, + ), + enabled: !!org, + }); +} + export function useIncident(org: string, uid: string) { return useQuery({ queryKey: ["incident", org, uid], diff --git a/web/dash0/src/locales/de/incidents.json b/web/dash0/src/locales/de/incidents.json index 518329cae..7693588b9 100644 --- a/web/dash0/src/locales/de/incidents.json +++ b/web/dash0/src/locales/de/incidents.json @@ -336,5 +336,38 @@ "noAffected": "Für diese Seite wurden keine betroffenen Komponenten ermittelt.", "noUpdates": "Noch keine Updates.", "automated": "automatisch" + }, + "insights": { + "title": "Incident-Auswertung", + "subtitle": "Welche Checks am häufigsten ausfallen und wie schnell Sie bestätigen und beheben.", + "button": "Auswertung", + "back": "Zurück zu den Incidents", + "period": "Zeitraum", + "days7": "7 Tage", + "days30": "30 Tage", + "days90": "90 Tage", + "topChecks": "Häufigste Ausfälle", + "colCheck": "Check", + "colIncidents": "Incidents", + "colDowntime": "Ausfallzeit", + "colRelapses": "Rückfälle", + "colLast": "Letzter Incident", + "responseTimes": "Reaktionszeiten", + "tta": "Zeit bis zur Bestätigung", + "ttr": "Zeit bis zur Behebung", + "resolution": "Behebung", + "median": "Median", + "p90": "p90", + "ackedOf": "{{acked}} von {{total}} bestätigt", + "noAck": "Kein bestätigter Incident in diesem Zeitraum", + "noResolve": "Kein behobener Incident in diesem Zeitraum", + "auto": "Selbst erholt", + "manual": "Manuell behoben", + "other": "Sonstige Abschlüsse", + "n": "{{n}} Incidents", + "medianValue": "Median {{value}}", + "empty": "Kein Incident in diesem Zeitraum", + "truncated": "Es werden nur die 5.000 neuesten Incidents berücksichtigt, die Zahlen sind daher unvollständig.", + "noData": "Keine Daten" } } diff --git a/web/dash0/src/locales/en/incidents.json b/web/dash0/src/locales/en/incidents.json index ca35c6dc1..454258581 100644 --- a/web/dash0/src/locales/en/incidents.json +++ b/web/dash0/src/locales/en/incidents.json @@ -336,5 +336,38 @@ "noAffected": "No affected components resolved for this page.", "noUpdates": "No updates yet.", "automated": "automated" + }, + "insights": { + "title": "Incident insights", + "subtitle": "Which checks fail the most, and how fast you acknowledge and resolve.", + "button": "Insights", + "back": "Back to incidents", + "period": "Period", + "days7": "7 days", + "days30": "30 days", + "days90": "90 days", + "topChecks": "Top failing checks", + "colCheck": "Check", + "colIncidents": "Incidents", + "colDowntime": "Downtime", + "colRelapses": "Relapses", + "colLast": "Last incident", + "responseTimes": "Response times", + "tta": "Time to acknowledge", + "ttr": "Time to resolve", + "resolution": "Resolution", + "median": "Median", + "p90": "p90", + "ackedOf": "{{acked}} of {{total}} acknowledged", + "noAck": "No acknowledged incident in this period", + "noResolve": "No resolved incident in this period", + "auto": "Auto-recovered", + "manual": "Manually resolved", + "other": "Other closures", + "n": "{{n}} incidents", + "medianValue": "median {{value}}", + "empty": "No incident in this period", + "truncated": "Only the 5,000 most recent incidents are included, so these numbers are partial.", + "noData": "No data" } } diff --git a/web/dash0/src/locales/es/incidents.json b/web/dash0/src/locales/es/incidents.json index 4c336bf8d..e70230d84 100644 --- a/web/dash0/src/locales/es/incidents.json +++ b/web/dash0/src/locales/es/incidents.json @@ -336,5 +336,38 @@ "noAffected": "No se han identificado componentes afectados para esta página.", "noUpdates": "Todavía no hay actualizaciones.", "automated": "automática" + }, + "insights": { + "title": "Estadísticas de incidentes", + "subtitle": "Qué checks fallan más y con qué rapidez se reconocen y resuelven.", + "button": "Estadísticas", + "back": "Volver a los incidentes", + "period": "Período", + "days7": "7 días", + "days30": "30 días", + "days90": "90 días", + "topChecks": "Checks que más fallan", + "colCheck": "Check", + "colIncidents": "Incidentes", + "colDowntime": "Inactividad", + "colRelapses": "Recaídas", + "colLast": "Último incidente", + "responseTimes": "Tiempos de respuesta", + "tta": "Tiempo de reconocimiento", + "ttr": "Tiempo de resolución", + "resolution": "Resolución", + "median": "Mediana", + "p90": "p90", + "ackedOf": "{{acked}} de {{total}} reconocidos", + "noAck": "Ningún incidente reconocido en este período", + "noResolve": "Ningún incidente resuelto en este período", + "auto": "Recuperados solos", + "manual": "Resueltos manualmente", + "other": "Otros cierres", + "n": "{{n}} incidentes", + "medianValue": "mediana {{value}}", + "empty": "Ningún incidente en este período", + "truncated": "Solo se incluyen los 5.000 incidentes más recientes, por lo que estas cifras son parciales.", + "noData": "Sin datos" } } diff --git a/web/dash0/src/locales/fr/incidents.json b/web/dash0/src/locales/fr/incidents.json index 8a8d1a630..1e50759b6 100644 --- a/web/dash0/src/locales/fr/incidents.json +++ b/web/dash0/src/locales/fr/incidents.json @@ -336,5 +336,38 @@ "noAffected": "Aucun composant affecté n'a été identifié pour cette page.", "noUpdates": "Aucune mise à jour pour le moment.", "automated": "automatique" + }, + "insights": { + "title": "Statistiques des incidents", + "subtitle": "Quels checks tombent le plus, et à quelle vitesse vous acquittez et résolvez.", + "button": "Statistiques", + "back": "Retour aux incidents", + "period": "Période", + "days7": "7 jours", + "days30": "30 jours", + "days90": "90 jours", + "topChecks": "Checks les plus en échec", + "colCheck": "Check", + "colIncidents": "Incidents", + "colDowntime": "Indisponibilité", + "colRelapses": "Rechutes", + "colLast": "Dernier incident", + "responseTimes": "Temps de réaction", + "tta": "Délai d'acquittement", + "ttr": "Délai de résolution", + "resolution": "Résolution", + "median": "Médiane", + "p90": "p90", + "ackedOf": "{{acked}} sur {{total}} acquittés", + "noAck": "Aucun incident acquitté sur cette période", + "noResolve": "Aucun incident résolu sur cette période", + "auto": "Rétablis seuls", + "manual": "Résolus manuellement", + "other": "Autres clôtures", + "n": "{{n}} incident(s)", + "medianValue": "médiane {{value}}", + "empty": "Aucun incident sur cette période", + "truncated": "Seuls les 5 000 incidents les plus récents sont pris en compte : ces chiffres sont partiels.", + "noData": "Aucune donnée" } } diff --git a/web/dash0/src/locales/identical-allowlist.ts b/web/dash0/src/locales/identical-allowlist.ts index e55fb3e42..7fcec02f7 100644 --- a/web/dash0/src/locales/identical-allowlist.ts +++ b/web/dash0/src/locales/identical-allowlist.ts @@ -348,6 +348,10 @@ export const DASH0_IDENTICAL_ALLOWLIST: IdenticalAllowlist = { "events:audit.targetTypes.integration": ["de"], // "Integration" "feedback:tool_text": ["de"], // "Text" "incidents:title": ["fr"], // "Incidents" + "incidents:insights.colCheck": ["fr", "de", "es"], // "Check" + "incidents:insights.colIncidents": ["fr", "de"], // "Incidents" + "incidents:insights.p90": ["fr", "de", "es"], // "p90" + "incidents:insights.median": ["de"], // "Median" "incidents:table.incident": ["fr"], // "Incident" "incidents:fallbackTitle": ["fr"], // "Incident" "incidents:snoozeDialog.until15m": ["fr"], // "15 minutes" diff --git a/web/dash0/src/routeTree.gen.ts b/web/dash0/src/routeTree.gen.ts index b3904c632..06cc9715e 100644 --- a/web/dash0/src/routeTree.gen.ts +++ b/web/dash0/src/routeTree.gen.ts @@ -109,6 +109,7 @@ import { Route as OrgsOrgMaintenanceWindowsMaintenanceWindowUidRouteImport } fro import { Route as OrgsOrgJobsJobUidRouteImport } from './routes/orgs/$org/jobs.$jobUid' import { Route as OrgsOrgIntegrationsNewRouteImport } from './routes/orgs/$org/integrations.new' import { Route as OrgsOrgIntegrationsIntegrationUidRouteImport } from './routes/orgs/$org/integrations.$integrationUid' +import { Route as OrgsOrgIncidentsInsightsRouteImport } from './routes/orgs/$org/incidents.insights' import { Route as OrgsOrgIncidentsIncidentUidRouteImport } from './routes/orgs/$org/incidents.$incidentUid' import { Route as OrgsOrgEscalationPoliciesNewRouteImport } from './routes/orgs/$org/escalation-policies.new' import { Route as OrgsOrgEscalationPoliciesUidRouteImport } from './routes/orgs/$org/escalation-policies.$uid' @@ -692,6 +693,12 @@ const OrgsOrgIntegrationsIntegrationUidRoute = path: '/$integrationUid', getParentRoute: () => OrgsOrgIntegrationsRoute, } as any) +const OrgsOrgIncidentsInsightsRoute = + OrgsOrgIncidentsInsightsRouteImport.update({ + id: '/insights', + path: '/insights', + getParentRoute: () => OrgsOrgIncidentsRoute, + } as any) const OrgsOrgIncidentsIncidentUidRoute = OrgsOrgIncidentsIncidentUidRouteImport.update({ id: '/$incidentUid', @@ -1063,6 +1070,7 @@ export interface FileRoutesByFullPath { '/orgs/$org/escalation-policies/$uid': typeof OrgsOrgEscalationPoliciesUidRoute '/orgs/$org/escalation-policies/new': typeof OrgsOrgEscalationPoliciesNewRoute '/orgs/$org/incidents/$incidentUid': typeof OrgsOrgIncidentsIncidentUidRoute + '/orgs/$org/incidents/insights': typeof OrgsOrgIncidentsInsightsRoute '/orgs/$org/integrations/$integrationUid': typeof OrgsOrgIntegrationsIntegrationUidRoute '/orgs/$org/integrations/new': typeof OrgsOrgIntegrationsNewRoute '/orgs/$org/jobs/$jobUid': typeof OrgsOrgJobsJobUidRoute @@ -1201,6 +1209,7 @@ export interface FileRoutesByTo { '/orgs/$org/escalation-policies/$uid': typeof OrgsOrgEscalationPoliciesUidRoute '/orgs/$org/escalation-policies/new': typeof OrgsOrgEscalationPoliciesNewRoute '/orgs/$org/incidents/$incidentUid': typeof OrgsOrgIncidentsIncidentUidRoute + '/orgs/$org/incidents/insights': typeof OrgsOrgIncidentsInsightsRoute '/orgs/$org/integrations/$integrationUid': typeof OrgsOrgIntegrationsIntegrationUidRoute '/orgs/$org/integrations/new': typeof OrgsOrgIntegrationsNewRoute '/orgs/$org/jobs/$jobUid': typeof OrgsOrgJobsJobUidRoute @@ -1351,6 +1360,7 @@ export interface FileRoutesById { '/orgs/$org/escalation-policies/$uid': typeof OrgsOrgEscalationPoliciesUidRoute '/orgs/$org/escalation-policies/new': typeof OrgsOrgEscalationPoliciesNewRoute '/orgs/$org/incidents/$incidentUid': typeof OrgsOrgIncidentsIncidentUidRoute + '/orgs/$org/incidents/insights': typeof OrgsOrgIncidentsInsightsRoute '/orgs/$org/integrations/$integrationUid': typeof OrgsOrgIntegrationsIntegrationUidRoute '/orgs/$org/integrations/new': typeof OrgsOrgIntegrationsNewRoute '/orgs/$org/jobs/$jobUid': typeof OrgsOrgJobsJobUidRoute @@ -1510,6 +1520,7 @@ export interface FileRouteTypes { | '/orgs/$org/escalation-policies/$uid' | '/orgs/$org/escalation-policies/new' | '/orgs/$org/incidents/$incidentUid' + | '/orgs/$org/incidents/insights' | '/orgs/$org/integrations/$integrationUid' | '/orgs/$org/integrations/new' | '/orgs/$org/jobs/$jobUid' @@ -1648,6 +1659,7 @@ export interface FileRouteTypes { | '/orgs/$org/escalation-policies/$uid' | '/orgs/$org/escalation-policies/new' | '/orgs/$org/incidents/$incidentUid' + | '/orgs/$org/incidents/insights' | '/orgs/$org/integrations/$integrationUid' | '/orgs/$org/integrations/new' | '/orgs/$org/jobs/$jobUid' @@ -1797,6 +1809,7 @@ export interface FileRouteTypes { | '/orgs/$org/escalation-policies/$uid' | '/orgs/$org/escalation-policies/new' | '/orgs/$org/incidents/$incidentUid' + | '/orgs/$org/incidents/insights' | '/orgs/$org/integrations/$integrationUid' | '/orgs/$org/integrations/new' | '/orgs/$org/jobs/$jobUid' @@ -2618,6 +2631,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof OrgsOrgIntegrationsIntegrationUidRouteImport parentRoute: typeof OrgsOrgIntegrationsRoute } + '/orgs/$org/incidents/insights': { + id: '/orgs/$org/incidents/insights' + path: '/insights' + fullPath: '/orgs/$org/incidents/insights' + preLoaderRoute: typeof OrgsOrgIncidentsInsightsRouteImport + parentRoute: typeof OrgsOrgIncidentsRoute + } '/orgs/$org/incidents/$incidentUid': { id: '/orgs/$org/incidents/$incidentUid' path: '/$incidentUid' @@ -3143,12 +3163,14 @@ const OrgsOrgEscalationPoliciesRouteWithChildren = interface OrgsOrgIncidentsRouteChildren { OrgsOrgIncidentsIncidentUidRoute: typeof OrgsOrgIncidentsIncidentUidRoute + OrgsOrgIncidentsInsightsRoute: typeof OrgsOrgIncidentsInsightsRoute OrgsOrgIncidentsIndexRoute: typeof OrgsOrgIncidentsIndexRoute OrgsOrgIncidentsIncidentUidNotificationsNotificationUidRoute: typeof OrgsOrgIncidentsIncidentUidNotificationsNotificationUidRoute } const OrgsOrgIncidentsRouteChildren: OrgsOrgIncidentsRouteChildren = { OrgsOrgIncidentsIncidentUidRoute: OrgsOrgIncidentsIncidentUidRoute, + OrgsOrgIncidentsInsightsRoute: OrgsOrgIncidentsInsightsRoute, OrgsOrgIncidentsIndexRoute: OrgsOrgIncidentsIndexRoute, OrgsOrgIncidentsIncidentUidNotificationsNotificationUidRoute: OrgsOrgIncidentsIncidentUidNotificationsNotificationUidRoute, diff --git a/web/dash0/src/routes/orgs/$org/incidents.index.tsx b/web/dash0/src/routes/orgs/$org/incidents.index.tsx index b1ecf9ac5..9c92ae27c 100644 --- a/web/dash0/src/routes/orgs/$org/incidents.index.tsx +++ b/web/dash0/src/routes/orgs/$org/incidents.index.tsx @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next"; import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { AlertTriangle, + BarChart3, Check, CheckCircle, Layers, @@ -320,6 +321,24 @@ function IncidentsIndexPage() { description={t("subtitle")} docsHref="/docs/features/incidents" className="flex-wrap" + actions={ + + } />
@@ -418,6 +437,7 @@ function IncidentsIndexPage() {
+ ))} + + } + /> + + {isLoading || !data || !summary ? ( + + ) : summary.incidentCount === 0 ? ( + + + {t("insights.empty")} + + + ) : ( + <> + {data.truncated && ( +
+ {t("insights.truncated")} +
+ )} + + + + {t("insights.topChecks")} + + + + + + {t("insights.colCheck")} + setSortKey("incidentCount")} + testId="insights-sort-incidents" + /> + setSortKey("downtimeSeconds")} + testId="insights-sort-downtime" + /> + setSortKey("relapseCount")} + testId="insights-sort-relapses" + /> + {t("insights.colLast")} + + + + {rows.map((row) => ( + + + + {row.checkName || row.checkSlug || row.checkUid} + + + + + {row.incidentCount} + + + + {formatDuration(row.downtimeSeconds * 1000)} + + {row.relapseCount} + + {row.lastIncidentAt ? ( + + ) : ( + "-" + )} + + + ))} + +
+
+
+ +

+ {t("insights.responseTimes")} +

+
+ + + + + + {t("insights.resolution")} + + + + {(["auto", "manual", "other"] as const).map((kind) => { + const stat = summary.byResolution[kind]; + const median = seconds(stat.medianSeconds); + return ( +
+ {t(`insights.${kind}`)} + + {t("insights.n", { n: stat.count })} + {kind !== "other" && median + ? ` · ${t("insights.medianValue", { value: median })}` + : ""} + +
+ ); + })} +
+
+
+ + )} + + ); +} + +function SortHead({ + label, + active, + onClick, + testId, +}: { + label: string; + active: boolean; + onClick: () => void; + testId: string; +}) { + return ( + + + + ); +} + +function StatCard({ + testId, + title, + median, + p90, + empty, + footer, +}: { + testId: string; + title: string; + median: string | null; + p90: string | null; + empty: string; + footer?: string; +}) { + const { t } = useTranslation("incidents"); + + return ( + + + {title} + + + {median === null ? ( + <> +
-
+

{empty}

+ + ) : ( + <> +
{median}
+

+ {t("insights.median")} · {t("insights.p90")} {p90} +

+ + )} + {footer &&

{footer}

} +
+
+ ); +} diff --git a/web/docs/docs/features/incidents.md b/web/docs/docs/features/incidents.md index 0fb0b5ee3..fc2f5b37b 100644 --- a/web/docs/docs/features/incidents.md +++ b/web/docs/docs/features/incidents.md @@ -304,6 +304,16 @@ the next evaluation. Degraded incidents do not count in the check's availability: the **Outages** column of the availability table counts outages only. +## Incident insights {#insights} + +The **Insights** button in the header of the incidents list opens a page that answers the questions a team asks after a few weeks on call. Pick a window of 7, 30 or 90 days. Only check outages count: SLO burn alerts and degraded incidents are left out. + +- **Top failing checks**: the 20 checks with the most incidents in the window (ties broken by downtime), with their downtime, relapses and last incident. Downtime is the wall-clock time of the incidents, clipped to the window; an open incident counts up to now. +- **Time to acknowledge**: the time between the start of the incident's current episode and its acknowledgement. When an incident reopens, the acknowledgement is cleared, so the time is measured from the reopening. It is always shown with "N of M acknowledged", because most incidents recover before anybody acknowledges them and the time alone would flatter you. Incidents rolled up under a root cause (nobody was paged) are not part of it. +- **Time to resolve**: the time between the start and the resolution of incidents that were auto-recovered or resolved manually. Closures that say nothing about recovery (expired, escalated, disabled check, deleted check) are counted under "Other" and left out of the median. + +Every duration is a **median** and a **p90** (nearest rank), never a mean, so one six-hour outage does not distort the picture. With no data the card shows a dash, not zero. The same numbers are available from `GET /api/v1/orgs/{org}/incidents/insights?period=30d`. At most 5,000 incidents are read; the response then carries `truncated: true`. + ## Notification Events | Event | When | Description | From 1d605d4e54260486edc052d60418d1e38bacb9ba Mon Sep 17 00:00:00 2001 From: fclairamb Date: Fri, 13 Nov 2026 00:51:26 +0100 Subject: [PATCH 05/23] chore: all checks passing for Incident insights page: top failing checks, time to acknowledge, time to resolve Co-Authored-By: Claude Sonnet 5.5 From 9e04646363cf066dff1761f0f55de6f551c6d923 Mon Sep 17 00:00:00 2001 From: fclairamb Date: Fri, 13 Nov 2026 00:52:03 +0100 Subject: [PATCH 06/23] docs: archive completed spec incident insights page Co-Authored-By: Claude Sonnet 5.5 --- .../2026/11}/2026-11-13-01-incident-insights-page.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename specs/{todos => done/2026/11}/2026-11-13-01-incident-insights-page.md (100%) diff --git a/specs/todos/2026-11-13-01-incident-insights-page.md b/specs/done/2026/11/2026-11-13-01-incident-insights-page.md similarity index 100% rename from specs/todos/2026-11-13-01-incident-insights-page.md rename to specs/done/2026/11/2026-11-13-01-incident-insights-page.md From df22a809c098c734f43924ce7795f1d7e23ac22c Mon Sep 17 00:00:00 2001 From: fclairamb Date: Fri, 13 Nov 2026 00:55:52 +0100 Subject: [PATCH 07/23] feat(report): response times and most-failing checks in the uptime report Reuses incidentinsights.Compute on the report window's incidents, restricted to the schedule's checks. CHANGELOG is release-please's. --- .../email/templates/uptime-report.html | 20 ++- .../handlers/emailpreview/fixtures_report.go | 49 ++++++-- server/internal/i18n/locales/de/report.json | 9 ++ server/internal/i18n/locales/en/report.json | 9 ++ server/internal/i18n/locales/es/report.json | 9 ++ server/internal/i18n/locales/fr/report.json | 9 ++ server/internal/uptimereport/builder_test.go | 112 +++++++++++++++++ .../uptimereport/locale_report_test.go | 29 ++++- server/internal/uptimereport/render_test.go | 116 ++++++++++++++++-- server/internal/uptimereport/report.go | 105 +++++++++++++++- server/internal/uptimereport/size_test.go | 33 +++-- server/internal/uptimereport/write.go | 11 ++ web/docs/docs/features/slos.md | 7 ++ 13 files changed, 484 insertions(+), 34 deletions(-) diff --git a/server/internal/email/templates/uptime-report.html b/server/internal/email/templates/uptime-report.html index e68a69a05..ab547ac1a 100644 --- a/server/internal/email/templates/uptime-report.html +++ b/server/internal/email/templates/uptime-report.html @@ -35,6 +35,17 @@

{{t "report.title"}}

{{t "report.labels.incidents"}}{{.IncidentCount}}{{if .LongestIncident}} · {{t "report.incidentStats.longest" "duration" .LongestIncident}}{{end}}{{if .AverageIncident}} · {{t "report.incidentStats.average" "duration" .AverageIncident}}{{end}}{{if .ShowIncidentDelta}}
{{.IncidentDeltaText}} {{t "report.versus" "period" .PreviousPeriodLabel "previous" .PreviousIncidentCount}}{{end}} {{if .TotalDowntime}}{{t "report.labels.totalDowntime"}}{{.TotalDowntime}}{{end}} + {{if .ShowInsights}} +

{{if .MedianTimeToResolve}}{{t "report.insights.resolve" "duration" .MedianTimeToResolve}} · {{end}}{{if .MedianTimeToAcknowledge}}{{t "report.insights.acknowledge" "duration" .MedianTimeToAcknowledge}} · {{end}}{{t "report.insights.acknowledged" "count" .AcknowledgedCount "total" .PagedCount}}

+ {{if .TopFailingChecks}} +

{{t "report.insights.title"}}

+ + {{range .TopFailingChecks}} + + {{end}} + + {{end}} + {{end}} {{if .HasLatency}}

{{t "report.latency.title"}}

@@ -105,7 +116,14 @@

{{t "report.slo.title"}}

{{end}}{{t "common.textField" "label" (t "report.labels.checksMonitored") "value" .CheckCount}} {{t "common.textField" "label" (t "report.labels.incidents") "value" .IncidentCount}}{{if .LongestIncident}} ({{t "report.incidentStats.longest" "duration" .LongestIncident}}{{if .AverageIncident}}, {{t "report.incidentStats.average" "duration" .AverageIncident}}{{end}}){{end}}{{if .ShowIncidentDelta}} ({{.IncidentDeltaText}} {{t "report.text.versus" "period" .PreviousPeriodLabel "previous" .PreviousIncidentCount}}){{end}} {{if .TotalDowntime}}{{t "common.textField" "label" (t "report.labels.totalDowntime") "value" .TotalDowntime}} -{{end}}{{if .HasLatency}} +{{end}}{{if .ShowInsights}}{{if .MedianTimeToResolve}}{{t "report.insights.resolve" "duration" .MedianTimeToResolve}} +{{end}}{{if .MedianTimeToAcknowledge}}{{t "report.insights.acknowledge" "duration" .MedianTimeToAcknowledge}} +{{end}}{{t "report.insights.acknowledged" "count" .AcknowledgedCount "total" .PagedCount}} +{{if .TopFailingChecks}} +{{t "report.insights.title"}} +{{range .TopFailingChecks}} - {{t "common.textField" "label" .Name "value" .Detail}} +{{if .URL}} {{.URL}} +{{end}}{{end}}{{end}}{{end}}{{if .HasLatency}} {{t "report.text.latencyHeading"}} - {{t "common.textField" "label" (t "report.latency.average") "value" .AvgResponseTime}}{{if .ShowResponseDelta}} ({{.ResponseDeltaText}} {{t "report.text.versus" "period" .PreviousPeriodLabel "previous" .PreviousAvgResponseTime}}){{end}} {{if .MinResponseTime}} - {{t "common.textField" "label" (t "report.latency.range") "value" (printf "%s / %s" .MinResponseTime .MaxResponseTime)}} diff --git a/server/internal/handlers/emailpreview/fixtures_report.go b/server/internal/handlers/emailpreview/fixtures_report.go index 0929375ff..ef7ae4deb 100644 --- a/server/internal/handlers/emailpreview/fixtures_report.go +++ b/server/internal/handlers/emailpreview/fixtures_report.go @@ -44,6 +44,13 @@ func uptimeReportFixture(loc i18n.Locale) map[string]any { objective := report.SLOs[0] + topFailing := make([]map[string]any, 0, len(report.TopFailingChecks)) + for _, row := range report.TopFailingChecks { + topFailing = append(topFailing, map[string]any{ + keyName: row.Name, "URL": row.URL, "Detail": row.Detail, + }) + } + return map[string]any{ keyOrgName: report.OrgName, "PeriodLabel": report.PeriodLabel, @@ -57,6 +64,13 @@ func uptimeReportFixture(loc i18n.Locale) map[string]any { "AverageIncident": report.AverageIncident, "TotalDowntime": report.TotalDowntime, + "ShowInsights": report.ShowInsights, + "MedianTimeToResolve": report.MedianTimeToResolve, + "MedianTimeToAcknowledge": report.MedianTimeToAcknowledge, + "AcknowledgedCount": report.AcknowledgedCount, + "PagedCount": report.PagedCount, + "TopFailingChecks": topFailing, + // Period-over-period trend. Present in the fixture so the preview // exercises the delta spans rather than only their absence. "PreviousPeriodLabel": report.PreviousPeriodLabel, @@ -120,6 +134,20 @@ func uptimeReportPreview() *uptimereport.Data { CheckCount: 2, IncidentCount: 3, + ShowInsights: true, + AcknowledgedCount: 2, + PagedCount: 3, + TopFailingChecks: []uptimereport.FailingCheckRow{ + { + Name: fixtureCheckName, IncidentCount: 2, RelapseCount: 1, + URL: "https://solidping.example/d/orgs/acme/checks/chk-prod-api", + }, + { + Name: "Marketing site", IncidentCount: 1, + URL: "https://solidping.example/d/orgs/acme/checks/chk-marketing", + }, + }, + HasPreviousData: true, PreviousIncidentCount: 5, ShowAvailabilityDelta: true, @@ -167,15 +195,18 @@ func uptimeReportPreview() *uptimereport.Data { LongestIncident: 42 * time.Minute, AverageIncident: 21*time.Minute + 40*time.Second, TotalDowntime: time.Hour + 5*time.Minute, - AvgResponse: 171, - MinResponse: 42, - MaxResponse: 3200, - HasResponseRange: true, - PreviousAvgResponse: 158, - SlowSamples: 4, - SlowPeaks: 2, - StripStart: time.Date(year, time.July, 1, 0, 0, 0, 0, time.UTC), - StripDays: 31, + + MedianTimeToResolve: 18 * time.Minute, + MedianTimeToAcknowledge: 4 * time.Minute, + AvgResponse: 171, + MinResponse: 42, + MaxResponse: 3200, + HasResponseRange: true, + PreviousAvgResponse: 158, + SlowSamples: 4, + SlowPeaks: 2, + StripStart: time.Date(year, time.July, 1, 0, 0, 0, 0, time.UTC), + StripDays: 31, }, } } diff --git a/server/internal/i18n/locales/de/report.json b/server/internal/i18n/locales/de/report.json index df1baea11..c07edeafb 100644 --- a/server/internal/i18n/locales/de/report.json +++ b/server/internal/i18n/locales/de/report.json @@ -89,5 +89,14 @@ "perCheckTruncated": "Pro Prüfung, schlechteste zuerst (angezeigt werden die {shown} Prüfungen mit der niedrigsten Verfügbarkeit von insgesamt {total}):", "objectivesHeading": "Ziele:", "budgetRemaining": "verbleibendes Budget {budget}" + }, + "insights": { + "title": "Meiste Vorfälle", + "resolve": "Mediane Zeit bis zur Lösung: {duration}", + "acknowledge": "Mediane Zeit bis zur Bestätigung: {duration}", + "acknowledged": "{count} von {total} bestätigt", + "rowDetail": "{incidents}, {relapses}", + "relapses_one": "{count} Rückfall", + "relapses_other": "{count} Rückfälle" } } diff --git a/server/internal/i18n/locales/en/report.json b/server/internal/i18n/locales/en/report.json index 9bf9bb195..e3e7d36fb 100644 --- a/server/internal/i18n/locales/en/report.json +++ b/server/internal/i18n/locales/en/report.json @@ -89,5 +89,14 @@ "perCheckTruncated": "Per check, worst first (showing the {shown} lowest-uptime checks of {total}):", "objectivesHeading": "Objectives:", "budgetRemaining": "budget remaining {budget}" + }, + "insights": { + "title": "Most incidents", + "resolve": "Median time to resolve: {duration}", + "acknowledge": "Median time to acknowledge: {duration}", + "acknowledged": "{count} of {total} acknowledged", + "rowDetail": "{incidents}, {relapses}", + "relapses_one": "{count} relapse", + "relapses_other": "{count} relapses" } } diff --git a/server/internal/i18n/locales/es/report.json b/server/internal/i18n/locales/es/report.json index 59fd31ddd..a8907f5f3 100644 --- a/server/internal/i18n/locales/es/report.json +++ b/server/internal/i18n/locales/es/report.json @@ -89,5 +89,14 @@ "perCheckTruncated": "Por comprobación, las peores primero (se muestran las {shown} comprobaciones con menor disponibilidad de un total de {total}):", "objectivesHeading": "Objetivos:", "budgetRemaining": "presupuesto restante {budget}" + }, + "insights": { + "title": "Más incidentes", + "resolve": "Tiempo mediano de resolución: {duration}", + "acknowledge": "Tiempo mediano de reconocimiento: {duration}", + "acknowledged": "{count} de {total} reconocidos", + "rowDetail": "{incidents}, {relapses}", + "relapses_one": "{count} recaída", + "relapses_other": "{count} recaídas" } } diff --git a/server/internal/i18n/locales/fr/report.json b/server/internal/i18n/locales/fr/report.json index 15c63629f..e6c5dc48b 100644 --- a/server/internal/i18n/locales/fr/report.json +++ b/server/internal/i18n/locales/fr/report.json @@ -89,5 +89,14 @@ "perCheckTruncated": "Par contrôle, les moins bons en premier (les {shown} contrôles sur {total} à la plus faible disponibilité) :", "objectivesHeading": "Objectifs :", "budgetRemaining": "budget restant {budget}" + }, + "insights": { + "title": "Le plus d'incidents", + "resolve": "Délai médian de résolution : {duration}", + "acknowledge": "Délai médian d'acquittement : {duration}", + "acknowledged": "{count} sur {total} acquittés", + "rowDetail": "{incidents}, {relapses}", + "relapses_one": "{count} rechute", + "relapses_other": "{count} rechutes" } } diff --git a/server/internal/uptimereport/builder_test.go b/server/internal/uptimereport/builder_test.go index 7460fe692..b566fdd2b 100644 --- a/server/internal/uptimereport/builder_test.go +++ b/server/internal/uptimereport/builder_test.go @@ -428,3 +428,115 @@ func TestBuilderReportsAverageIncidentDuration(t *testing.T) { r.Equal("20m", data.AverageIncident) r.Equal("40m", data.TotalDowntime) } + +func (e *builderEnv) addIncident( + t *testing.T, check *models.Check, startedAt time.Time, resolveAfter, ackAfter time.Duration, relapses int, +) { + t.Helper() + + incident := models.NewIncident(e.org.UID, check.UID, startedAt, "down") + incident.RelapseCount = relapses + + if resolveAfter > 0 { + resolvedAt := startedAt.Add(resolveAfter) + resolutionType := models.ResolutionTypeAuto + incident.State = models.IncidentStateResolved + incident.ResolvedAt = &resolvedAt + incident.ResolutionType = &resolutionType + } + + if ackAfter > 0 { + ackedAt := startedAt.Add(ackAfter) + incident.AcknowledgedAt = &ackedAt + } + + require.NoError(t, e.db.CreateIncident(e.ctx, incident)) +} + +func TestBuilderFillsIncidentInsights(t *testing.T) { + t.Parallel() + + r := require.New(t) + + env := newBuilderEnv(t) + now := time.Now().UTC() + window := currentMonth(now) + day := 24 * time.Hour + + api := env.createCheck(t, "api") + web := env.createCheck(t, "web") + outside := env.createCheck(t, "outside") + + for _, check := range []*models.Check{api, web, outside} { + env.addRaw(t, check, now.Add(-time.Hour), 90, 100, 150) + } + + env.addIncident(t, api, window.Start.Add(day), 10*time.Minute, 2*time.Minute, 1) + env.addIncident(t, api, window.Start.Add(2*day), 30*time.Minute, 0, 2) + env.addIncident(t, web, window.Start.Add(3*day), 20*time.Minute, 4*time.Minute, 0) + // Not in the schedule's scope: must not be counted anywhere. + env.addIncident(t, outside, window.Start.Add(4*day), 5*time.Hour, 0, 9) + env.addIncident(t, outside, window.Start.Add(5*day), 5*time.Hour, 0, 9) + env.addIncident(t, outside, window.Start.Add(6*day), 5*time.Hour, 0, 9) + + schedule := monthlySchedule(env.org.UID) + schedule.CheckUIDs = []string{api.UID, web.UID} + + data, err := env.builder.Build(env.ctx, env.org, schedule, window, now) + r.NoError(err) + + r.True(data.ShowInsights) + r.Equal(3, data.IncidentCount) + r.Equal("20m", data.MedianTimeToResolve) + r.Equal("2m", data.MedianTimeToAcknowledge) // nearest-rank median of 2m and 4m + r.Equal(2, data.AcknowledgedCount) + r.Equal(3, data.PagedCount) + + r.Len(data.TopFailingChecks, 2) + r.Equal("api", data.TopFailingChecks[0].Name) + r.Equal(2, data.TopFailingChecks[0].IncidentCount) + r.Equal(3, data.TopFailingChecks[0].RelapseCount) + r.Equal("web", data.TopFailingChecks[1].Name) + r.Equal(1, data.TopFailingChecks[1].IncidentCount) +} + +func TestBuilderLeavesInsightsEmptyWithoutIncidents(t *testing.T) { + t.Parallel() + + r := require.New(t) + + env := newBuilderEnv(t) + now := time.Now().UTC() + + check := env.createCheck(t, "api") + env.addRaw(t, check, now.Add(-time.Hour), 100, 100, 150) + + data, err := env.builder.Build(env.ctx, env.org, monthlySchedule(env.org.UID), currentMonth(now), now) + r.NoError(err) + + r.False(data.ShowInsights) + r.Empty(data.MedianTimeToResolve) + r.Empty(data.MedianTimeToAcknowledge) + r.Zero(data.AcknowledgedCount) + r.Empty(data.TopFailingChecks) +} + +func TestBuilderOmitsTopFailingForASingleCheckScope(t *testing.T) { + t.Parallel() + + r := require.New(t) + + env := newBuilderEnv(t) + now := time.Now().UTC() + window := currentMonth(now) + + check := env.createCheck(t, "api") + env.addRaw(t, check, now.Add(-time.Hour), 90, 100, 150) + env.addIncident(t, check, window.Start.Add(24*time.Hour), 10*time.Minute, time.Minute, 0) + + data, err := env.builder.Build(env.ctx, env.org, monthlySchedule(env.org.UID), window, now) + r.NoError(err) + + r.True(data.ShowInsights) + r.Empty(data.TopFailingChecks) +} diff --git a/server/internal/uptimereport/locale_report_test.go b/server/internal/uptimereport/locale_report_test.go index 768610836..b7e80c818 100644 --- a/server/internal/uptimereport/locale_report_test.go +++ b/server/internal/uptimereport/locale_report_test.go @@ -46,7 +46,7 @@ func englishLeftovers() []string { return []string{ "Uptime report", "Availability", "Response time", "Objectives", "Open dashboard", "worst first", "No data", "Checks monitored", "Total downtime", "Budget remaining", - " vs ", "uptime", "longest", "average", "samples", "Gray means", + " vs ", "uptime", "longest", "average", "samples", "Gray means", "Most incidents", "Median time", "acknowledged", "relapse", } } @@ -281,3 +281,30 @@ func TestUptimeReportPluralsSurviveTheJob(t *testing.T) { r.Contains(preheader[1], tc.want, "%s, %d incidents", tc.lang, tc.count) } } + +// TestUptimeReportInsightsAreWrittenInEveryLanguage pins the new strings: each +// locale renders its own figures and block title, with no raw key left. +func TestUptimeReportInsightsAreWrittenInEveryLanguage(t *testing.T) { + t.Parallel() + + want := map[string][]string{ + i18n.LangEnglish: {"Median time to resolve: 18m", "2 of 3 acknowledged", "Most incidents", "2 incidents, 1 relapse"}, + i18n.LangFrench: {"Délai médian de résolution", "2 sur 3 acquittés", "Le plus d'incidents", "2 incidents, 1 rechute"}, + i18n.LangGerman: {"Mediane Zeit bis zur Lösung", "2 von 3 bestätigt", "Meiste Vorfälle", "2 Vorfälle, 1 Rückfall"}, + i18n.LangSpanish: {"Tiempo mediano de resolución", "2 de 3 reconocidos", "Más incidentes", "2 incidentes, 1 recaída"}, + } + + formatter, err := email.NewFormatter() + require.NoError(t, err) + + for lang, phrases := range want { + _, body, text := renderReport(t, formatter, i18n.New(lang, "Europe/Paris"), sampleData()) + + for _, phrase := range phrases { + require.Contains(t, visibleText(body), phrase, lang) + require.Contains(t, text, phrase, lang) + } + + require.NotContains(t, text, "report.insights", lang) + } +} diff --git a/server/internal/uptimereport/render_test.go b/server/internal/uptimereport/render_test.go index 1846f839a..3abb61a53 100644 --- a/server/internal/uptimereport/render_test.go +++ b/server/internal/uptimereport/render_test.go @@ -42,6 +42,14 @@ func sampleData() *uptimereport.Data { CheckCount: 2, IncidentCount: 3, + ShowInsights: true, + AcknowledgedCount: 2, + PagedCount: 3, + TopFailingChecks: []uptimereport.FailingCheckRow{ + {Name: "Production API", URL: "https://solidping.example/d/orgs/acme/checks/chk-prod-api", IncidentCount: 2, RelapseCount: 1}, + {Name: "Marketing site", IncidentCount: 1}, + }, + HasPreviousData: true, PreviousIncidentCount: 5, @@ -98,15 +106,18 @@ func sampleData() *uptimereport.Data { LongestIncident: 42 * time.Minute, AverageIncident: 21*time.Minute + 40*time.Second, TotalDowntime: time.Hour + 5*time.Minute, - AvgResponse: 171, - MinResponse: 42, - MaxResponse: 3200, - HasResponseRange: true, - PreviousAvgResponse: 158, - SlowSamples: 4, - SlowPeaks: 2, - StripStart: time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC), - StripDays: 31, + + MedianTimeToResolve: 18 * time.Minute, + MedianTimeToAcknowledge: 4 * time.Minute, + AvgResponse: 171, + MinResponse: 42, + MaxResponse: 3200, + HasResponseRange: true, + PreviousAvgResponse: 158, + SlowSamples: 4, + SlowPeaks: 2, + StripStart: time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC), + StripDays: 31, }, } @@ -299,6 +310,7 @@ func TestUptimeReportRendersNoDataHonestly(t *testing.T) { data.AvailabilityPct = "" data.Checks = nil data.SLOs = nil + data.TopFailingChecks = nil _, html, text, err := formatter.Format(uptimereport.TemplateName, i18n.English, roundTrip(t, data)) r.NoError(err) @@ -457,6 +469,7 @@ func TestUptimeReportEveryViewModelFieldSurvivesTheRoundTrip(t *testing.T) { requireEveryFieldIsKeyed(t, data.Checks[0]) requireEveryFieldIsKeyed(t, data.Checks[0].Days[0]) requireEveryFieldIsKeyed(t, data.SLOs[0]) + requireEveryFieldIsKeyed(t, data.TopFailingChecks[0]) // And the whole thing still renders with no unresolved key. formatter, err := email.NewFormatter() @@ -628,3 +641,88 @@ func TestUptimeReportDownAllPeriodStatesTheFactAndDropsLatency(t *testing.T) { r.NotContains(html, "color:#15803d") r.NotContains(html, `bgcolor="#15803d"`) } + +// insightsCopy is what the three figures and the block render as in English +// for sampleData. +var insightsCopy = struct{ resolve, acknowledge, acknowledged, title string }{ + resolve: "Median time to resolve: 18m", + acknowledge: "Median time to acknowledge: 4m", + acknowledged: "2 of 3 acknowledged", + title: "Most incidents", +} + +func renderInsights(t *testing.T, data *uptimereport.Data) (string, string) { + t.Helper() + + formatter, err := email.NewFormatter() + require.NoError(t, err) + + _, html, text, err := formatter.Format(uptimereport.TemplateName, i18n.English, roundTrip(t, data.In(i18n.English))) + require.NoError(t, err) + + return visibleText(html), text +} + +func TestUptimeReportRendersInsightsLineAndBlock(t *testing.T) { + t.Parallel() + + html, text := renderInsights(t, sampleData()) + + for _, body := range []string{html, text} { + require.Contains(t, body, insightsCopy.resolve) + require.Contains(t, body, insightsCopy.acknowledge) + require.Contains(t, body, insightsCopy.acknowledged) + require.Contains(t, body, insightsCopy.title) + require.Contains(t, body, "2 incidents, 1 relapse") + require.Contains(t, body, "1 incident, 0 relapses") + } +} + +func TestUptimeReportOmitsInsightsWithoutIncidents(t *testing.T) { + t.Parallel() + + data := sampleData() + data.IncidentCount = 0 + data.ShowInsights = false + data.AcknowledgedCount, data.PagedCount = 0, 0 + data.TopFailingChecks = nil + data.Figures.MedianTimeToResolve, data.Figures.MedianTimeToAcknowledge = 0, 0 + + html, text := renderInsights(t, data) + + for _, body := range []string{html, text} { + require.NotContains(t, body, "Median time") + require.NotContains(t, body, "acknowledged") + require.NotContains(t, body, insightsCopy.title) + } +} + +func TestUptimeReportInsightsWithNothingAcknowledged(t *testing.T) { + t.Parallel() + + data := sampleData() + data.AcknowledgedCount = 0 + data.Figures.MedianTimeToAcknowledge = 0 + + html, text := renderInsights(t, data) + + for _, body := range []string{html, text} { + require.NotContains(t, body, "Median time to acknowledge") + require.Contains(t, body, "0 of 3 acknowledged") + require.Contains(t, body, insightsCopy.resolve) + } +} + +func TestUptimeReportOmitsMostIncidentsForASingleCheck(t *testing.T) { + t.Parallel() + + data := sampleData() + data.TopFailingChecks = nil + + html, text := renderInsights(t, data) + + for _, body := range []string{html, text} { + require.NotContains(t, body, insightsCopy.title) + require.Contains(t, body, insightsCopy.acknowledged) + } +} diff --git a/server/internal/uptimereport/report.go b/server/internal/uptimereport/report.go index 3157ff3a5..e301da8b3 100644 --- a/server/internal/uptimereport/report.go +++ b/server/internal/uptimereport/report.go @@ -22,6 +22,7 @@ import ( "github.com/fclairamb/solidping/server/internal/handlers/slos" "github.com/fclairamb/solidping/server/internal/i18n" "github.com/fclairamb/solidping/server/internal/i18n/recipient" + "github.com/fclairamb/solidping/server/internal/incidentinsights" "github.com/fclairamb/solidping/server/internal/slo" "github.com/fclairamb/solidping/server/internal/systemconfig" "github.com/fclairamb/solidping/server/internal/uptimebar" @@ -164,6 +165,22 @@ type Data struct { AverageIncident string `json:"AverageIncident,omitempty"` TotalDowntime string `json:"TotalDowntime,omitempty"` + // --- Incident insights (package incidentinsights) -------------------- + // + // ShowInsights is false when the period had no incident: neither the + // medians line nor the block is rendered. The medians are empty when + // nothing was acknowledged / resolved. AcknowledgedCount and PagedCount + // carry no omitempty: "0 of 3 acknowledged" must survive the round trip. + ShowInsights bool `json:"ShowInsights"` + MedianTimeToResolve string `json:"MedianTimeToResolve,omitempty"` + MedianTimeToAcknowledge string `json:"MedianTimeToAcknowledge,omitempty"` + AcknowledgedCount int `json:"AcknowledgedCount"` + // PagedCount is the incidents somebody could have acknowledged. + PagedCount int `json:"PagedCount"` + // TopFailingChecks is up to maxTopFailing checks, most incidents first. + // Empty (block omitted) for a scope of a single check. + TopFailingChecks []FailingCheckRow `json:"TopFailingChecks,omitempty"` + // --- Period-over-period trend ------------------------------------- // // Every Show* flag is false when there is no previous-period data, and the @@ -275,6 +292,10 @@ type Figures struct { AverageIncident time.Duration TotalDowntime time.Duration + // Incident insight medians (zero when there is no sample). + MedianTimeToResolve time.Duration + MedianTimeToAcknowledge time.Duration + // Response times in milliseconds (HasLatency, the range when // HasResponseRange, the previous average when ShowResponseDelta). AvgResponse float64 @@ -384,12 +405,13 @@ func (b *Builder) measure( applyOverall(data, overall, checks) - incidents, err := b.incidentBlock(ctx, org.UID, checkUIDs, window, now) + incidents, incidentRows, err := b.incidentBlock(ctx, org.UID, checkUIDs, window, now) if err != nil { return nil, err } applyIncidents(data, incidents) + applyInsights(data, checks, incidentRows, window, now, baseURL, org.Slug) if trendErr := b.applyPreviousPeriod( ctx, org.UID, schedule, checkUIDs, window, now, hints, data, overall, incidents.Count, @@ -476,6 +498,77 @@ func applyIncidents(data *Data, incidents availability.PeriodIncidents) { data.Figures.TotalDowntime = time.Duration(incidents.TotalDowntimeSeconds) * time.Second } +// maxTopFailing caps the "Most incidents" block. +const maxTopFailing = 5 + +// FailingCheckRow is one line of the "Most incidents" block. +// +//nolint:tagliatelle // template view-model keys are PascalCase; see Data. +type FailingCheckRow struct { + Name string `json:"Name"` + URL string `json:"URL,omitempty"` + IncidentCount int `json:"IncidentCount"` + RelapseCount int `json:"RelapseCount"` + // Detail is "4 incidents, 2 relapses", written in the reader's language. + Detail string `json:"Detail,omitempty"` +} + +// applyInsights fills the incident insights from the incidents of the scope's +// checks, using the same computation as the dashboard page. +func applyInsights( + data *Data, checks []*models.Check, incidents []*models.Incident, + window slo.Window, now time.Time, baseURL, orgSlug string, +) { + byUID := make(map[string]*models.Check, len(checks)) + for _, check := range checks { + byUID[check.UID] = check + } + + scoped := make([]*models.Incident, 0, len(incidents)) + + for _, inc := range incidents { + if _, ok := byUID[inc.CheckUID]; ok { + scoped = append(scoped, inc) + } + } + + result := incidentinsights.Compute(scoped, window.Start.UTC(), window.End.UTC(), now.UTC()) + if result.Summary.IncidentCount == 0 { + return + } + + data.ShowInsights = true + data.AcknowledgedCount = result.Summary.AcknowledgedCount + data.PagedCount = result.Summary.PagedCount + + if m := result.Summary.TimeToResolve.MedianSeconds; m != nil { + data.Figures.MedianTimeToResolve = time.Duration(*m) * time.Second + } + + if m := result.Summary.TimeToAcknowledge.MedianSeconds; m != nil { + data.Figures.MedianTimeToAcknowledge = time.Duration(*m) * time.Second + } + + // The table already names the one check of a single-check scope. + if len(checks) < 2 { + return + } + + for _, row := range result.Checks { + if len(data.TopFailingChecks) == maxTopFailing { + break + } + + check := byUID[row.CheckUID] + data.TopFailingChecks = append(data.TopFailingChecks, FailingCheckRow{ + Name: checkDisplayName(check), + URL: checkReportURL(baseURL, orgSlug, check), + IncidentCount: row.IncidentCount, + RelapseCount: row.RelapseCount, + }) + } +} + // buildCheckRows folds the per-check window stats into rows and, at the same // time, into the scope-level total. Returning both from one pass is what keeps // "the headline" and "the sum of the table" from ever disagreeing. @@ -582,7 +675,7 @@ func (b *Builder) applyPreviousPeriod( previousOverall.Add(byCheck[checkUID]) } - previousIncidents, err := b.incidentBlock(ctx, orgUID, checkUIDs, previous, now) + previousIncidents, _, err := b.incidentBlock(ctx, orgUID, checkUIDs, previous, now) if err != nil { return err } @@ -658,7 +751,7 @@ func (b *Builder) scopeChecks( func (b *Builder) incidentBlock( ctx context.Context, orgUID string, checkUIDs []string, window slo.Window, now time.Time, -) (availability.PeriodIncidents, error) { +) (availability.PeriodIncidents, []*models.Incident, error) { seen := make(map[string]struct{}) all := make([]*models.Incident, 0) @@ -676,7 +769,7 @@ func (b *Builder) incidentBlock( incidents, _, err := b.db.ListIncidents(ctx, filter) if err != nil { - return availability.PeriodIncidents{}, fmt.Errorf("list incidents: %w", err) + return availability.PeriodIncidents{}, nil, fmt.Errorf("list incidents: %w", err) } for _, inc := range incidents { @@ -690,7 +783,9 @@ func (b *Builder) incidentBlock( } } - return availability.IncidentBlock(all, window.Start.UTC(), window.End.UTC(), now.UTC()), nil + block := availability.IncidentBlock(all, window.Start.UTC(), window.End.UTC(), now.UTC()) + + return block, all, nil } // sloRows evaluates every enabled objective whose scope intersects the report's. diff --git a/server/internal/uptimereport/size_test.go b/server/internal/uptimereport/size_test.go index 7cc2f562c..c4a550e86 100644 --- a/server/internal/uptimereport/size_test.go +++ b/server/internal/uptimereport/size_test.go @@ -50,6 +50,9 @@ func worstCaseMonthlyReport(t *testing.T) *Data { AvailabilityColor: "#c2410c", CheckCount: maxCheckRows + 87, IncidentCount: 42, + ShowInsights: true, + AcknowledgedCount: 30, + PagedCount: 42, HasPreviousData: true, ShowAvailabilityDelta: true, AvailabilityDeltaColor: deltaBadColor, @@ -77,15 +80,18 @@ func worstCaseMonthlyReport(t *testing.T) *Data { LongestIncident: 3*time.Hour + 12*time.Minute, AverageIncident: 18 * time.Minute, TotalDowntime: 12*time.Hour + 36*time.Minute, - AvgResponse: 412, - MinResponse: 37, - MaxResponse: 9800, - HasResponseRange: true, - PreviousAvgResponse: 292, - SlowSamples: 318, - SlowPeaks: 24, - StripStart: time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC), - StripDays: days, + + MedianTimeToResolve: 2*time.Hour + 41*time.Minute, + MedianTimeToAcknowledge: 13*time.Minute + 20*time.Second, + AvgResponse: 412, + MinResponse: 37, + MaxResponse: 9800, + HasResponseRange: true, + PreviousAvgResponse: 292, + SlowSamples: 318, + SlowPeaks: 24, + StripStart: time.Date(2026, time.July, 1, 0, 0, 0, 0, time.UTC), + StripDays: days, }, } @@ -108,6 +114,15 @@ func worstCaseMonthlyReport(t *testing.T) *Data { }) } + for i := range maxTopFailing { + data.TopFailingChecks = append(data.TopFailingChecks, FailingCheckRow{ + Name: fmt.Sprintf("top-failing-check-with-a-fairly-long-name-%02d.example", i), + URL: fmt.Sprintf("https://solidping.example/d/orgs/acme/checks/chk-%02d", i), + IncidentCount: 40 - i, + RelapseCount: 12 - i, + }) + } + // Exactly what Build does before handing the view model to the mailer, // including the note it raises when the budget drops a strip — the test // must never hand-write that text, or it would pass while the builder diff --git a/server/internal/uptimereport/write.go b/server/internal/uptimereport/write.go index b39e1ffc1..b64ae4c28 100644 --- a/server/internal/uptimereport/write.go +++ b/server/internal/uptimereport/write.go @@ -56,6 +56,7 @@ func (d *Data) In(loc i18n.Locale) *Data { out := *d out.Checks = slices.Clone(d.Checks) out.SLOs = slices.Clone(d.SLOs) + out.TopFailingChecks = slices.Clone(d.TopFailingChecks) out.readers = nil out.written = nil out.write(loc) @@ -84,6 +85,9 @@ func (d *Data) write(loc i18n.Locale) { d.AverageIncident = spanIfAny(loc, fig.AverageIncident) d.TotalDowntime = spanIfAny(loc, fig.TotalDowntime) + d.MedianTimeToResolve = spanIfAny(loc, fig.MedianTimeToResolve) + d.MedianTimeToAcknowledge = spanIfAny(loc, fig.MedianTimeToAcknowledge) + d.writeTrend(loc) d.writeLatency(loc) @@ -92,6 +96,13 @@ func (d *Data) write(loc i18n.Locale) { row.AvailabilityPct = pctIf(loc, row.HasData, row.Availability) } + for i := range d.TopFailingChecks { + row := &d.TopFailingChecks[i] + row.Detail = loc.T("report.insights.rowDetail", + "incidents", loc.Tn("report.incidents", row.IncidentCount), + "relapses", loc.Tn("report.insights.relapses", row.RelapseCount)) + } + for i := range d.SLOs { d.SLOs[i].write(loc) } diff --git a/web/docs/docs/features/slos.md b/web/docs/docs/features/slos.md index c9e57dc34..b553f8e55 100644 --- a/web/docs/docs/features/slos.md +++ b/web/docs/docs/features/slos.md @@ -119,6 +119,13 @@ scope. - **Incidents**: how many, the longest, the average, and the total downtime, each against the previous period. There is deliberately no MTBF: with the handful of incidents a typical month produces it is not a meaningful number. +- **Incident response**: the median time to resolve, the median time to + acknowledge, and "12 of 40 acknowledged", computed by the same code as the + incident insights page of the dashboard. A **Most incidents** block lists the + five checks that opened the most incidents, with their relapses, so a check + that flaps all month is not hidden behind a good availability. Nothing is + shown for a period without incident, and the block is left out when the + report covers a single check. - **Response time**: average, fastest and slowest, and how many probes were slower than one second. Recent periods count individual *samples*; older data is stored as rollups, which can only report *peaks* — a rolled-up period whose From 8f2d07ecae5132fa7cf5e94699e8367ed52a8ce7 Mon Sep 17 00:00:00 2001 From: fclairamb Date: Fri, 13 Nov 2026 00:57:14 +0100 Subject: [PATCH 08/23] chore: all checks passing for Add response times and most-failing checks to the scheduled uptime report --- .../handlers/emailpreview/fixtures_report.go | 84 +++++++++++-------- .../uptimereport/locale_report_test.go | 14 +++- server/internal/uptimereport/render_test.go | 33 ++++---- server/internal/uptimereport/report.go | 4 +- 4 files changed, 81 insertions(+), 54 deletions(-) diff --git a/server/internal/handlers/emailpreview/fixtures_report.go b/server/internal/handlers/emailpreview/fixtures_report.go index ef7ae4deb..a179a7c91 100644 --- a/server/internal/handlers/emailpreview/fixtures_report.go +++ b/server/internal/handlers/emailpreview/fixtures_report.go @@ -44,12 +44,7 @@ func uptimeReportFixture(loc i18n.Locale) map[string]any { objective := report.SLOs[0] - topFailing := make([]map[string]any, 0, len(report.TopFailingChecks)) - for _, row := range report.TopFailingChecks { - topFailing = append(topFailing, map[string]any{ - keyName: row.Name, "URL": row.URL, "Detail": row.Detail, - }) - } + topFailing := topFailingMaps(report.TopFailingChecks) return map[string]any{ keyOrgName: report.OrgName, @@ -124,7 +119,6 @@ func uptimeReportPreview() *uptimereport.Data { year := time.Now().Year() july := time.Date(year, time.July, 1, 0, 0, 0, 0, paris) june := july.AddDate(0, -1, 0) - attainment := 99.95 return &uptimereport.Data{ OrgName: fixtureOrgName, @@ -137,16 +131,7 @@ func uptimeReportPreview() *uptimereport.Data { ShowInsights: true, AcknowledgedCount: 2, PagedCount: 3, - TopFailingChecks: []uptimereport.FailingCheckRow{ - { - Name: fixtureCheckName, IncidentCount: 2, RelapseCount: 1, - URL: "https://solidping.example/d/orgs/acme/checks/chk-prod-api", - }, - { - Name: "Marketing site", IncidentCount: 1, - URL: "https://solidping.example/d/orgs/acme/checks/chk-marketing", - }, - }, + TopFailingChecks: previewTopFailing(), HasPreviousData: true, PreviousIncidentCount: 5, @@ -173,14 +158,7 @@ func uptimeReportPreview() *uptimereport.Data { Days: []uptimereport.DayCell{{Color: "#d1d5db", Span: 31, Wide: true}}, }, }, - SLOs: []uptimereport.SLORow{{ - Name: "API availability", - HasData: true, - State: slo.StateHealthy, - Attainment: &attainment, - Target: 99.9, - Budget: 21*time.Minute + 30*time.Second, - }}, + SLOs: previewSLOs(), DashboardURL: fixtureDashboardURL, Figures: uptimereport.Figures{ @@ -198,15 +176,53 @@ func uptimeReportPreview() *uptimereport.Data { MedianTimeToResolve: 18 * time.Minute, MedianTimeToAcknowledge: 4 * time.Minute, - AvgResponse: 171, - MinResponse: 42, - MaxResponse: 3200, - HasResponseRange: true, - PreviousAvgResponse: 158, - SlowSamples: 4, - SlowPeaks: 2, - StripStart: time.Date(year, time.July, 1, 0, 0, 0, 0, time.UTC), - StripDays: 31, + + AvgResponse: 171, + MinResponse: 42, + MaxResponse: 3200, + HasResponseRange: true, + PreviousAvgResponse: 158, + SlowSamples: 4, + SlowPeaks: 2, + StripStart: time.Date(year, time.July, 1, 0, 0, 0, 0, time.UTC), + StripDays: 31, + }, + } +} + +// previewTopFailing is the "Most incidents" block of the preview. +func previewTopFailing() []uptimereport.FailingCheckRow { + return []uptimereport.FailingCheckRow{ + { + Name: fixtureCheckName, IncidentCount: 2, RelapseCount: 1, + URL: "https://solidping.example/d/orgs/acme/checks/chk-prod-api", + }, + { + Name: "Marketing site", IncidentCount: 1, + URL: "https://solidping.example/d/orgs/acme/checks/chk-marketing", }, } } + +func topFailingMaps(rows []uptimereport.FailingCheckRow) []map[string]any { + out := make([]map[string]any, 0, len(rows)) + + for i := range rows { + out = append(out, map[string]any{keyName: rows[i].Name, "URL": rows[i].URL, "Detail": rows[i].Detail}) + } + + return out +} + +func previewSLOs() []uptimereport.SLORow { + attainment := 99.95 + + return []uptimereport.SLORow{{ + Name: "API availability", + HasData: true, + State: slo.StateHealthy, + Attainment: &attainment, + Target: 99.9, + Budget: 21*time.Minute + 30*time.Second, + }} +} diff --git a/server/internal/uptimereport/locale_report_test.go b/server/internal/uptimereport/locale_report_test.go index b7e80c818..1d3b1865b 100644 --- a/server/internal/uptimereport/locale_report_test.go +++ b/server/internal/uptimereport/locale_report_test.go @@ -46,7 +46,8 @@ func englishLeftovers() []string { return []string{ "Uptime report", "Availability", "Response time", "Objectives", "Open dashboard", "worst first", "No data", "Checks monitored", "Total downtime", "Budget remaining", - " vs ", "uptime", "longest", "average", "samples", "Gray means", "Most incidents", "Median time", "acknowledged", "relapse", + " vs ", "uptime", "longest", "average", "samples", "Gray means", + "Most incidents", "Median time", "acknowledged", "relapse", } } @@ -289,9 +290,14 @@ func TestUptimeReportInsightsAreWrittenInEveryLanguage(t *testing.T) { want := map[string][]string{ i18n.LangEnglish: {"Median time to resolve: 18m", "2 of 3 acknowledged", "Most incidents", "2 incidents, 1 relapse"}, - i18n.LangFrench: {"Délai médian de résolution", "2 sur 3 acquittés", "Le plus d'incidents", "2 incidents, 1 rechute"}, - i18n.LangGerman: {"Mediane Zeit bis zur Lösung", "2 von 3 bestätigt", "Meiste Vorfälle", "2 Vorfälle, 1 Rückfall"}, - i18n.LangSpanish: {"Tiempo mediano de resolución", "2 de 3 reconocidos", "Más incidentes", "2 incidentes, 1 recaída"}, + i18n.LangFrench: { + "Délai médian de résolution", "2 sur 3 acquittés", "Le plus d'incidents", "2 incidents, 1 rechute", + }, + i18n.LangGerman: {"Mediane Zeit bis zur Lösung", "2 von 3 bestätigt", "Meiste Vorfälle", "2 Vorfälle, 1 Rückfall"}, + i18n.LangSpanish: { + "Tiempo mediano de resolución", "2 de 3 reconocidos", "Más incidentes", //nolint:misspell // Spanish + "2 incidentes, 1 recaída", //nolint:misspell // Spanish + }, } formatter, err := email.NewFormatter() diff --git a/server/internal/uptimereport/render_test.go b/server/internal/uptimereport/render_test.go index 3abb61a53..b41970800 100644 --- a/server/internal/uptimereport/render_test.go +++ b/server/internal/uptimereport/render_test.go @@ -46,7 +46,10 @@ func sampleData() *uptimereport.Data { AcknowledgedCount: 2, PagedCount: 3, TopFailingChecks: []uptimereport.FailingCheckRow{ - {Name: "Production API", URL: "https://solidping.example/d/orgs/acme/checks/chk-prod-api", IncidentCount: 2, RelapseCount: 1}, + { + Name: "Production API", IncidentCount: 2, RelapseCount: 1, + URL: "https://solidping.example/d/orgs/acme/checks/chk-prod-api", + }, {Name: "Marketing site", IncidentCount: 1}, }, @@ -644,12 +647,12 @@ func TestUptimeReportDownAllPeriodStatesTheFactAndDropsLatency(t *testing.T) { // insightsCopy is what the three figures and the block render as in English // for sampleData. -var insightsCopy = struct{ resolve, acknowledge, acknowledged, title string }{ - resolve: "Median time to resolve: 18m", - acknowledge: "Median time to acknowledge: 4m", - acknowledged: "2 of 3 acknowledged", - title: "Most incidents", -} +const ( + insightsResolve = "Median time to resolve: 18m" + insightsAcknowledge = "Median time to acknowledge: 4m" + insightsAcknowledged = "2 of 3 acknowledged" + insightsTitle = "Most incidents" +) func renderInsights(t *testing.T, data *uptimereport.Data) (string, string) { t.Helper() @@ -669,10 +672,10 @@ func TestUptimeReportRendersInsightsLineAndBlock(t *testing.T) { html, text := renderInsights(t, sampleData()) for _, body := range []string{html, text} { - require.Contains(t, body, insightsCopy.resolve) - require.Contains(t, body, insightsCopy.acknowledge) - require.Contains(t, body, insightsCopy.acknowledged) - require.Contains(t, body, insightsCopy.title) + require.Contains(t, body, insightsResolve) + require.Contains(t, body, insightsAcknowledge) + require.Contains(t, body, insightsAcknowledged) + require.Contains(t, body, insightsTitle) require.Contains(t, body, "2 incidents, 1 relapse") require.Contains(t, body, "1 incident, 0 relapses") } @@ -693,7 +696,7 @@ func TestUptimeReportOmitsInsightsWithoutIncidents(t *testing.T) { for _, body := range []string{html, text} { require.NotContains(t, body, "Median time") require.NotContains(t, body, "acknowledged") - require.NotContains(t, body, insightsCopy.title) + require.NotContains(t, body, insightsTitle) } } @@ -709,7 +712,7 @@ func TestUptimeReportInsightsWithNothingAcknowledged(t *testing.T) { for _, body := range []string{html, text} { require.NotContains(t, body, "Median time to acknowledge") require.Contains(t, body, "0 of 3 acknowledged") - require.Contains(t, body, insightsCopy.resolve) + require.Contains(t, body, insightsResolve) } } @@ -722,7 +725,7 @@ func TestUptimeReportOmitsMostIncidentsForASingleCheck(t *testing.T) { html, text := renderInsights(t, data) for _, body := range []string{html, text} { - require.NotContains(t, body, insightsCopy.title) - require.Contains(t, body, insightsCopy.acknowledged) + require.NotContains(t, body, insightsTitle) + require.Contains(t, body, insightsAcknowledged) } } diff --git a/server/internal/uptimereport/report.go b/server/internal/uptimereport/report.go index e301da8b3..f422c0663 100644 --- a/server/internal/uptimereport/report.go +++ b/server/internal/uptimereport/report.go @@ -554,11 +554,13 @@ func applyInsights( return } - for _, row := range result.Checks { + for i := range result.Checks { if len(data.TopFailingChecks) == maxTopFailing { break } + row := &result.Checks[i] + check := byUID[row.CheckUID] data.TopFailingChecks = append(data.TopFailingChecks, FailingCheckRow{ Name: checkDisplayName(check), From b96cdc84600c6d7e531f151c3bb3ccce0d98451b Mon Sep 17 00:00:00 2001 From: fclairamb Date: Fri, 13 Nov 2026 00:57:25 +0100 Subject: [PATCH 09/23] docs: archive completed spec uptime report incident insights Co-Authored-By: Claude Sonnet 5.5 --- .../2026/11}/2026-11-13-02-uptime-report-incident-insights.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename specs/{todos => done/2026/11}/2026-11-13-02-uptime-report-incident-insights.md (100%) diff --git a/specs/todos/2026-11-13-02-uptime-report-incident-insights.md b/specs/done/2026/11/2026-11-13-02-uptime-report-incident-insights.md similarity index 100% rename from specs/todos/2026-11-13-02-uptime-report-incident-insights.md rename to specs/done/2026/11/2026-11-13-02-uptime-report-incident-insights.md From a6f3ef5d59ce68907ed7753eba17e3115b7eaf1e Mon Sep 17 00:00:00 2001 From: fclairamb Date: Fri, 13 Nov 2026 01:19:29 +0100 Subject: [PATCH 10/23] fix: incidents insights button test id was shadowed by the link's (batch gate) Button asChild merges its props into the Link, whose own data-testid won, so incidents-insights-button never existed in the DOM. Co-Authored-By: Claude Sonnet 5.5 --- web/dash0/src/routes/orgs/$org/incidents.index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/web/dash0/src/routes/orgs/$org/incidents.index.tsx b/web/dash0/src/routes/orgs/$org/incidents.index.tsx index 9c92ae27c..f7ee21206 100644 --- a/web/dash0/src/routes/orgs/$org/incidents.index.tsx +++ b/web/dash0/src/routes/orgs/$org/incidents.index.tsx @@ -332,7 +332,6 @@ function IncidentsIndexPage() { to="/orgs/$org/incidents/insights" params={{ org }} search={{ period: "30d" }} - data-testid="incidents-insights-link" > {t("insights.button")} From f8e36e2b398833fcf9ffa77f99372a6f6fcaae30 Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Fri, 13 Nov 2026 01:39:17 +0100 Subject: [PATCH 11/23] fix: ignore the in-flight /auth/me stub fetch at test teardown (batch gate) The Discord notifications negative-control test ends while the stubbed route.fetch() is pending, so the disposed response failed the test on its first attempt and passed on retry. Co-Authored-By: Claude Sonnet 5.5 --- .../e2e/account-notifications-discord.spec.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/web/dash0/e2e/account-notifications-discord.spec.ts b/web/dash0/e2e/account-notifications-discord.spec.ts index cfcb57fa2..760cb8f06 100644 --- a/web/dash0/e2e/account-notifications-discord.spec.ts +++ b/web/dash0/e2e/account-notifications-discord.spec.ts @@ -82,9 +82,18 @@ async function stubAccount(page: Page, opts: StubOptions = {}) { return; } - const response = await route.fetch(); - const body = await response.json(); - await route.fulfill({ response, json: { ...body, hasPassword } }); + // A test that asserts an absence can end while this fetch is still in + // flight; the page is then gone and the fetch is disposed. That is teardown, + // not a failure, so it must not be reported against the test. + try { + const response = await route.fetch(); + const body = await response.json(); + await route.fulfill({ response, json: { ...body, hasPassword } }); + } catch (error) { + if (!/disposed|closed/i.test(String(error))) { + throw error; + } + } }); await page.route("**/api/v1/config", async (route) => { From 4a00bf0ba11ed33a57f664ec6f0b221547428b43 Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Fri, 13 Nov 2026 01:45:06 +0100 Subject: [PATCH 12/23] fix(incidents): insights and the report load the incidents active during the period Adds ListIncidentsFilter.ActiveSince (resolved_at IS NULL OR resolved_at >= value), used with Until by the insights page and the report's insights figures, so an outage that began before the window and is still open (or resolved inside it) is counted. The report's downtime and incident-count figures are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- server/internal/app/openapi/openapi.yaml | 4 +- .../internal/db/incident_active_since_test.go | 98 +++++++++++++++++++ server/internal/db/models/incident.go | 14 ++- server/internal/db/postgres/postgres.go | 4 + server/internal/db/sqlite/sqlite.go | 4 + .../handlers/incidents/export_test.go | 6 ++ .../internal/handlers/incidents/insights.go | 39 +++++--- server/internal/handlers/incidents/service.go | 4 + server/internal/uptimereport/report.go | 77 +++++++++++---- web/docs/docs/features/incidents.md | 4 +- 10 files changed, 212 insertions(+), 42 deletions(-) create mode 100644 server/internal/db/incident_active_since_test.go diff --git a/server/internal/app/openapi/openapi.yaml b/server/internal/app/openapi/openapi.yaml index 16e8be3d6..817e863df 100644 --- a/server/internal/app/openapi/openapi.yaml +++ b/server/internal/app/openapi/openapi.yaml @@ -4050,7 +4050,9 @@ paths: tags: [Incidents] summary: Incident insights description: >- - Aggregates the check outages of a trailing window: the checks that fail + Aggregates the check outages active during a trailing period (started before + its end, still open or resolved inside it; downtime is clipped to the + period): the checks that fail the most, the time to acknowledge, the time to resolve and how incidents were resolved. Medians and nearest-rank p90, never means. Rolled-up (paging-suppressed) incidents count in the checks table and the resolve diff --git a/server/internal/db/incident_active_since_test.go b/server/internal/db/incident_active_since_test.go new file mode 100644 index 000000000..e9e51edd9 --- /dev/null +++ b/server/internal/db/incident_active_since_test.go @@ -0,0 +1,98 @@ +package db_test + +import ( + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/fclairamb/solidping/server/internal/db" + "github.com/fclairamb/solidping/server/internal/db/models" + "github.com/fclairamb/solidping/server/internal/db/postgres" + "github.com/fclairamb/solidping/server/internal/db/sqlite" + "github.com/fclairamb/solidping/server/internal/testsupport" +) + +func TestListIncidentsActiveSince_SQLite(t *testing.T) { + t.Parallel() + + tempDir, err := os.MkdirTemp("", "sqlite-active-since-*") + require.NoError(t, err) + t.Cleanup(func() { _ = os.RemoveAll(tempDir) }) + + svc, err := sqlite.New(t.Context(), sqlite.Config{DataDir: tempDir}) + require.NoError(t, err) + t.Cleanup(func() { _ = svc.Close() }) + require.NoError(t, svc.Initialize(t.Context())) + + testListIncidentsActiveSince(t, svc) +} + +func TestListIncidentsActiveSince_Postgres(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skipping embedded-postgres test in -short mode") + } + + ctx := t.Context() + + svc, err := postgres.New(ctx, &postgres.Config{Embedded: true, Port: testsupport.FreePort(t), RunMode: "test"}) + if err != nil { + testsupport.PostgresUnavailable(t, err) + } + + t.Cleanup(func() { _ = svc.Close() }) + + if initErr := svc.Initialize(ctx); initErr != nil { + testsupport.PostgresInitFailed(t, initErr) + } + + testListIncidentsActiveSince(t, svc) +} + +// testListIncidentsActiveSince pins ActiveSince: open incidents and incidents +// resolved at/after the bound are kept, earlier-resolved ones are not. +func testListIncidentsActiveSince(t *testing.T, svc db.Service) { + t.Helper() + + ctx := t.Context() + r := require.New(t) + + org := models.NewOrganization("active-since-org", "") + r.NoError(svc.CreateOrganization(ctx, org)) + check := models.NewCheck(org.UID, "active-since-check", "http") + r.NoError(svc.CreateCheck(ctx, check)) + + now := time.Now().UTC() + bound := now.Add(-24 * time.Hour) + + mk := func(started time.Time, resolved *time.Time) string { + inc := models.NewIncident(org.UID, check.UID, started, "x") + inc.ResolvedAt = resolved + r.NoError(svc.CreateIncident(ctx, inc)) + + return inc.UID + } + + before := bound.Add(-time.Hour) + inside := bound.Add(time.Hour) + stillOpen := mk(bound.Add(-48*time.Hour), nil) + resolvedInside := mk(bound.Add(-48*time.Hour), &inside) + resolvedBefore := mk(bound.Add(-48*time.Hour), &before) + + list, _, err := svc.ListIncidents(ctx, &models.ListIncidentsFilter{ + OrganizationUID: org.UID, ActiveSince: &bound, Limit: 10, + }) + r.NoError(err) + + got := map[string]bool{} + for _, inc := range list { + got[inc.UID] = true + } + + r.True(got[stillOpen], "open incident is active") + r.True(got[resolvedInside], "incident resolved after the bound is active") + r.False(got[resolvedBefore], "incident resolved before the bound is not") +} diff --git a/server/internal/db/models/incident.go b/server/internal/db/models/incident.go index 81d83a9cc..e590345af 100644 --- a/server/internal/db/models/incident.go +++ b/server/internal/db/models/incident.go @@ -224,11 +224,15 @@ type ListIncidentsFilter struct { // check outages. Callers that compute DOWNTIME from incidents (availability, // SLO status, uptime reports) pass IncidentKindCheck, or a burn alert would // count as downtime against the very objective that produced it. - Kinds []string - Since *time.Time // Optional: incidents started after this time - Until *time.Time // Optional: incidents started before this time - HideSuppressed bool // Optional: hide rolled-up (paging-suppressed) incidents - CausedByUID string // Optional: only incidents whose caused_by_incident_uid equals this + Kinds []string + Since *time.Time // Optional: incidents started after this time + Until *time.Time // Optional: incidents started before this time + // ActiveSince keeps incidents still open or resolved at/after this instant + // (resolved_at IS NULL OR resolved_at >= value). With Until it selects the + // incidents overlapping a window. + ActiveSince *time.Time + HideSuppressed bool // Optional: hide rolled-up (paging-suppressed) incidents + CausedByUID string // Optional: only incidents whose caused_by_incident_uid equals this // AckedOnly returns only incidents with non-NULL acknowledged_at (and // expired snoozes, since snoozed-but-expired is just acked again). Set by // the handler when ?state=acked is requested. diff --git a/server/internal/db/postgres/postgres.go b/server/internal/db/postgres/postgres.go index 27662c1a3..8781e46c0 100644 --- a/server/internal/db/postgres/postgres.go +++ b/server/internal/db/postgres/postgres.go @@ -4147,6 +4147,10 @@ func applyIncidentsFilter(query *bun.SelectQuery, filter *models.ListIncidentsFi query = query.Where("started_at < ?", *filter.Until) } + if filter.ActiveSince != nil { + query = query.Where("(resolved_at IS NULL OR resolved_at >= ?)", *filter.ActiveSince) + } + if filter.HideSuppressed { query = query.Where("paging_suppressed = ?", false) } diff --git a/server/internal/db/sqlite/sqlite.go b/server/internal/db/sqlite/sqlite.go index ec49c7bc9..68fe73f7f 100644 --- a/server/internal/db/sqlite/sqlite.go +++ b/server/internal/db/sqlite/sqlite.go @@ -4107,6 +4107,10 @@ func applyIncidentsFilter(query *bun.SelectQuery, filter *models.ListIncidentsFi query = query.Where("started_at < ?", *filter.Until) } + if filter.ActiveSince != nil { + query = query.Where("(resolved_at IS NULL OR resolved_at >= ?)", *filter.ActiveSince) + } + if filter.HideSuppressed { query = query.Where("paging_suppressed = ?", false) } diff --git a/server/internal/handlers/incidents/export_test.go b/server/internal/handlers/incidents/export_test.go index 67e567349..538729291 100644 --- a/server/internal/handlers/incidents/export_test.go +++ b/server/internal/handlers/incidents/export_test.go @@ -75,3 +75,9 @@ func (s *Service) RollUpExistingChildrenForTest( ) { s.rollUpExistingChildren(ctx, parentCheck, parent, onset) } + +// SetInsightsLimitsForTest shrinks the insights page size and load cap so +// pagination and truncation can be exercised without thousands of rows. +func (s *Service) SetInsightsLimitsForTest(pageSize, maxLoaded int) { + s.insightsPageSize, s.insightsMaxLoaded = pageSize, maxLoaded +} diff --git a/server/internal/handlers/incidents/insights.go b/server/internal/handlers/incidents/insights.go index 19e71de66..aeed49b10 100644 --- a/server/internal/handlers/incidents/insights.go +++ b/server/internal/handlers/incidents/insights.go @@ -14,10 +14,10 @@ import ( ) const ( - insightsPageSize = 500 - insightsMaxLoaded = 5000 - insightsTopChecks = 20 - insightsDefaultDays = 30 + defaultInsightsPageSize = 500 + defaultInsightsMaxLoaded = 5000 + insightsTopChecks = 20 + insightsDefaultDays = 30 ) var errInvalidInsightsPeriod = errors.New("invalid period: must be one of 7d, 30d, 90d") @@ -77,7 +77,7 @@ func (s *Service) GetInsights(ctx context.Context, orgSlug, period string, days now := s.clock.Now() windowStart := now.AddDate(0, 0, -days) - incidents, truncated, err := s.loadInsightsIncidents(ctx, org.UID, windowStart) + incidents, truncated, err := s.loadInsightsIncidents(ctx, org.UID, windowStart, now) if err != nil { return nil, err } @@ -131,11 +131,21 @@ func (s *Service) GetInsights(ctx context.Context, orgSlug, period string, days return response, nil } -// loadInsightsIncidents pages through the window's check outages, stopping at -// insightsMaxLoaded rows (truncated is then true). +// loadInsightsIncidents pages through the check outages active during the +// window (started before its end, still open or resolved inside it), stopping +// at the load cap (truncated is then true). func (s *Service) loadInsightsIncidents( - ctx context.Context, orgUID string, since time.Time, + ctx context.Context, orgUID string, since, until time.Time, ) ([]*models.Incident, bool, error) { + pageSize, maxLoaded := s.insightsPageSize, s.insightsMaxLoaded + if pageSize <= 0 { + pageSize = defaultInsightsPageSize + } + + if maxLoaded <= 0 { + maxLoaded = defaultInsightsMaxLoaded + } + var all []*models.Incident var ( @@ -147,18 +157,19 @@ func (s *Service) loadInsightsIncidents( page, _, err := s.db.ListIncidents(ctx, &models.ListIncidentsFilter{ OrganizationUID: orgUID, Kinds: []string{models.IncidentKindCheck}, - Since: &since, + ActiveSince: &since, + Until: &until, CursorTimestamp: cursorTS, CursorUID: cursorUID, - Limit: insightsPageSize + 1, + Limit: pageSize + 1, }) if err != nil { return nil, false, fmt.Errorf("failed to list incidents: %w", err) } - hasMore := len(page) > insightsPageSize + hasMore := len(page) > pageSize if hasMore { - page = page[:insightsPageSize] + page = page[:pageSize] } all = append(all, page...) @@ -167,8 +178,8 @@ func (s *Service) loadInsightsIncidents( return all, false, nil } - if len(all) >= insightsMaxLoaded { - return all, true, nil + if len(all) >= maxLoaded { + return all[:maxLoaded], true, nil } last := page[len(page)-1] diff --git a/server/internal/handlers/incidents/service.go b/server/internal/handlers/incidents/service.go index eb415cdab..a3322bfa8 100644 --- a/server/internal/handlers/incidents/service.go +++ b/server/internal/handlers/incidents/service.go @@ -254,6 +254,10 @@ type Service struct { jobsSvc jobsvc.Service clock clock.Clock + // insightsPageSize and insightsMaxLoaded override the insights loader's + // defaults when positive (tests). + insightsPageSize, insightsMaxLoaded int + // publications is the status-page publication hook. Nil-safe: every call // site goes through the publish* helpers below, which no-op when unset // (status pages disabled, tests). diff --git a/server/internal/uptimereport/report.go b/server/internal/uptimereport/report.go index f422c0663..b07549004 100644 --- a/server/internal/uptimereport/report.go +++ b/server/internal/uptimereport/report.go @@ -405,13 +405,19 @@ func (b *Builder) measure( applyOverall(data, overall, checks) - incidents, incidentRows, err := b.incidentBlock(ctx, org.UID, checkUIDs, window, now) + incidents, err := b.incidentBlock(ctx, org.UID, checkUIDs, window, now) if err != nil { return nil, err } applyIncidents(data, incidents) - applyInsights(data, checks, incidentRows, window, now, baseURL, org.Slug) + + insightRows, err := b.insightsIncidents(ctx, org.UID, checkUIDs, window) + if err != nil { + return nil, err + } + + applyInsights(data, checks, insightRows, window, now, baseURL, org.Slug) if trendErr := b.applyPreviousPeriod( ctx, org.UID, schedule, checkUIDs, window, now, hints, data, overall, incidents.Count, @@ -677,7 +683,7 @@ func (b *Builder) applyPreviousPeriod( previousOverall.Add(byCheck[checkUID]) } - previousIncidents, _, err := b.incidentBlock(ctx, orgUID, checkUIDs, previous, now) + previousIncidents, err := b.incidentBlock(ctx, orgUID, checkUIDs, previous, now) if err != nil { return err } @@ -753,25 +759,58 @@ func (b *Builder) scopeChecks( func (b *Builder) incidentBlock( ctx context.Context, orgUID string, checkUIDs []string, window slo.Window, now time.Time, -) (availability.PeriodIncidents, []*models.Incident, error) { +) (availability.PeriodIncidents, error) { + // Started inside the window: the downtime and count figures are about the + // incidents that began in the period. + all, err := b.listCheckIncidents(ctx, orgUID, checkUIDs, &models.ListIncidentsFilter{ + Since: &window.Start, + Until: &window.End, + }) + if err != nil { + return availability.PeriodIncidents{}, fmt.Errorf("list incidents: %w", err) + } + + return availability.IncidentBlock(all, window.Start.UTC(), window.End.UTC(), now.UTC()), nil +} + +// insightsIncidents loads the incidents active during the window (started +// before its end, still open or resolved inside it), the population of the +// incident insights page. incidentBlock counts by start date, so an outage that +// began before the window would be missing here. +func (b *Builder) insightsIncidents( + ctx context.Context, orgUID string, checkUIDs []string, window slo.Window, +) ([]*models.Incident, error) { + all, err := b.listCheckIncidents(ctx, orgUID, checkUIDs, &models.ListIncidentsFilter{ + ActiveSince: &window.Start, + Until: &window.End, + }) + if err != nil { + return nil, fmt.Errorf("list incidents for insights: %w", err) + } + + return all, nil +} + +// listCheckIncidents returns the check incidents of every check (a group +// incident listed by several members once), narrowed by the time fields of base. +func (b *Builder) listCheckIncidents( + ctx context.Context, orgUID string, checkUIDs []string, base *models.ListIncidentsFilter, +) ([]*models.Incident, error) { seen := make(map[string]struct{}) all := make([]*models.Incident, 0) for _, checkUID := range checkUIDs { - filter := &models.ListIncidentsFilter{ - OrganizationUID: orgUID, - MemberCheckUID: checkUID, - // Check incidents only — the digest reports downtime, not budget - // alerts (spec 2026-08-21-08). - Kinds: []string{models.IncidentKindCheck}, - Since: &window.Start, - Until: &window.End, - Limit: incidentFetchLimit, - } - - incidents, _, err := b.db.ListIncidents(ctx, filter) + filter := *base + filter.OrganizationUID = orgUID + filter.MemberCheckUID = checkUID + // Check incidents only: the digest reports downtime, not budget + // alerts (spec 2026-08-21-08). + filter.Kinds = []string{models.IncidentKindCheck} + filter.Limit = incidentFetchLimit + + incidents, _, err := b.db.ListIncidents(ctx, &filter) if err != nil { - return availability.PeriodIncidents{}, nil, fmt.Errorf("list incidents: %w", err) + return nil, err //nolint:wrapcheck // wrapped by the callers } for _, inc := range incidents { @@ -785,9 +824,7 @@ func (b *Builder) incidentBlock( } } - block := availability.IncidentBlock(all, window.Start.UTC(), window.End.UTC(), now.UTC()) - - return block, all, nil + return all, nil } // sloRows evaluates every enabled objective whose scope intersects the report's. diff --git a/web/docs/docs/features/incidents.md b/web/docs/docs/features/incidents.md index fc2f5b37b..89d876a35 100644 --- a/web/docs/docs/features/incidents.md +++ b/web/docs/docs/features/incidents.md @@ -306,9 +306,9 @@ column of the availability table counts outages only. ## Incident insights {#insights} -The **Insights** button in the header of the incidents list opens a page that answers the questions a team asks after a few weeks on call. Pick a window of 7, 30 or 90 days. Only check outages count: SLO burn alerts and degraded incidents are left out. +The **Insights** button in the header of the incidents list opens a page that answers the questions a team asks after a few weeks on call. Pick a window of 7, 30 or 90 days. It covers the incidents active during the period: those that started inside it, and those that started earlier and were still open or resolved inside it. Only check outages count: SLO burn alerts and degraded incidents are left out. -- **Top failing checks**: the 20 checks with the most incidents in the window (ties broken by downtime), with their downtime, relapses and last incident. Downtime is the wall-clock time of the incidents, clipped to the window; an open incident counts up to now. +- **Top failing checks**: the 20 checks with the most incidents active in the window (ties broken by downtime), with their downtime, relapses and last incident. Downtime is the wall-clock time of the incidents, clipped to the window; an open incident counts up to now. - **Time to acknowledge**: the time between the start of the incident's current episode and its acknowledgement. When an incident reopens, the acknowledgement is cleared, so the time is measured from the reopening. It is always shown with "N of M acknowledged", because most incidents recover before anybody acknowledges them and the time alone would flatter you. Incidents rolled up under a root cause (nobody was paged) are not part of it. - **Time to resolve**: the time between the start and the resolution of incidents that were auto-recovered or resolved manually. Closures that say nothing about recovery (expired, escalated, disabled check, deleted check) are counted under "Other" and left out of the median. From 253dc9e58f6ccf2b8becab3acc2a34e721eacc8c Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Fri, 13 Nov 2026 01:45:06 +0100 Subject: [PATCH 13/23] test(incidents): window overlap, pagination, lookup error, group dedupe, real endpoint Co-Authored-By: Claude Sonnet 5.5 --- .../app/incident_insights_route_test.go | 6 + .../handlers/incidents/insights_test.go | 135 +++++++++++++++++ server/internal/uptimereport/builder_test.go | 136 ++++++++++++++++++ web/dash0/e2e/incident-insights.spec.ts | 16 +++ 4 files changed, 293 insertions(+) diff --git a/server/internal/app/incident_insights_route_test.go b/server/internal/app/incident_insights_route_test.go index 5136a9150..bb7e0043d 100644 --- a/server/internal/app/incident_insights_route_test.go +++ b/server/internal/app/incident_insights_route_test.go @@ -23,4 +23,10 @@ func TestIncidentInsightsRoute(t *testing.T) { authed := env.get(t, path+"?period=7d", map[string]string{"Authorization": "Bearer " + env.login(t)}) r.Equal(http.StatusOK, authed.status, string(authed.body)) r.Contains(string(authed.body), `"period":"7d"`) + r.Contains(string(authed.body), `"summary"`) + r.Contains(string(authed.body), `"checks"`) + + bad := env.get(t, path+"?period=1y", map[string]string{"Authorization": "Bearer " + env.login(t)}) + r.Equal(http.StatusBadRequest, bad.status, string(bad.body)) + r.Contains(string(bad.body), `"VALIDATION_ERROR"`) } diff --git a/server/internal/handlers/incidents/insights_test.go b/server/internal/handlers/incidents/insights_test.go index ab3f00e2d..e13ae92f3 100644 --- a/server/internal/handlers/incidents/insights_test.go +++ b/server/internal/handlers/incidents/insights_test.go @@ -1,7 +1,9 @@ package incidents_test import ( + "context" "encoding/json" + "errors" "net/http" "net/http/httptest" "testing" @@ -10,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/fclairamb/solidping/server/internal/config" + "github.com/fclairamb/solidping/server/internal/db" "github.com/fclairamb/solidping/server/internal/db/models" "github.com/fclairamb/solidping/server/internal/db/sqlite" "github.com/fclairamb/solidping/server/internal/handlers/incidents" @@ -140,3 +143,135 @@ func TestGetInsights(t *testing.T) { r.Nil(got.Summary.TimeToAcknowledge.MedianSeconds) }) } + +func newInsightsDB(t *testing.T) *sqlite.Service { + t.Helper() + + dbSvc, err := sqlite.New(t.Context(), sqlite.Config{InMemory: true}) + require.NoError(t, err) + t.Cleanup(func() { _ = dbSvc.Close() }) + require.NoError(t, dbSvc.Initialize(t.Context())) + + return dbSvc +} + +func newInsightsRouterFor(dbSvc db.Service, limits ...int) *httpx.Router { + svc := incidents.NewService(dbSvc, nil, clock.Real{}, nil) + if len(limits) == 2 { + svc.SetInsightsLimitsForTest(limits[0], limits[1]) + } + + router := httpx.New() + router.GET("/orgs/:org/incidents/insights", incidents.NewHandler(svc, &config.Config{}).GetInsights) + + return router +} + +// TestGetInsightsWindowOverlap: incidents active during the period count even +// when they started before it; one resolved before the window start does not. +func TestGetInsightsWindowOverlap(t *testing.T) { + t.Parallel() + + ctx := t.Context() + r := require.New(t) + dbSvc := newInsightsDB(t) + + org := models.NewOrganization("overlap-org", "") + r.NoError(dbSvc.CreateOrganization(ctx, org)) + + openCheck := models.NewCheck(org.UID, "still-open", "http") + r.NoError(dbSvc.CreateCheck(ctx, openCheck)) + oldCheck := models.NewCheck(org.UID, "long-resolved", "http") + r.NoError(dbSvc.CreateCheck(ctx, oldCheck)) + crossCheck := models.NewCheck(org.UID, "resolved-inside", "http") + r.NoError(dbSvc.CreateCheck(ctx, crossCheck)) + + now := time.Now() + started := now.Add(-20 * 24 * time.Hour) // before the 7d window + + r.NoError(dbSvc.CreateIncident(ctx, models.NewIncident(org.UID, openCheck.UID, started, "x"))) + + oldResolvedAt := now.Add(-10 * 24 * time.Hour) + old := models.NewIncident(org.UID, oldCheck.UID, started, "x") + old.ResolvedAt = &oldResolvedAt + r.NoError(dbSvc.CreateIncident(ctx, old)) + + crossResolvedAt := now.Add(-3 * 24 * time.Hour) + cross := models.NewIncident(org.UID, crossCheck.UID, started, "x") + cross.ResolvedAt = &crossResolvedAt + r.NoError(dbSvc.CreateIncident(ctx, cross)) + + rec, got := fetchInsights(t, newInsightsRouterFor(dbSvc), org.Slug, "?period=7d") + r.Equal(http.StatusOK, rec.Code, rec.Body.String()) + r.Equal(2, got.Summary.IncidentCount, "open and resolved-inside count, resolved-before does not") + + slugs := map[string]bool{} + for _, row := range got.Checks { + slugs[row.CheckSlug] = true + } + + r.True(slugs["still-open"], "positive control: an open outage from before the window") + r.True(slugs["resolved-inside"]) + r.False(slugs["long-resolved"], "resolved before the window start") +} + +// TestGetInsightsPaginationAndTruncation uses a tiny page size and cap. +func TestGetInsightsPaginationAndTruncation(t *testing.T) { + t.Parallel() + + ctx := t.Context() + r := require.New(t) + dbSvc := newInsightsDB(t) + + org := models.NewOrganization("paging-org", "") + r.NoError(dbSvc.CreateOrganization(ctx, org)) + check := models.NewCheck(org.UID, "paged", "http") + r.NoError(dbSvc.CreateCheck(ctx, check)) + + for idx := range 7 { + inc := models.NewIncident(org.UID, check.UID, time.Now().Add(-time.Duration(idx+1)*time.Hour), "x") + r.NoError(dbSvc.CreateIncident(ctx, inc)) + } + + // 7 incidents over pages of 3: every page is followed, nothing truncated. + rec, got := fetchInsights(t, newInsightsRouterFor(dbSvc, 3, 100), org.Slug, "?period=7d") + r.Equal(http.StatusOK, rec.Code, rec.Body.String()) + r.Equal(7, got.Summary.IncidentCount) + r.False(got.Truncated) + + // A cap of 5 over pages of 3: truncated at exactly 5. + rec, got = fetchInsights(t, newInsightsRouterFor(dbSvc, 3, 5), org.Slug, "?period=7d") + r.Equal(http.StatusOK, rec.Code, rec.Body.String()) + r.Equal(5, got.Summary.IncidentCount) + r.True(got.Truncated) +} + +var errChecksLookup = errors.New("checks lookup failed") + +type failingChecksDB struct { + db.Service +} + +func (failingChecksDB) GetChecksByUIDs(context.Context, string, []string) (map[string]*models.Check, error) { + return nil, errChecksLookup +} + +// TestGetInsightsChecksLookupError: a failed check lookup is a 500, not a +// silently nameless table. +func TestGetInsightsChecksLookupError(t *testing.T) { + t.Parallel() + + ctx := t.Context() + r := require.New(t) + dbSvc := newInsightsDB(t) + + org := models.NewOrganization("lookup-err-org", "") + r.NoError(dbSvc.CreateOrganization(ctx, org)) + check := models.NewCheck(org.UID, "chk-lookup", "http") + r.NoError(dbSvc.CreateCheck(ctx, check)) + r.NoError(dbSvc.CreateIncident(ctx, models.NewIncident(org.UID, check.UID, time.Now().Add(-time.Hour), "x"))) + + rec, _ := fetchInsights(t, newInsightsRouterFor(failingChecksDB{dbSvc}), org.Slug, "?period=7d") + r.Equal(http.StatusInternalServerError, rec.Code, rec.Body.String()) + r.Contains(rec.Body.String(), "INTERNAL_ERROR") +} diff --git a/server/internal/uptimereport/builder_test.go b/server/internal/uptimereport/builder_test.go index b566fdd2b..cc4089265 100644 --- a/server/internal/uptimereport/builder_test.go +++ b/server/internal/uptimereport/builder_test.go @@ -2,6 +2,7 @@ package uptimereport import ( "context" + "errors" "fmt" "testing" "time" @@ -540,3 +541,138 @@ func TestBuilderOmitsTopFailingForASingleCheckScope(t *testing.T) { r.True(data.ShowInsights) r.Empty(data.TopFailingChecks) } + +// TestBuilderInsightsCountOutagesFromBeforeThePeriod: an outage that began +// before the window and is still open is in the insights (positive control), +// one resolved before the window start is not, and neither moves the +// incident-count figure, which counts by start date. +func TestBuilderInsightsCountOutagesFromBeforeThePeriod(t *testing.T) { + t.Parallel() + + r := require.New(t) + + env := newBuilderEnv(t) + now := time.Now().UTC() + window := currentMonth(now) + day := 24 * time.Hour + + stuck := env.createCheck(t, "stuck") + old := env.createCheck(t, "old") + + for _, check := range []*models.Check{stuck, old} { + env.addRaw(t, check, now.Add(-time.Hour), 90, 100, 150) + } + + env.addIncident(t, stuck, window.Start.Add(-10*day), 0, 0, 3) // still open + env.addIncident(t, old, window.Start.Add(-20*day), 2*day, 0, 0) + + schedule := monthlySchedule(env.org.UID) + schedule.CheckUIDs = []string{stuck.UID, old.UID} + + data, err := env.builder.Build(env.ctx, env.org, schedule, window, now) + r.NoError(err) + + r.True(data.ShowInsights) + r.Len(data.TopFailingChecks, 1) + r.Equal("stuck", data.TopFailingChecks[0].Name) + r.Equal(3, data.TopFailingChecks[0].RelapseCount) + r.Zero(data.IncidentCount, "the incident count is by start date and is unchanged") +} + +// TestBuilderCountsAGroupIncidentOnceAcrossMembers: a group incident is listed +// for each in-scope member and counts once. +func TestBuilderCountsAGroupIncidentOnceAcrossMembers(t *testing.T) { + t.Parallel() + + r := require.New(t) + + env := newBuilderEnv(t) + now := time.Now().UTC() + window := currentMonth(now) + + first := env.createCheck(t, "grp-a") + second := env.createCheck(t, "grp-b") + + for _, check := range []*models.Check{first, second} { + env.addRaw(t, check, now.Add(-time.Hour), 90, 100, 150) + } + + group := models.NewCheckGroup(env.org.UID, "Group", "grp") + r.NoError(env.db.CreateCheckGroup(env.ctx, group)) + + startedAt := window.Start.Add(24 * time.Hour) + incident := models.NewIncident(env.org.UID, first.UID, startedAt, "group down") + incident.CheckGroupUID = &group.UID + r.NoError(env.db.CreateIncident(env.ctx, incident)) + + for _, check := range []*models.Check{first, second} { + r.NoError(env.db.UpsertIncidentMemberCheck(env.ctx, &models.IncidentMemberCheck{ + IncidentUID: incident.UID, CheckUID: check.UID, + JoinedAt: startedAt, FirstFailureAt: startedAt, LastFailureAt: startedAt, FailureCount: 1, + })) + } + + // Positive control: each member's own load returns the incident. + for _, check := range []*models.Check{first, second} { + listed, _, err := env.db.ListIncidents(env.ctx, &models.ListIncidentsFilter{ + OrganizationUID: env.org.UID, MemberCheckUID: check.UID, + Kinds: []string{models.IncidentKindCheck}, ActiveSince: &window.Start, Until: &window.End, Limit: 10, + }) + r.NoError(err) + r.Len(listed, 1) + } + + schedule := monthlySchedule(env.org.UID) + schedule.CheckUIDs = []string{first.UID, second.UID} + + data, err := env.builder.Build(env.ctx, env.org, schedule, window, now) + r.NoError(err) + + r.True(data.ShowInsights) + r.Equal(1, data.PagedCount) + + total := 0 + for _, row := range data.TopFailingChecks { + total += row.IncidentCount + } + + r.Equal(1, total) +} + +var errListIncidents = errors.New("list incidents failed") + +// failingInsightsDB fails only the insights load (the one filtering by +// ActiveSince), so the incident block still succeeds. +type failingInsightsDB struct { + db.Service +} + +func (f failingInsightsDB) ListIncidents( + ctx context.Context, filter *models.ListIncidentsFilter, +) ([]*models.Incident, int64, error) { + if filter.ActiveSince != nil { + return nil, 0, errListIncidents + } + + return f.Service.ListIncidents(ctx, filter) +} + +// TestBuilderFailsWhenTheInsightsIncidentLoadFails: the report errors with a +// wrapped cause instead of going out without its insights. +func TestBuilderFailsWhenTheInsightsIncidentLoadFails(t *testing.T) { + t.Parallel() + + r := require.New(t) + + env := newBuilderEnv(t) + now := time.Now().UTC() + + check := env.createCheck(t, "api") + env.addRaw(t, check, now.Add(-time.Hour), 90, 100, 150) + + builder := NewBuilder(failingInsightsDB{env.db}, &config.Config{}, nil) + + _, err := builder.Build(env.ctx, env.org, monthlySchedule(env.org.UID), currentMonth(now), now) + r.ErrorIs(err, errListIncidents) + r.Contains(err.Error(), "list incidents for insights") +} diff --git a/web/dash0/e2e/incident-insights.spec.ts b/web/dash0/e2e/incident-insights.spec.ts index 27a0ad49e..6c25087d2 100644 --- a/web/dash0/e2e/incident-insights.spec.ts +++ b/web/dash0/e2e/incident-insights.spec.ts @@ -117,4 +117,20 @@ test.describe("Incident insights", () => { ); expect(overflow).toBe(false); }); + + // No page.route: the page talks to the real insights endpoint. The test-mode + // seed (server/test/testdata) has an active check incident on "Captured + // Check" that stays open for the whole run. + test("against the real endpoint, a seeded incident shows its check and count", async ({ + authenticatedPage, + }) => { + const page = authenticatedPage; + + await page.goto(`${DASH_BASE}/orgs/test/incidents/insights?period=30d`); + + const row = page.getByTestId("insights-check-row").filter({ hasText: "Captured Check" }); + await expect(row).toHaveCount(1); + await expect(row.getByTestId("insights-incident-count")).toHaveText(/^[1-9]\d*$/); + await expect(page.getByTestId("insights-empty")).toHaveCount(0); + }); }); From c469878653827b678d32cfba6d3913fe803598ed Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Fri, 13 Nov 2026 01:47:23 +0100 Subject: [PATCH 14/23] fix(incidents): keep the report measure function under the lint length Co-Authored-By: Claude Sonnet 5.5 --- server/internal/uptimereport/report.go | 23 ++++++++++++++++++----- 1 file changed, 18 insertions(+), 5 deletions(-) diff --git a/server/internal/uptimereport/report.go b/server/internal/uptimereport/report.go index b07549004..691e86845 100644 --- a/server/internal/uptimereport/report.go +++ b/server/internal/uptimereport/report.go @@ -412,13 +412,10 @@ func (b *Builder) measure( applyIncidents(data, incidents) - insightRows, err := b.insightsIncidents(ctx, org.UID, checkUIDs, window) - if err != nil { - return nil, err + if insightsErr := b.loadInsights(ctx, data, org, checks, checkUIDs, window, now, baseURL); insightsErr != nil { + return nil, insightsErr } - applyInsights(data, checks, insightRows, window, now, baseURL, org.Slug) - if trendErr := b.applyPreviousPeriod( ctx, org.UID, schedule, checkUIDs, window, now, hints, data, overall, incidents.Count, ); trendErr != nil { @@ -773,6 +770,22 @@ func (b *Builder) incidentBlock( return availability.IncidentBlock(all, window.Start.UTC(), window.End.UTC(), now.UTC()), nil } +// loadInsights fills the incident insights; a failed load fails the report +// rather than sending it without them. +func (b *Builder) loadInsights( + ctx context.Context, data *Data, org *models.Organization, checks []*models.Check, + checkUIDs []string, window slo.Window, now time.Time, baseURL string, +) error { + incidents, err := b.insightsIncidents(ctx, org.UID, checkUIDs, window) + if err != nil { + return err + } + + applyInsights(data, checks, incidents, window, now, baseURL, org.Slug) + + return nil +} + // insightsIncidents loads the incidents active during the window (started // before its end, still open or resolved inside it), the population of the // incident insights page. incidentBlock counts by start date, so an outage that From 004e763d01fb906f0639d218203187d51ddd668e Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Fri, 13 Nov 2026 01:48:38 +0100 Subject: [PATCH 15/23] test(dash0): locate the seeded insights row through its check link Co-Authored-By: Claude Sonnet 5.5 --- web/dash0/e2e/incident-insights.spec.ts | 40 ++++++++++++++++++------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/web/dash0/e2e/incident-insights.spec.ts b/web/dash0/e2e/incident-insights.spec.ts index 6c25087d2..1d71eef7f 100644 --- a/web/dash0/e2e/incident-insights.spec.ts +++ b/web/dash0/e2e/incident-insights.spec.ts @@ -30,7 +30,10 @@ function insightsBody(period: string, incidentCount: number) { ? { medianSeconds: 600, p90Seconds: 900, sampleCount: incidentCount } : emptyDuration, byResolution: { - auto: incidentCount > 0 ? { count: incidentCount, medianSeconds: 600, p90Seconds: 900 } : emptyResolution, + auto: + incidentCount > 0 + ? { count: incidentCount, medianSeconds: 600, p90Seconds: 900 } + : emptyResolution, manual: emptyResolution, other: emptyResolution, }, @@ -54,7 +57,8 @@ function insightsBody(period: string, incidentCount: number) { async function mockInsights(page: Page, countFor: (period: string) => number) { await page.route("**/api/v1/orgs/*/incidents/insights*", async (route) => { - const period = new URL(route.request().url()).searchParams.get("period") ?? "30d"; + const period = + new URL(route.request().url()).searchParams.get("period") ?? "30d"; await route.fulfill({ json: insightsBody(period, countFor(period)) }); }); } @@ -74,28 +78,36 @@ test.describe("Incident insights", () => { const row = page.getByTestId("insights-check-row").first(); await expect(row).toBeVisible({ timeout: 15000 }); - const count = Number(await row.getByTestId("insights-incident-count").innerText()); + const count = Number( + await row.getByTestId("insights-incident-count").innerText(), + ); expect(count).toBeGreaterThanOrEqual(1); await expect(page.getByTestId("insights-card-ack")).toBeVisible(); await expect(page.getByTestId("insights-card-resolve")).toBeVisible(); await expect(page.getByTestId("insights-card-resolution")).toBeVisible(); }); - test("switching the period updates the URL and the numbers", async ({ authenticatedPage }) => { + test("switching the period updates the URL and the numbers", async ({ + authenticatedPage, + }) => { const page = authenticatedPage; await mockInsights(page, (period) => (period === "7d" ? 2 : 5)); await page.goto(`${DASH_BASE}/orgs/test/incidents/insights`); await expect(page.getByTestId("insights-incident-count")).toHaveText("5"); // No acknowledged incident: a dash and the reason, never "0s". - await expect(page.getByTestId("insights-card-ack")).toContainText("No acknowledged incident"); + await expect(page.getByTestId("insights-card-ack")).toContainText( + "No acknowledged incident", + ); await page.getByTestId("insights-period-7d").click(); await expect(page).toHaveURL(/period=7d/); await expect(page.getByTestId("insights-incident-count")).toHaveText("2"); }); - test("an org with no incident shows the empty state", async ({ authenticatedPage }) => { + test("an org with no incident shows the empty state", async ({ + authenticatedPage, + }) => { const page = authenticatedPage; await mockInsights(page, () => 0); @@ -104,7 +116,9 @@ test.describe("Incident insights", () => { await expect(page.getByTestId("insights-check-row")).toHaveCount(0); }); - test("a phone viewport has no horizontal page scroll", async ({ authenticatedPage }) => { + test("a phone viewport has no horizontal page scroll", async ({ + authenticatedPage, + }) => { const page = authenticatedPage; await page.setViewportSize({ width: 375, height: 812 }); await mockInsights(page, () => 5); @@ -113,7 +127,9 @@ test.describe("Incident insights", () => { await expect(page.getByTestId("insights-check-row")).toBeVisible(); const overflow = await page.evaluate( - () => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + () => + document.documentElement.scrollWidth > + document.documentElement.clientWidth + 1, ); expect(overflow).toBe(false); }); @@ -128,9 +144,13 @@ test.describe("Incident insights", () => { await page.goto(`${DASH_BASE}/orgs/test/incidents/insights?period=30d`); - const row = page.getByTestId("insights-check-row").filter({ hasText: "Captured Check" }); + const row = page.getByTestId("insights-check-row").filter({ + has: page.getByRole("link", { name: "Captured Check" }), + }); await expect(row).toHaveCount(1); - await expect(row.getByTestId("insights-incident-count")).toHaveText(/^[1-9]\d*$/); + await expect(row.getByTestId("insights-incident-count")).toHaveText( + /^[1-9]\d*$/, + ); await expect(page.getByTestId("insights-empty")).toHaveCount(0); }); }); From f6b3b6ccc506cf77f9d6d5db314ec5df203227d7 Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Fri, 13 Nov 2026 01:48:46 +0100 Subject: [PATCH 16/23] test(dash0): keep the insights spec formatting, only the row locator changes Co-Authored-By: Claude Sonnet 5.5 --- web/dash0/e2e/incident-insights.spec.ts | 36 +++++++------------------ 1 file changed, 9 insertions(+), 27 deletions(-) diff --git a/web/dash0/e2e/incident-insights.spec.ts b/web/dash0/e2e/incident-insights.spec.ts index 1d71eef7f..ae384e25b 100644 --- a/web/dash0/e2e/incident-insights.spec.ts +++ b/web/dash0/e2e/incident-insights.spec.ts @@ -30,10 +30,7 @@ function insightsBody(period: string, incidentCount: number) { ? { medianSeconds: 600, p90Seconds: 900, sampleCount: incidentCount } : emptyDuration, byResolution: { - auto: - incidentCount > 0 - ? { count: incidentCount, medianSeconds: 600, p90Seconds: 900 } - : emptyResolution, + auto: incidentCount > 0 ? { count: incidentCount, medianSeconds: 600, p90Seconds: 900 } : emptyResolution, manual: emptyResolution, other: emptyResolution, }, @@ -57,8 +54,7 @@ function insightsBody(period: string, incidentCount: number) { async function mockInsights(page: Page, countFor: (period: string) => number) { await page.route("**/api/v1/orgs/*/incidents/insights*", async (route) => { - const period = - new URL(route.request().url()).searchParams.get("period") ?? "30d"; + const period = new URL(route.request().url()).searchParams.get("period") ?? "30d"; await route.fulfill({ json: insightsBody(period, countFor(period)) }); }); } @@ -78,36 +74,28 @@ test.describe("Incident insights", () => { const row = page.getByTestId("insights-check-row").first(); await expect(row).toBeVisible({ timeout: 15000 }); - const count = Number( - await row.getByTestId("insights-incident-count").innerText(), - ); + const count = Number(await row.getByTestId("insights-incident-count").innerText()); expect(count).toBeGreaterThanOrEqual(1); await expect(page.getByTestId("insights-card-ack")).toBeVisible(); await expect(page.getByTestId("insights-card-resolve")).toBeVisible(); await expect(page.getByTestId("insights-card-resolution")).toBeVisible(); }); - test("switching the period updates the URL and the numbers", async ({ - authenticatedPage, - }) => { + test("switching the period updates the URL and the numbers", async ({ authenticatedPage }) => { const page = authenticatedPage; await mockInsights(page, (period) => (period === "7d" ? 2 : 5)); await page.goto(`${DASH_BASE}/orgs/test/incidents/insights`); await expect(page.getByTestId("insights-incident-count")).toHaveText("5"); // No acknowledged incident: a dash and the reason, never "0s". - await expect(page.getByTestId("insights-card-ack")).toContainText( - "No acknowledged incident", - ); + await expect(page.getByTestId("insights-card-ack")).toContainText("No acknowledged incident"); await page.getByTestId("insights-period-7d").click(); await expect(page).toHaveURL(/period=7d/); await expect(page.getByTestId("insights-incident-count")).toHaveText("2"); }); - test("an org with no incident shows the empty state", async ({ - authenticatedPage, - }) => { + test("an org with no incident shows the empty state", async ({ authenticatedPage }) => { const page = authenticatedPage; await mockInsights(page, () => 0); @@ -116,9 +104,7 @@ test.describe("Incident insights", () => { await expect(page.getByTestId("insights-check-row")).toHaveCount(0); }); - test("a phone viewport has no horizontal page scroll", async ({ - authenticatedPage, - }) => { + test("a phone viewport has no horizontal page scroll", async ({ authenticatedPage }) => { const page = authenticatedPage; await page.setViewportSize({ width: 375, height: 812 }); await mockInsights(page, () => 5); @@ -127,9 +113,7 @@ test.describe("Incident insights", () => { await expect(page.getByTestId("insights-check-row")).toBeVisible(); const overflow = await page.evaluate( - () => - document.documentElement.scrollWidth > - document.documentElement.clientWidth + 1, + () => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, ); expect(overflow).toBe(false); }); @@ -148,9 +132,7 @@ test.describe("Incident insights", () => { has: page.getByRole("link", { name: "Captured Check" }), }); await expect(row).toHaveCount(1); - await expect(row.getByTestId("insights-incident-count")).toHaveText( - /^[1-9]\d*$/, - ); + await expect(row.getByTestId("insights-incident-count")).toHaveText(/^[1-9]\d*$/); await expect(page.getByTestId("insights-empty")).toHaveCount(0); }); }); From 3fb6bba48a7a4af8fcaf4664b051649b47af25a8 Mon Sep 17 00:00:00 2001 From: fclairamb Date: Fri, 13 Nov 2026 02:07:15 +0100 Subject: [PATCH 17/23] fix(ci): set Kinds on the uptime report incident filters --- server/internal/uptimereport/report.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/internal/uptimereport/report.go b/server/internal/uptimereport/report.go index 691e86845..830893be0 100644 --- a/server/internal/uptimereport/report.go +++ b/server/internal/uptimereport/report.go @@ -762,6 +762,7 @@ func (b *Builder) incidentBlock( all, err := b.listCheckIncidents(ctx, orgUID, checkUIDs, &models.ListIncidentsFilter{ Since: &window.Start, Until: &window.End, + Kinds: []string{models.IncidentKindCheck}, }) if err != nil { return availability.PeriodIncidents{}, fmt.Errorf("list incidents: %w", err) @@ -796,6 +797,7 @@ func (b *Builder) insightsIncidents( all, err := b.listCheckIncidents(ctx, orgUID, checkUIDs, &models.ListIncidentsFilter{ ActiveSince: &window.Start, Until: &window.End, + Kinds: []string{models.IncidentKindCheck}, }) if err != nil { return nil, fmt.Errorf("list incidents for insights: %w", err) From 9bc345c2cc868a3f8182a872e4cb9b833fa826f0 Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Fri, 13 Nov 2026 08:39:55 +0100 Subject: [PATCH 18/23] fix(dash): save email check authentication options on edit (#864) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #862: merge it first. Editing an email check dropped its config from the save request, so the sender authentication options (SPF, DKIM, DMARC, blocklists) were never saved. Email checks now send their config; heartbeat and private-location are unchanged. Browser e2e test added. Closes fclairamb/solidping-business#73 ## Decided by autopilot None. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- web/dash0/e2e/email-check-sender-auth.spec.ts | 46 +++++++++++++++++++ .../src/components/shared/check-form.tsx | 10 +++- 2 files changed, 54 insertions(+), 2 deletions(-) diff --git a/web/dash0/e2e/email-check-sender-auth.spec.ts b/web/dash0/e2e/email-check-sender-auth.spec.ts index 0bd338af1..9d4464ab3 100644 --- a/web/dash0/e2e/email-check-sender-auth.spec.ts +++ b/web/dash0/e2e/email-check-sender-auth.spec.ts @@ -75,4 +75,50 @@ test.describe("Email check sender authentication", () => { "zen.spamhaus.org\nbl.spamcop.net", ); }); + + // The edit form used to drop `config` from every passive check's PATCH, so + // the options changed on an existing email check were never saved. + test("saves the authentication options changed on edit, keeping the token", async ({ authenticatedPage }) => { + const page = authenticatedPage; + const authToken = await getAuthToken(page); + await ensureEmailInboxConfigured(page, authToken); + + const stamp = Date.now(); + const created = await page.request.post(`${API_BASE}/api/v1/orgs/test/checks`, { + headers: { Authorization: `Bearer ${authToken}` }, + data: { + type: "email", + name: `E2E Email Auth Edit ${stamp}`, + slug: `e2e-email-auth-edit-${stamp}`, + config: {}, + }, + }); + expect(created.status(), await created.text()).toBe(201); + const { uid, config: before } = await created.json(); + expect(typeof before.token).toBe("string"); + + await page.goto(`orgs/test/checks/${uid}/edit`); + await page.getByTestId("email-auth-section").click(); + await page.getByTestId("email-require-spf-select").click(); + await page.getByRole("option", { name: "Pass", exact: true }).click(); + await page.getByTestId("email-require-dkim-switch").click(); + await page.getByTestId("email-check-blocklists-switch").click(); + await page.getByTestId("email-blocklists-input").fill("zen.spamhaus.org"); + + const saved = page.waitForResponse( + (r) => r.url().includes(`/checks/${uid}`) && r.request().method() === "PATCH", + ); + await page.getByTestId("check-submit-button").click(); + expect((await saved).ok()).toBe(true); + + const resp = await page.request.get(`${API_BASE}/api/v1/orgs/test/checks/${uid}`, { + headers: { Authorization: `Bearer ${authToken}` }, + }); + const config = (await resp.json()).config; + expect(config.require_spf).toBe("pass"); + expect(config.require_dkim).toBe(true); + expect(config.check_blocklists).toBe(true); + expect(config.blocklists).toEqual(["zen.spamhaus.org"]); + expect(config.token).toBe(before.token); + }); }); diff --git a/web/dash0/src/components/shared/check-form.tsx b/web/dash0/src/components/shared/check-form.tsx index 9101a7df6..0e92dd7b7 100644 --- a/web/dash0/src/components/shared/check-form.tsx +++ b/web/dash0/src/components/shared/check-form.tsx @@ -1480,8 +1480,14 @@ export function CheckForm({ escalationPolicyUid: escalationPolicyUid || (mode === "edit" ? "" : undefined), period: isPassiveCheckType(type) ? formatPeriod(periodValue, periodUnit) : period, - // Don't send config for passive edits — the token is managed by the backend - ...(isPassiveCheckType(type) && mode === "edit" ? {} : { config: submittedConfig }), + // Heartbeat and private-location edits have nothing editable in their + // config (the token / region are server-managed), so it is not sent. + // An email check's sender-authentication options ARE editable: its + // config goes out, the stored token riding along in the passthrough + // (and the server preserves an absent one anyway). + ...(isPassiveCheckType(type) && type !== "email" && mode === "edit" + ? {} + : { config: submittedConfig }), // Automatic placement sends the count and lets the server choose the // regions; pinned sends the explicit list (spec 2026-09-25-06). When // the picker is hidden (fewer than two eligible regions), a NEW check From e2a552755adf064488dfee3f21eafe84d415dcd8 Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Fri, 13 Nov 2026 08:57:41 +0100 Subject: [PATCH 19/23] fix: sftp handshake timeout, browser page detached logs, html lang follows i18n (#866) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #862: merge it first. Closes fclairamb/solidping-business#74 Closes fclairamb/solidping-business#75 - sftp checker: close connection when context expires; context errors map to timeout - browser checker: detached page logged as INFO with clear diagnostics - dashboard: `` follows displayed language (fixes browser auto-translate crash) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../internal/checkers/checkbrowser/checker.go | 38 +++++++++- .../checkbrowser/page_detached_test.go | 54 ++++++++++++++ server/internal/checkers/checksftp/checker.go | 61 ++++++++++++---- .../checkers/checksftp/context_test.go | 72 +++++++++++++++++++ web/dash0/src/i18n-html-lang.test.ts | 34 +++++++++ web/dash0/src/i18n.ts | 10 +++ 6 files changed, 255 insertions(+), 14 deletions(-) create mode 100644 server/internal/checkers/checkbrowser/page_detached_test.go create mode 100644 server/internal/checkers/checksftp/context_test.go create mode 100644 web/dash0/src/i18n-html-lang.test.ts diff --git a/server/internal/checkers/checkbrowser/checker.go b/server/internal/checkers/checkbrowser/checker.go index 361438f8b..11c31d485 100644 --- a/server/internal/checkers/checkbrowser/checker.go +++ b/server/internal/checkers/checkbrowser/checker.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/chromedp/cdproto" "github.com/chromedp/chromedp" checkconfig "github.com/fclairamb/solidping/server/internal/checkers/checkbrowser/config" @@ -329,8 +330,19 @@ func (c *BrowserChecker) captureScreenshot( shot, err := capture(shotCtx) if err != nil { - slog.WarnContext(ctx, "browser check: screenshot capture failed", - "url", cfg.URL, "error", err, "budget_remaining_ms", budgetRemaining.Milliseconds()) + // A page that closed or crashed before the shot is the monitored + // page's doing, not a CDP/infra failure: logged at INFO so it does not + // read as a worker fault in the pod logs. + detached := pageDetached(err) + + level := slog.LevelWarn + if detached { + level = slog.LevelInfo + } + + slog.Log(ctx, level, "browser check: screenshot capture failed", + "url", cfg.URL, "error", err, "budget_remaining_ms", budgetRemaining.Milliseconds(), + "page_detached", detached) setScreenshotError(result, CaptureErrorMessage(err, budgetRemaining)) return @@ -387,9 +399,31 @@ func CaptureErrorMessage(err error, budget time.Duration) string { return "the capture timed out" } + if pageDetached(err) { + return "the page was closed or crashed before the capture" + } + return err.Error() } +// cdpServerError is the JSON-RPC "server error" code Chrome answers with when +// a command targets a session whose page is gone. +const cdpServerError = -32000 + +// pageDetached reports whether a CDP command failed because its page was no +// longer attached: the tab closed itself, crashed, or the target went away +// before the command ran. Chrome reports it as -32000 "Not attached to an +// active page" (or "Session with given id not found"). +func pageDetached(err error) bool { + var cdpErr *cdproto.Error + if !errors.As(err, &cdpErr) || cdpErr.Code != cdpServerError { + return false + } + + return strings.Contains(cdpErr.Message, "Not attached to an active page") || + strings.Contains(cdpErr.Message, "Session with given id not found") +} + // OverCapMessage is the ScreenshotError of a capture dropped over // MaxScreenshotBytes. Shared with the JS runtime. func OverCapMessage(size int) string { diff --git a/server/internal/checkers/checkbrowser/page_detached_test.go b/server/internal/checkers/checkbrowser/page_detached_test.go new file mode 100644 index 000000000..8a9a85631 --- /dev/null +++ b/server/internal/checkers/checkbrowser/page_detached_test.go @@ -0,0 +1,54 @@ +package checkbrowser + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/chromedp/cdproto" + "github.com/stretchr/testify/require" +) + +// errPlainDetachedText carries the CDP wording without being a CDP error. +var errPlainDetachedText = errors.New("plain: Not attached to an active page") + +// TestCaptureErrorMessageNamesADetachedPage: a capture whose page closed or +// crashed first fails with CDP -32000 "Not attached to an active page". That +// is the monitored page's doing, so the operator reads a plain sentence rather +// than the raw protocol error, and the log is not raised as a worker fault. +func TestCaptureErrorMessageNamesADetachedPage(t *testing.T) { + t.Parallel() + + detached := &cdproto.Error{Code: cdpServerError, Message: "Not attached to an active page"} + gone := &cdproto.Error{Code: cdpServerError, Message: "Session with given id not found"} + other := &cdproto.Error{Code: cdpServerError, Message: "Internal error"} + + cases := []struct { + name string + err error + detached bool + message string + }{ + {"detached", detached, true, "the page was closed or crashed before the capture"}, + {"wrapped", fmt.Errorf("capture: %w", detached), true, "the page was closed or crashed before the capture"}, + {"session gone", gone, true, "the page was closed or crashed before the capture"}, + {"other cdp error", other, false, other.Error()}, + {"plain error", errRendererGone, false, errRendererGone.Error()}, + {"deadline", context.DeadlineExceeded, false, "the capture timed out"}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + r := require.New(t) + + r.Equal(tc.detached, pageDetached(tc.err)) + r.Equal(tc.message, CaptureErrorMessage(tc.err, 0)) + }) + } + + r := require.New(t) + r.False(pageDetached(nil)) + r.False(pageDetached(errPlainDetachedText)) +} diff --git a/server/internal/checkers/checksftp/checker.go b/server/internal/checkers/checksftp/checker.go index a3502623c..442c625eb 100644 --- a/server/internal/checkers/checksftp/checker.go +++ b/server/internal/checkers/checksftp/checker.go @@ -125,6 +125,13 @@ func (c *SFTPChecker) Execute(ctx context.Context, config checkerdef.Config) (*c }, nil } + // ssh.ClientConfig.Timeout only bounds a dial ssh does itself, and the + // handshake, the SFTP session and Stat read this conn with no deadline: a + // server that stalls after accepting would outlive the worker's deadline. + // Closing the conn when the context ends unblocks every one of them. + stopWatch := context.AfterFunc(ctx, func() { _ = netConn.Close() }) + defer stopWatch() + sshConn, chans, reqs, err := ssh.NewClientConn(netConn, target, sshConfig) if err != nil { _ = netConn.Close() @@ -164,9 +171,33 @@ func (c *SFTPChecker) Execute(ctx context.Context, config checkerdef.Config) (*c connectTime := time.Since(connectStart) metrics["connection_time_ms"] = float64(connectTime.Microseconds()) / microsecondsPerMs + if failed := probeSFTP(ctx, sshClient, cfg.Path, start, metrics, output); failed != nil { + return failed, nil + } + + metrics["total_time_ms"] = float64(time.Since(start).Microseconds()) / microsecondsPerMs + + return &checkerdef.Result{ + Status: checkerdef.StatusUp, + Duration: time.Since(start), + Metrics: metrics, + Output: output, + }, nil +} + +// probeSFTP opens the SFTP session and, when path is set, stats it. It returns +// the failing result, or nil when both succeeded. +func probeSFTP( + ctx context.Context, sshClient *ssh.Client, path string, + start time.Time, metrics, output map[string]any, +) *checkerdef.Result { // Create SFTP client sftpClient, err := sftp.NewClient(sshClient) if err != nil { + if ctx.Err() != nil { + return timeoutResult(start, metrics, output, "SFTP session timeout") + } + return &checkerdef.Result{ Status: checkerdef.StatusDown, Duration: time.Since(start), @@ -174,15 +205,19 @@ func (c *SFTPChecker) Execute(ctx context.Context, config checkerdef.Config) (*c Output: mergeOutput(output, map[string]any{ checkerdef.OutputKeyError: fmt.Sprintf("SFTP session failed: %v", err), }), - }, nil + } } defer func() { _ = sftpClient.Close() }() // Verify path if specified - if cfg.Path != "" { - _, err = sftpClient.Stat(cfg.Path) + if path != "" { + _, err = sftpClient.Stat(path) if err != nil { + if ctx.Err() != nil { + return timeoutResult(start, metrics, output, "path verification timeout") + } + return &checkerdef.Result{ Status: checkerdef.StatusDown, Duration: time.Since(start), @@ -190,18 +225,11 @@ func (c *SFTPChecker) Execute(ctx context.Context, config checkerdef.Config) (*c Output: mergeOutput(output, map[string]any{ checkerdef.OutputKeyError: fmt.Sprintf("path verification failed: %v", err), }), - }, nil + } } } - metrics["total_time_ms"] = float64(time.Since(start).Microseconds()) / microsecondsPerMs - - return &checkerdef.Result{ - Status: checkerdef.StatusUp, - Duration: time.Since(start), - Metrics: metrics, - Output: output, - }, nil + return nil } // hostKeyCallback builds the SSH host key callback for an SFTP check. @@ -225,6 +253,15 @@ func hostKeyCallback(pin string, output map[string]any, observed *string) ssh.Ho } } +func timeoutResult(start time.Time, metrics, output map[string]any, msg string) *checkerdef.Result { + return &checkerdef.Result{ + Status: checkerdef.StatusTimeout, + Duration: time.Since(start), + Metrics: metrics, + Output: mergeOutput(output, map[string]any{checkerdef.OutputKeyError: msg}), + } +} + func mergeOutput(base, extra map[string]any) map[string]any { result := make(map[string]any, len(base)+len(extra)) for k, v := range base { diff --git a/server/internal/checkers/checksftp/context_test.go b/server/internal/checkers/checksftp/context_test.go new file mode 100644 index 000000000..b976053ef --- /dev/null +++ b/server/internal/checkers/checksftp/context_test.go @@ -0,0 +1,72 @@ +package checksftp_test + +import ( + "context" + "net" + "strconv" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/fclairamb/solidping/server/internal/checkers/checkerdef" + "github.com/fclairamb/solidping/server/internal/checkers/checksftp" +) + +// TestExecute_HonorsContextOnAStalledServer: a server that accepts the TCP +// connection and then never speaks used to hold the checker in the SSH +// handshake until the OS gave up, past the worker's deadline, so the worker +// abandoned it ("did not honor context"). Canceling the context must now cut +// the connection and return a timeout promptly. +func TestExecute_HonorsContextOnAStalledServer(t *testing.T) { + t.Parallel() + r := require.New(t) + + listener, err := (&net.ListenConfig{}).Listen(t.Context(), "tcp", "127.0.0.1:0") + r.NoError(err) + + t.Cleanup(func() { _ = listener.Close() }) + + go func() { + for { + conn, acceptErr := listener.Accept() + if acceptErr != nil { + return + } + + t.Cleanup(func() { _ = conn.Close() }) // held open, silent + } + }() + + host, portStr, err := net.SplitHostPort(listener.Addr().String()) + r.NoError(err) + + port, err := strconv.Atoi(portStr) + r.NoError(err) + + cfg := &checksftp.SFTPConfig{ + Host: host, + Port: port, + Timeout: time.Minute, // the context, not the config, must stop it + Username: "tester", + Password: "s3cret", + } + + ctx, cancel := context.WithTimeout(t.Context(), 300*time.Millisecond) + defer cancel() + + done := make(chan *checkerdef.Result, 1) + + go func() { + result, _ := (&checksftp.SFTPChecker{}).Execute(ctx, cfg) + done <- result + }() + + select { + case result := <-done: + r.NotNil(result) + r.Equal(checkerdef.StatusTimeout, result.Status, "output: %#v", result.Output) + case <-time.After(5 * time.Second): + r.Fail("Execute ignored its context on a stalled SSH handshake") + } +} diff --git a/web/dash0/src/i18n-html-lang.test.ts b/web/dash0/src/i18n-html-lang.test.ts new file mode 100644 index 000000000..87a43eae9 --- /dev/null +++ b/web/dash0/src/i18n-html-lang.test.ts @@ -0,0 +1,34 @@ +/** + * @vitest-environment jsdom + */ +import { afterEach, describe, expect, it } from "vitest"; +import i18n, { i18nReady } from "./i18n"; + +// The static index.html says lang="en". When the app renders another +// language, a stale lang makes the browser offer to auto-translate a page +// that is already in the visitor's language, and the translator's DOM edits +// then crash React reconciliation (removeChild NotFoundError). +describe(" follows the rendered language", () => { + afterEach(async () => { + await i18n.changeLanguage("en"); + }); + + it("is the rendered language after a change", async () => { + await i18nReady; + await i18n.changeLanguage("es"); + expect(document.documentElement.lang).toBe("es"); + + await i18n.changeLanguage("en"); + expect(document.documentElement.lang).toBe("en"); + }); + + it("uses the base language for a regional variant", async () => { + await i18n.changeLanguage("fr-CA"); + expect(document.documentElement.lang).toBe("fr"); + }); + + it("stays English for a language the app does not ship", async () => { + await i18n.changeLanguage("pt-BR"); + expect(document.documentElement.lang).toBe("en"); + }); +}); diff --git a/web/dash0/src/i18n.ts b/web/dash0/src/i18n.ts index a7c240e2c..50bb9410f 100644 --- a/web/dash0/src/i18n.ts +++ b/web/dash0/src/i18n.ts @@ -89,6 +89,16 @@ const enResources = { support: en_support, }; +// Keep on the language actually rendered (index.html ships "en"). +// A stale lang makes the browser offer to auto-translate a page already in the +// visitor's language, and the translator's DOM edits crash React's +// reconciliation (removeChild NotFoundError). resolvedLanguage is the shipped +// language in use: "fr" for fr-CA, "en" for a language the app lacks. +i18n.on("languageChanged", () => { + if (typeof document === "undefined") return; + document.documentElement.lang = i18n.resolvedLanguage ?? "en"; +}); + const initPromise = i18n .use(LanguageDetector) .use(initReactI18next) From 78f2f92256d5d133e3ee88eb31ea2a6f2088ea10 Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Fri, 13 Nov 2026 09:46:32 +0100 Subject: [PATCH 20/23] fix(ci): wait for a just-released image in the upgrade test A release tag enters the upgrade matrix the moment it is pushed, but its image is published by the tag's own CI about 20 minutes later. A pull request whose CI started in between failed every leg from that tag on 'not found' (both v0.49.1 legs on this branch). upgrade-test.sh now pulls the old image first and retries for IMAGE_WAIT seconds (default 0, so a local run still fails at once). CI sets 1500 and the job timeout goes from 10 to 35 minutes to leave room for it. Co-Authored-By: Claude Fable 5.2 --- .github/workflows/ci.yml | 6 +++++- scripts/upgrade-test.sh | 19 +++++++++++++++++++ wiki/testing/test-layers.md | 5 +++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e75d41e85..eaef1606e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1423,7 +1423,9 @@ jobs: runs-on: ubuntu-26.04 needs: [changes, upgrade-matrix] if: ${{ !startsWith(github.ref, 'refs/tags/') && needs.changes.outputs.code == 'true' }} - timeout-minutes: 10 + # A leg takes about 3 minutes. The rest is IMAGE_WAIT below: room to wait + # for the image of a tag pushed moments before this run started. + timeout-minutes: 35 strategy: fail-fast: false matrix: @@ -1446,6 +1448,8 @@ jobs: run: go build -o ../solidping . - name: Upgrade ${{ matrix.tag }} to HEAD + env: + IMAGE_WAIT: "1500" run: | set -euo pipefail command -v sqlite3 >/dev/null || sudo apt-get install -y sqlite3 diff --git a/scripts/upgrade-test.sh b/scripts/upgrade-test.sh index 3ddc60a77..f154dbef3 100755 --- a/scripts/upgrade-test.sh +++ b/scripts/upgrade-test.sh @@ -10,6 +10,8 @@ # HEAD_BIN the HEAD server binary (default: ./solidping, see `make test-upgrade`) # SKIP_TAGS space-separated tags left out of the matrix (default: SKIP_TAGS below) # PG_IMAGE Postgres image for the old and new side (default: docker-compose.yml's) +# IMAGE_WAIT seconds to wait for the released image to be published (default: 0). +# CI sets it: a tag exists minutes before the image its own CI pushes. # # Needs: bash, docker, curl, jq, git (matrix), sqlite3 (sqlite engine). @@ -121,6 +123,22 @@ fail() { exit 1 } +# pull_image : pulls it, retrying every 30 s for IMAGE_WAIT seconds. A +# release tag enters the matrix the moment it is pushed, but its image only +# exists once the tag's own CI has built it (about 20 minutes later): a PR whose +# CI starts in between failed every leg from that tag on "not found". +pull_image() { + local image=$1 deadline=$((SECONDS + ${IMAGE_WAIT:-0})) err + until err=$(docker pull -q "$image" 2>&1 >/dev/null); do + if ((SECONDS >= deadline)); then + echo "$err" >&2 + return 1 + fi + echo " $image is not available yet, retrying in 30 s" >&2 + sleep 30 + done +} + # wait_health : succeeds once /api/mgmt/health answers ok. wait_health() { local port=$1 deadline=$((SECONDS + $2)) @@ -222,6 +240,7 @@ fi # ---- Old side ------------------------------------------------------------- step "starting $IMAGE_REPO:${from_tag#v}" +pull_image "$IMAGE_REPO:${from_tag#v}" || fail "could not pull $IMAGE_REPO:${from_tag#v}" old_env_args=() for kv in "${common_env[@]}"; do old_env_args+=(-e "$kv"); done docker run -d --name "$old_name" -p 127.0.0.1::4000 "${old_args[@]}" "${old_env_args[@]}" \ diff --git a/wiki/testing/test-layers.md b/wiki/testing/test-layers.md index d154e7a86..ed0b88c19 100644 --- a/wiki/testing/test-layers.md +++ b/wiki/testing/test-layers.md @@ -148,6 +148,11 @@ recent `v*` tags plus the newest tag of the previous minor, each on both engines in `ci.yml`, both gating the `ci` job). Locally, `make test-upgrade` runs the whole matrix and needs docker, jq and sqlite3. A single leg: `HEAD_BIN=./solidping scripts/upgrade-test.sh sqlite v0.38.6`. +A tag enters the matrix the moment it is pushed, about 20 minutes before its own CI publishes +its image. CI sets `IMAGE_WAIT=1500` so a leg retries the pull that long instead of failing on +"not found" (the job timeout is 35 minutes for that reason). The default is 0: locally a +missing image fails at once. + It complements `scripts/check-frozen-migrations.sh` (static) and does not replace it. CI builds its own Go-only binary for the `upgrade` job (no frontends), so the layer also runs on From 976991affda894fcc957db0f0e480ecf59858297 Mon Sep 17 00:00:00 2001 From: fclairamb Date: Fri, 13 Nov 2026 22:44:59 +0100 Subject: [PATCH 21/23] fix(ci): upgrade test follows an enabled check, not a disabled fixture The snapshot took the newest raw result, which can belong to a disabled seed fixture (validating check ...030) that the old release probes once at boot and the scheduler never runs again, so the leg failed at random. Co-Authored-By: Claude Sonnet 5.5 --- scripts/upgrade-test.sh | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/upgrade-test.sh b/scripts/upgrade-test.sh index f154dbef3..c1b7a6fb5 100755 --- a/scripts/upgrade-test.sh +++ b/scripts/upgrade-test.sh @@ -190,8 +190,13 @@ snapshot() { echo "$name=$(api "$port" "$token" "$name?limit=1000" | jq '.data | length')" done echo "integrations=$(count_integrations "$port" "$token")" - api "$port" "$token" "results?periodType=raw&limit=1" | - jq -r '.data[0] | "lastCheck=\(.checkUid)\nlastPeriodStart=\(.periodStart)"' + # Newest result of an ENABLED check: the seed also holds disabled fixtures + # that a release probes once at boot, and the scheduler never runs them again. + local enabled + enabled=$(api "$port" "$token" "checks?limit=1000" | jq -c '[.data[] | select(.enabled != false) | .uid]') + api "$port" "$token" "results?periodType=raw&limit=200" | + jq -r --argjson enabled "$enabled" \ + '[.data[] | select(.checkUid as $u | $enabled | index($u))][0] | "lastCheck=\(.checkUid)\nlastPeriodStart=\(.periodStart)"' } latest_period_start() { # latest_period_start From f1ee4ebf30596cc2c1d135a1244f94999ff74677 Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Fri, 13 Nov 2026 22:46:32 +0100 Subject: [PATCH 22/23] fix(ci): watch an enabled check in the upgrade test The "scheduler writes new results" step watched the check that owned the newest raw result of the old version. Every fixture check is seeded with a "created" row. When no check finished during the 30 s warmup, the newest row was the one of the disabled validating-check fixture, which never gets another result, so the leg failed after 180 s ("Upgrade sqlite from v0.49.1" on 690b18dd6). The snapshot now keeps the newest raw result of an enabled check. Co-Authored-By: Claude Fable 5.2 --- scripts/upgrade-test.sh | 17 ++++++++++++----- wiki/testing/test-layers.md | 2 +- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/scripts/upgrade-test.sh b/scripts/upgrade-test.sh index f154dbef3..fc1c13239 100755 --- a/scripts/upgrade-test.sh +++ b/scripts/upgrade-test.sh @@ -179,9 +179,10 @@ count_integrations() { echo 0 } -# snapshot prints "name=count" lines plus the newest raw result. +# snapshot prints "name=count" lines plus the newest raw result of an +# enabled check. snapshot() { - local port=$1 token name + local port=$1 token name enabled token=$(curl -sf -X POST -H 'Content-Type: application/json' \ -d '{"org":"test","email":"test@test.com","password":"test"}' \ "http://127.0.0.1:$port/api/v1/auth/login" | jq -r .accessToken) @@ -190,8 +191,14 @@ snapshot() { echo "$name=$(api "$port" "$token" "$name?limit=1000" | jq '.data | length')" done echo "integrations=$(count_integrations "$port" "$token")" - api "$port" "$token" "results?periodType=raw&limit=1" | - jq -r '.data[0] | "lastCheck=\(.checkUid)\nlastPeriodStart=\(.periodStart)"' + # Only an enabled check can be watched. Every fixture check is seeded with a + # "created" row, and when no check finished during the warmup the newest row + # is the one of a disabled fixture, which never gets another result. + enabled=$(api "$port" "$token" "checks?limit=1000" | jq -c '[.data[] | select(.enabled) | .uid]') + api "$port" "$token" "results?periodType=raw&limit=100" | + jq -r --argjson enabled "$enabled" \ + '[.data[] | select(.checkUid as $uid | $enabled | index($uid))][0] + | "lastCheck=\(.checkUid)\nlastPeriodStart=\(.periodStart)"' } latest_period_start() { # latest_period_start @@ -258,7 +265,7 @@ before=$(snapshot "$old_port") || fail "could not snapshot the old version" echo "$before" last_check=$(sed -n 's/^lastCheck=//p' <<<"$before") last_start=$(sed -n 's/^lastPeriodStart=//p' <<<"$before") -[[ -n "$last_check" && "$last_check" != null ]] || fail "the old version wrote no result" +[[ -n "$last_check" && "$last_check" != null ]] || fail "the old version has no result for an enabled check" step "stopping the old version (SIGTERM)" docker stop "$old_name" >/dev/null diff --git a/wiki/testing/test-layers.md b/wiki/testing/test-layers.md index ed0b88c19..c545c7084 100644 --- a/wiki/testing/test-layers.md +++ b/wiki/testing/test-layers.md @@ -138,7 +138,7 @@ checks), HEAD starts on it, and the script asserts: - health answers `ok` within 60 s, and the log has no "half-migrated" or "migration content changed"; - every HEAD migration is listed in `bun_migrations`; - the counts of checks, incidents, status pages, integrations and members are unchanged; -- a check gets a result newer than the old version's last one (the scheduler runs on upgraded data); +- an enabled check gets a result newer than the old version's last one (the scheduler runs on upgraded data). The watched check must be enabled: every fixture check has a seeded `created` row, and a disabled one never gets another result; - SQLite only: the `solidping.db.pre-.bak` exists when HEAD has a newer migration. `scripts/upgrade-test.sh --matrix` prints the legs, one ` ` per line: the 3 most From f5fd3532d68e6317668942f3dc87c088e77c6e4f Mon Sep 17 00:00:00 2001 From: Florent Clairambault Date: Sat, 14 Nov 2026 16:33:43 +0100 Subject: [PATCH 23/23] fix: quieter status page and placement logs, checks list menu on mobile (#877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stacked on #862: merge it first. ## Summary - **Status page logs:** a visitor leaving a public status page mid-load no longer logs every best-effort loader as an ERROR (`context.Canceled` on a cancelled request is ignored; timeouts and real DB failures still log). - **Dark-region placement logs:** `ReplaceAutoChecks` logs one line per pass and region with a `stuck` count, instead of one line per stuck check on every pass. - **Checks list on mobile:** the icon-only header buttons are square below `sm`, so the "…" menu stays at the end of the row (e2e test at 361×654). Closes fclairamb/solidping-business#77 Closes fclairamb/solidping-business#78 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .../handlers/checks/placement_replace.go | 19 +++- .../handlers/statuspages/load_failure.go | 24 +++++ .../handlers/statuspages/load_failure_test.go | 100 ++++++++++++++++++ .../internal/handlers/statuspages/service.go | 42 ++++---- server/internal/regionsweep/placement_test.go | 33 ++++++ .../e2e/checks-index-header-mobile.spec.ts | 51 +++++++++ .../src/routes/orgs/$org/checks.index.tsx | 9 +- 7 files changed, 252 insertions(+), 26 deletions(-) create mode 100644 server/internal/handlers/statuspages/load_failure.go create mode 100644 server/internal/handlers/statuspages/load_failure_test.go create mode 100644 web/dash0/e2e/checks-index-header-mobile.spec.ts diff --git a/server/internal/handlers/checks/placement_replace.go b/server/internal/handlers/checks/placement_replace.go index 1f50fb3e3..58a103893 100644 --- a/server/internal/handlers/checks/placement_replace.go +++ b/server/internal/handlers/checks/placement_replace.go @@ -77,6 +77,18 @@ func (s *Service) ReplaceAutoChecks(ctx context.Context, req ReplacementRequest) envs := make(map[string]*placementEnv) changes := make([]PlacementChange, 0, len(candidates)) + // Checks left in the dark region for want of a healthy candidate. The + // sweep calls this on every pass while the region stays dark, so they are + // logged as ONE line per pass, never one line per check per pass. + stuck := 0 + + defer func() { + if stuck > 0 { + slog.InfoContext(ctx, "Auto-placed checks stay in a dark region: no healthy candidate left", + "region", req.Region, "stuck", stuck) + } + }() + for _, check := range candidates { if !check.Enabled || check.IsPassive() || !slices.Contains(check.Regions, req.Region) { continue @@ -100,6 +112,8 @@ func (s *Service) ReplaceAutoChecks(ctx context.Context, req ReplacementRequest) if moved { changes = append(changes, change) + } else { + stuck++ } } @@ -107,6 +121,7 @@ func (s *Service) ReplaceAutoChecks(ctx context.Context, req ReplacementRequest) } // replaceOne moves one check off the region, when a healthy candidate exists. +// It reports moved = false, without an error, for a check that has none. func (s *Service) replaceOne( ctx context.Context, check *models.Check, env *placementEnv, req *ReplacementRequest, ) (PlacementChange, bool, error) { @@ -119,9 +134,7 @@ func (s *Service) replaceOne( next, target, ok := regions.Replace(check.Regions, eligible, req.Healthy, req.Region) if !ok { - slog.InfoContext(ctx, "Auto-placed check stays in a dark region: no healthy candidate left", - "checkUid", check.UID, "region", req.Region) - + // Counted and logged once per pass by ReplaceAutoChecks. return PlacementChange{}, false, nil } diff --git a/server/internal/handlers/statuspages/load_failure.go b/server/internal/handlers/statuspages/load_failure.go new file mode 100644 index 000000000..d48843f7c --- /dev/null +++ b/server/internal/handlers/statuspages/load_failure.go @@ -0,0 +1,24 @@ +package statuspages + +import ( + "context" + "errors" + "log/slog" +) + +// logLoadFailure logs the failure of one of the best-effort loaders behind a +// status page view (updates, incidents, response times, coverage…), each of +// which degrades the page rather than failing it. +// +// A visitor who closes the tab or navigates away cancels the request context, +// and every loader still running then fails with context.Canceled. That is not +// a server fault, so nothing is logged for it. A deadline, or a cancellation +// that did not come from the request (the context is still live), stays logged +// at level, like any real database failure. +func logLoadFailure(ctx context.Context, level slog.Level, msg string, err error, args ...any) { + if errors.Is(err, context.Canceled) && errors.Is(ctx.Err(), context.Canceled) { + return + } + + slog.Log(ctx, level, msg, append([]any{"error", err}, args...)...) +} diff --git a/server/internal/handlers/statuspages/load_failure_test.go b/server/internal/handlers/statuspages/load_failure_test.go new file mode 100644 index 000000000..e75468e8e --- /dev/null +++ b/server/internal/handlers/statuspages/load_failure_test.go @@ -0,0 +1,100 @@ +package statuspages + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/fclairamb/solidping/server/internal/config" + "github.com/fclairamb/solidping/server/internal/db/models" + "github.com/fclairamb/solidping/server/internal/db/sqlite" +) + +var errDiskIO = errors.New("disk I/O error") + +// captureLogs routes the default logger to a buffer for the test. Not parallel: +// slog's default logger is process-wide. +func captureLogs(t *testing.T) *bytes.Buffer { + t.Helper() + + var buf bytes.Buffer + + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(previous) }) + + return &buf +} + +// TestLogLoadFailureSkipsVisitorCancellation: a status page loader failing +// because the visitor went away logs nothing, while every other failure keeps +// its level. +func TestLogLoadFailureSkipsVisitorCancellation(t *testing.T) { //nolint:paralleltest // slog.SetDefault + canceled, cancel := context.WithCancel(context.Background()) + cancel() + + wrapped := fmt.Errorf("aggregate response time bins: %w", context.Canceled) + + tests := []struct { + name string + ctx context.Context //nolint:containedctx // table input + err error + wantLog bool + }{ + {"visitor went away", canceled, wrapped, false}, + {"canceled error on a live request", context.Background(), wrapped, true}, + {"real failure on a canceled request", canceled, errDiskIO, true}, + {"deadline exceeded", canceled, context.DeadlineExceeded, true}, + {"real failure", context.Background(), errDiskIO, true}, + } + + for _, tc := range tests { //nolint:paralleltest // slog.SetDefault + t.Run(tc.name, func(t *testing.T) { + buf := captureLogs(t) + + logLoadFailure(tc.ctx, slog.LevelError, "Failed to load acme loader", tc.err, "orgUID", "acme-org") + + if !tc.wantLog { + require.Empty(t, buf.String()) + + return + } + + require.Contains(t, buf.String(), "level=ERROR") + require.Contains(t, buf.String(), "Failed to load acme loader") + require.Contains(t, buf.String(), "orgUID=acme-org") + }) + } +} + +// TestLoadRecentUpdatesCanceledRequestLogsNothing drives a real loader: the +// updates query of a request whose visitor has gone fails with +// context.Canceled, and the page load must not log it as a server error. +func TestLoadRecentUpdatesCanceledRequestLogsNothing(t *testing.T) { //nolint:paralleltest // slog.SetDefault + r := require.New(t) + ctx := t.Context() + + dbService, err := sqlite.New(ctx, sqlite.Config{InMemory: true}) + r.NoError(err) + r.NoError(dbService.Initialize(ctx)) + t.Cleanup(func() { _ = dbService.Close() }) + + org := models.NewOrganization("acme", "Acme") + r.NoError(dbService.CreateOrganization(ctx, org)) + + svc := NewService(dbService, &config.Config{}, nil) + page := &models.StatusPage{UID: "acme-page", HistoryDays: 90} + + reqCtx, cancel := context.WithCancel(ctx) + cancel() + + buf := captureLogs(t) + + r.Nil(svc.loadRecentUpdates(reqCtx, org.UID, page, 0)) + r.NotContains(buf.String(), "Failed to load public status updates") +} diff --git a/server/internal/handlers/statuspages/service.go b/server/internal/handlers/statuspages/service.go index d6614a8a8..d0a316e2e 100644 --- a/server/internal/handlers/statuspages/service.go +++ b/server/internal/handlers/statuspages/service.go @@ -1756,8 +1756,8 @@ func (s *Service) enrichSelectorCounts( if err != nil { // Best-effort like the rest of this function: a failed lookup // leaves every counter at zero rather than failing the page load. - slog.ErrorContext(ctx, "Failed to check selector group existence", - "error", err, "orgUid", orgUID, "sectionUid", section.UID) + logLoadFailure(ctx, slog.LevelError, "Failed to check selector group existence", + err, "orgUid", orgUID, "sectionUid", section.UID) return } @@ -1769,8 +1769,8 @@ func (s *Service) enrichSelectorCounts( // claimed-elsewhere tally needs the actual matched UIDs. matched, total, err := s.db.ListChecks(ctx, orgUID, section.Selector.Filter()) if err != nil { - slog.ErrorContext(ctx, "Failed to count status page selector matches", - "error", err, "orgUid", orgUID, "sectionUid", section.UID) + logLoadFailure(ctx, slog.LevelError, "Failed to count status page selector matches", + err, "orgUid", orgUID, "sectionUid", section.UID) return } @@ -1904,8 +1904,8 @@ func (s *Service) enrichSelectorCountsIfDynamic( states, err := s.loadPageState(ctx, pageUID) if err != nil { - slog.ErrorContext(ctx, "Failed to load page state for selector enrichment", - "error", err, "orgUid", orgUID, "statusPageUid", pageUID) + logLoadFailure(ctx, slog.LevelError, "Failed to load page state for selector enrichment", + err, "orgUid", orgUID, "statusPageUid", pageUID) return } @@ -2682,8 +2682,8 @@ func (s *Service) computeStatusPageView( if s.publicIncidents != nil { active, incErr := s.publicIncidents.ListPublicIncidents(ctx, page, true) if incErr != nil { - slog.ErrorContext(ctx, "Failed to load active incident publications for status page", - "error", incErr, "statusPageUID", page.UID, "orgUID", org.UID) + logLoadFailure(ctx, slog.LevelError, "Failed to load active incident publications for status page", + incErr, "statusPageUID", page.UID, "orgUID", org.UID) } else { response.ActiveIncidents = active } @@ -2722,8 +2722,8 @@ func (s *Service) loadRecentUpdates( updates, err := s.db.ListPublicStatusUpdates(ctx, page.UID, window) if err != nil { - slog.ErrorContext(ctx, "Failed to load public status updates for status page", - "error", err, "statusPageUID", page.UID, "orgUID", orgUID) + logLoadFailure(ctx, slog.LevelError, "Failed to load public status updates for status page", + err, "statusPageUID", page.UID, "orgUID", orgUID) return nil } @@ -3024,8 +3024,8 @@ func (s *Service) expandResourceMembers( case resource.CheckGroupUID != nil: uids, err := s.db.ListCheckUIDsByGroup(ctx, orgUID, *resource.CheckGroupUID) if err != nil { - slog.ErrorContext(ctx, "Failed to list check group members for status page", - "error", err, "orgUID", orgUID, "checkGroupUID", *resource.CheckGroupUID) + logLoadFailure(ctx, slog.LevelError, "Failed to list check group members for status page", + err, "orgUID", orgUID, "checkGroupUID", *resource.CheckGroupUID) continue } @@ -3342,8 +3342,8 @@ func (s *Service) fetchRecentResults( rows, err := s.db.RecentResultsPerCheck(sloghook.WithCallsite(ctx, "statuspages.recent_results"), filter) if err != nil { - slog.ErrorContext(ctx, "Failed to load status page response-time results", - "error", err, "orgUID", orgUID, "checks", len(checkUIDs)) + logLoadFailure(ctx, slog.LevelError, "Failed to load status page response-time results", + err, "orgUID", orgUID, "checks", len(checkUIDs)) return recentByCheck } @@ -3436,8 +3436,8 @@ func (s *Service) fetchResponseTimeSeam( BinDuration: seamBinWidth(windowSpan), }) if err != nil { - slog.ErrorContext(ctx, "Failed to load status page response-time seam bins", - "error", err, "orgUID", orgUID, "checks", len(checkUIDs)) + logLoadFailure(ctx, slog.LevelError, "Failed to load status page response-time seam bins", + err, "orgUID", orgUID, "checks", len(checkUIDs)) return nil } @@ -3848,9 +3848,9 @@ func (s *Service) responseTimeBudgets( ) map[string]int { checks, err := s.db.GetChecksByUIDs(ctx, orgUID, checkUIDs) if err != nil { - slog.WarnContext(ctx, "Failed to size status page response-time budgets from check regions; "+ + logLoadFailure(ctx, slog.LevelWarn, "Failed to size status page response-time budgets from check regions; "+ "falling back to the region fan-out cap", - "error", err, "orgUID", orgUID) + err, "orgUID", orgUID) return nil } @@ -3944,7 +3944,7 @@ func (s *Service) enrichHourly( func (s *Service) memberChecks(ctx context.Context, orgUID string, checkUIDs []string) map[string]*models.Check { checks, err := s.db.GetChecksByUIDs(ctx, orgUID, checkUIDs) if err != nil { - slog.WarnContext(ctx, "Failed to load checks for status page coverage", "error", err) + logLoadFailure(ctx, slog.LevelWarn, "Failed to load checks for status page coverage", err) return nil } @@ -4444,8 +4444,8 @@ func (s *Service) enrichResourceInfo( if pageHasGroupResource(sections) { counts, err := s.db.GetCheckGroupStatusCounts(ctx, orgUID) if err != nil { - slog.ErrorContext(ctx, "Failed to load check group status counts for status page", - "error", err, "orgUID", orgUID) + logLoadFailure(ctx, slog.LevelError, "Failed to load check group status counts for status page", + err, "orgUID", orgUID) } else { groupCounts = counts } diff --git a/server/internal/regionsweep/placement_test.go b/server/internal/regionsweep/placement_test.go index 6467af377..57f544743 100644 --- a/server/internal/regionsweep/placement_test.go +++ b/server/internal/regionsweep/placement_test.go @@ -1,6 +1,9 @@ package regionsweep_test import ( + "bytes" + "log/slog" + "strings" "testing" "time" @@ -214,3 +217,33 @@ func TestReplaceAutoChecksNeverTouchesPinnedOrPrivate(t *testing.T) { r.NoError(err) r.Empty(changes, "a private region is never a re-placement source") } + +// TestStuckAutoChecksLogOneLinePerPass: the sweep retries the stuck checks on +// every pass while the region stays dark, so a line per check per pass would +// flood the logs during a long outage. One pass logs one line, with the count. +func TestStuckAutoChecksLogOneLinePerPass(t *testing.T) { //nolint:paralleltest // slog.SetDefault + r := require.New(t) + env := newTestEnv(t) + + uids := make([]string, 0, 3) + for _, name := range []string{"Acme API", "Acme Web", "Acme DB"} { + uids = append(uids, env.autoCheck(t, env.acme, name, darkRegion).UID) + } + + var logs bytes.Buffer + + previous := slog.Default() + slog.SetDefault(slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(previous) }) + + changes, err := env.checks.ReplaceAutoChecks(t.Context(), checks.ReplacementRequest{ + Region: darkRegion, CheckUIDs: uids, Healthy: map[string]bool{}, + Reason: models.PlacementReasonRegionOffline, + }) + r.NoError(err) + r.Empty(changes, "no healthy candidate: nothing moves") + + r.Equal(1, strings.Count(logs.String(), "stay in a dark region"), logs.String()) + r.Contains(logs.String(), "region="+darkRegion) + r.Contains(logs.String(), "stuck=3") +} diff --git a/web/dash0/e2e/checks-index-header-mobile.spec.ts b/web/dash0/e2e/checks-index-header-mobile.spec.ts new file mode 100644 index 000000000..ca0e3dff8 --- /dev/null +++ b/web/dash0/e2e/checks-index-header-mobile.spec.ts @@ -0,0 +1,51 @@ +import { test, expect, stubSavedPreferences } from "./fixtures"; +import type { Route } from "@playwright/test"; + +// The checks list header on a phone: its actions (new group, new check, +// "describe it", the "…" menu) have their own row below the title. The "…" +// menu used to wrap alone onto a second row, at its LEFT edge, on a 361 px +// wide phone in French with AI enabled (the longest row). It must stay at the +// right end of the actions. + +test.describe("Checks list header (mobile)", () => { + test("the more-actions menu sits at the right end of the actions row", async ({ + authenticatedPage, + }) => { + const page = authenticatedPage; + await page.setViewportSize({ width: 361, height: 654 }); + // French: its "Nouveau contrôle" is among the widest primary labels. The + // account's saved language wins over the browser's, so stub that one. + await stubSavedPreferences(page, () => ({ locale: "fr" })); + // AI on: the "describe it" button joins the row, as on an AI-enabled server. + await page.route("**/api/v1/config", async (route: Route) => { + const response = await route.fetch(); + const body = await response.json(); + await route.fulfill({ response, json: { ...body, ai: { enabled: true } } }); + }); + + await page.goto("orgs/test/checks"); + + const newCheck = page.getByTestId("new-check-button"); + const describe = page.getByTestId("describe-check-button"); + const more = page.getByTestId("checks-more-actions"); + await expect(newCheck).toHaveText(/Nouveau contrôle/); + await expect(describe).toBeVisible(); + await expect(more).toBeVisible(); + + const newCheckBox = await newCheck.boundingBox(); + const describeBox = await describe.boundingBox(); + const moreBox = await more.boundingBox(); + expect(newCheckBox && describeBox && moreBox).toBeTruthy(); + if (!newCheckBox || !describeBox || !moreBox) return; + + // One row: the menu is level with the primary action, after "describe it". + expect(Math.abs(moreBox.y - newCheckBox.y)).toBeLessThan(2); + expect(moreBox.x).toBeGreaterThan(describeBox.x + describeBox.width); + + // And nothing in the header pushes the page sideways. + const overflows = await page.evaluate( + () => document.documentElement.scrollWidth > document.documentElement.clientWidth + 1, + ); + expect(overflows).toBe(false); + }); +}); diff --git a/web/dash0/src/routes/orgs/$org/checks.index.tsx b/web/dash0/src/routes/orgs/$org/checks.index.tsx index 9de4e4645..886f826a9 100644 --- a/web/dash0/src/routes/orgs/$org/checks.index.tsx +++ b/web/dash0/src/routes/orgs/$org/checks.index.tsx @@ -1641,7 +1641,9 @@ function ChecksIndexPage() { description={t("subtitle")} actions={ <> - @@ -1655,7 +1657,7 @@ function ChecksIndexPage() { {/* "Describe it" (spec 2026-10-03-07): only when the server has an AI provider. */} {aiChecksEnabled && ( -