diff --git a/server/internal/app/openapi/openapi.yaml b/server/internal/app/openapi/openapi.yaml index 04af08f44..17b667d43 100644 --- a/server/internal/app/openapi/openapi.yaml +++ b/server/internal/app/openapi/openapi.yaml @@ -12285,6 +12285,14 @@ components: type: object enabled: type: boolean + resolveOpenIncidents: + type: boolean + description: >- + Close the check's open incidents (outage and degraded) in the same + request, with resolution type `check_disabled`, so they do not stay + open on a check that no longer runs. Only valid together with + `enabled: false`; otherwise a `VALIDATION_ERROR`. Defaults to false: + incidents stay open. period: type: string regions: diff --git a/server/internal/app/server.go b/server/internal/app/server.go index 6575afde6..a45233bbf 100644 --- a/server/internal/app/server.go +++ b/server/internal/app/server.go @@ -2068,6 +2068,7 @@ func (s *Server) SetupRoutes(ctx context.Context) { // Deleting a check resolves its open incidents (the DB layer does that); // this tells whoever was paged, once, that it closed (spec 2026-10-08-02). checksService.SetCheckDeletedIncidentNotifier(incidentsService) + checksService.SetCheckDisableIncidentResolver(incidentsService) // The MCP surface builds its OWN statuspages.Service (mcp.NewHandler runs // far earlier in this function), and every NewService starts with its own // view memo. Point it at this one's, or an MCP-driven page edit evicts a map diff --git a/server/internal/db/models/incident.go b/server/internal/db/models/incident.go index a08664330..d33722588 100644 --- a/server/internal/db/models/incident.go +++ b/server/internal/db/models/incident.go @@ -38,6 +38,10 @@ const ( // org deletion, the test API. The DB layer's DeleteCheck sets it in the // same transaction as the soft delete, so no incident outlives its check. ResolutionTypeCheckDeleted = "check_deleted" + // ResolutionTypeCheckDisabled closes an active incident because its check + // was disabled by hand with resolveOpenIncidents (spec 2026-10-23-01). The + // check did not necessarily recover: it simply stopped running. + ResolutionTypeCheckDisabled = "check_disabled" ) // Incident kinds. `kind` discriminates what an incident row is ABOUT. It is a diff --git a/server/internal/email/templates/incident-resolved.html b/server/internal/email/templates/incident-resolved.html index ff7454d5e..07c3ec89a 100644 --- a/server/internal/email/templates/incident-resolved.html +++ b/server/internal/email/templates/incident-resolved.html @@ -1,12 +1,12 @@ -{{define "subject"}}{{if .SubjectPrefix}}{{.SubjectPrefix}} {{end}}{{if .CheckDeleted}}⚪ {{t "incident.resolved.deletedSubject" "check" .CheckName}}{{else}}🟢 {{if .Duration}}{{t "incident.resolved.titleAfter" "check" .CheckName "duration" (duration .Duration)}}{{else}}{{t "incident.resolved.title" "check" .CheckName}}{{end}}{{end}}{{if .IncidentNumber}} (#{{.IncidentNumber}}){{end}}{{end}} -{{define "preheader"}}{{if .CheckDeleted}}{{t "incident.resolved.deletedPreheader" "check" .CheckName}}{{else}}{{if .Duration}}{{t "incident.resolved.downFor" "duration" (duration .Duration)}} {{end}}{{t "incident.resolved.passing"}}{{end}}{{end}} +{{define "subject"}}{{if .SubjectPrefix}}{{.SubjectPrefix}} {{end}}{{if .CheckDeleted}}⚪ {{t (print "incident.resolved." .ClosedKind "Subject") "check" .CheckName}}{{else}}🟢 {{if .Duration}}{{t "incident.resolved.titleAfter" "check" .CheckName "duration" (duration .Duration)}}{{else}}{{t "incident.resolved.title" "check" .CheckName}}{{end}}{{end}}{{if .IncidentNumber}} (#{{.IncidentNumber}}){{end}}{{end}} +{{define "preheader"}}{{if .CheckDeleted}}{{t (print "incident.resolved." .ClosedKind "Preheader") "check" .CheckName}}{{else}}{{if .Duration}}{{t "incident.resolved.downFor" "duration" (duration .Duration)}} {{end}}{{t "incident.resolved.passing"}}{{end}}{{end}} {{template "base.html" .}} -{{define "statusbanner"}}{{if .CheckDeleted}}{{template "statusbanner-render" dict "Variant" "comment" "Label" (t "incident.pill.checkDeleted")}}{{else}}{{template "statusbanner-render" dict "Variant" "recovered" "Label" (t "incident.pill.recovered")}}{{end}}{{end}} +{{define "statusbanner"}}{{if .CheckDeleted}}{{template "statusbanner-render" dict "Variant" "comment" "Label" (t (print "incident.pill.check" .ClosedPill))}}{{else}}{{template "statusbanner-render" dict "Variant" "recovered" "Label" (t "incident.pill.recovered")}}{{end}}{{end}} {{define "content"}} {{$check := nolink .CheckName}}{{if .CheckURL}}{{$check = tHTML "incident.checkLink" "url" .CheckURL "check" $check}}{{end}} -

{{if .CheckDeleted}}{{tHTML "incident.resolved.deletedTitle" "check" $check}}{{else}}{{tHTML "incident.resolved.title" "check" $check}}{{end}}

+

{{if .CheckDeleted}}{{tHTML (print "incident.resolved." .ClosedKind "Title") "check" $check}}{{else}}{{tHTML "incident.resolved.title" "check" $check}}{{end}}

{{if .CheckDeleted}} -

{{t "incident.resolved.deletedBody"}}

+

{{t (print "incident.resolved." .ClosedKind "Body")}}

{{else if .Duration}}

{{tHTML "incident.resolved.leadDuration" "duration" (duration .Duration)}}

{{else}} @@ -23,9 +23,9 @@

{{if .CheckDeleted}}{{tHTML "incident.resolved.deletedTitle" "check" $check}
{{template "button" dict "URL" .IncidentURL "Label" (t "common.incidentActions.viewIncident") "Variant" "secondary"}}
{{end}} {{end}} -{{define "text"}}{{if .CheckDeleted}}{{t "incident.resolved.deletedTitle" "check" .CheckName}} +{{define "text"}}{{if .CheckDeleted}}{{t (print "incident.resolved." .ClosedKind "Title") "check" .CheckName}} -{{t "incident.resolved.deletedBody"}} +{{t (print "incident.resolved." .ClosedKind "Body")}} {{else}}{{t "incident.resolved.textTitle" "check" .CheckName}} {{t "incident.resolved.textBody" "check" .CheckName}} diff --git a/server/internal/handlers/checks/check_disable_incidents_test.go b/server/internal/handlers/checks/check_disable_incidents_test.go new file mode 100644 index 000000000..13b4cf04a --- /dev/null +++ b/server/internal/handlers/checks/check_disable_incidents_test.go @@ -0,0 +1,121 @@ +package checks_test + +// Disabling a check with resolveOpenIncidents closes its open incidents with +// resolution type check_disabled (spec 2026-10-23-01). + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/fclairamb/solidping/server/internal/db/models" + "github.com/fclairamb/solidping/server/internal/handlers/checks" +) + +func TestDisableResolvesOpenIncidents(t *testing.T) { + t.Parallel() + r := require.New(t) + ctx := t.Context() + world := newDegradedDisableWorld(t, "disable-resolves") + world.checks.SetCheckDisableIncidentResolver(world.incidents) + + check := models.NewCheck(world.org.UID, "acme", "http") + r.NoError(world.dbSvc.CreateCheck(ctx, check)) + + outage := models.NewIncident(world.org.UID, check.UID, time.Now().Add(-time.Hour), "acme is down") + r.NoError(world.dbSvc.CreateIncident(ctx, outage)) + degraded := world.openDegraded(t, check.UID) + + // Positive control: disabling without the flag leaves both active. + _, err := world.checks.UpdateCheck(ctx, world.org.Slug, check.UID, &checks.UpdateCheckRequest{ + Enabled: boolPtr(false), + }) + r.NoError(err) + r.Equal(models.IncidentStateActive, world.incident(t, outage.UID).State) + r.Equal(models.IncidentStateActive, world.incident(t, degraded.UID).State) + + _, err = world.checks.UpdateCheck(ctx, world.org.Slug, check.UID, &checks.UpdateCheckRequest{ + Enabled: boolPtr(false), + ResolveOpenIncidents: boolPtr(true), + }) + r.NoError(err) + + for _, uid := range []string{outage.UID, degraded.UID} { + got := world.incident(t, uid) + r.Equal(models.IncidentStateResolved, got.State) + r.NotNil(got.ResolutionType) + r.Equal(models.ResolutionTypeCheckDisabled, *got.ResolutionType) + r.NotNil(got.ResolvedAt) + + events, listErr := world.dbSvc.ListEvents(ctx, &models.ListEventsFilter{ + OrganizationUID: world.org.UID, + IncidentUID: &uid, + EventTypes: []models.EventType{models.EventTypeIncidentResolved}, + }) + r.NoError(listErr) + r.Len(events, 1, "one incident.resolved event per incident") + } + + // A retry with nothing open is a no-op, and emits no second event. + _, err = world.checks.UpdateCheck(ctx, world.org.Slug, check.UID, &checks.UpdateCheckRequest{ + Enabled: boolPtr(false), + ResolveOpenIncidents: boolPtr(true), + }) + r.NoError(err) + + for _, uid := range []string{outage.UID, degraded.UID} { + events, listErr := world.dbSvc.ListEvents(ctx, &models.ListEventsFilter{ + OrganizationUID: world.org.UID, + IncidentUID: &uid, + EventTypes: []models.EventType{models.EventTypeIncidentResolved}, + }) + r.NoError(listErr) + r.Len(events, 1, "retry must not emit a second incident.resolved event") + } +} + +func TestResolveOpenIncidentsWithoutDisableOverHTTPIs400(t *testing.T) { + t.Parallel() + r := require.New(t) + + router := newFullChecksRouter(t) + base := "/orgs/acme/checks" + + body := `{"type":"http","name":"acme","config":{"url":"https://acme.com"}}` + rec := checksCall(t, router, http.MethodPost, base, body) + r.Equal(http.StatusCreated, rec.Code, rec.Body.String()) + var created struct { + UID string `json:"uid"` + } + r.NoError(json.Unmarshal(rec.Body.Bytes(), &created)) + + rec = checksCall(t, router, http.MethodPatch, base+"/"+created.UID, `{"resolveOpenIncidents":true}`) + r.Equal(http.StatusBadRequest, rec.Code, rec.Body.String()) + r.Contains(rec.Body.String(), "VALIDATION_ERROR") +} + +func TestResolveOpenIncidentsWithoutDisableIsRejected(t *testing.T) { + t.Parallel() + r := require.New(t) + ctx := t.Context() + world := newDegradedDisableWorld(t, "disable-rejects") + world.checks.SetCheckDisableIncidentResolver(world.incidents) + + check := models.NewCheck(world.org.UID, "acme", "http") + r.NoError(world.dbSvc.CreateCheck(ctx, check)) + outage := models.NewIncident(world.org.UID, check.UID, time.Now().Add(-time.Hour), "acme is down") + r.NoError(world.dbSvc.CreateIncident(ctx, outage)) + + for _, req := range []*checks.UpdateCheckRequest{ + {ResolveOpenIncidents: boolPtr(true)}, + {Enabled: boolPtr(true), ResolveOpenIncidents: boolPtr(true)}, + } { + _, err := world.checks.UpdateCheck(ctx, world.org.Slug, check.UID, req) + r.Error(err) + } + + r.Equal(models.IncidentStateActive, world.incident(t, outage.UID).State) +} diff --git a/server/internal/handlers/checks/disable_incidents.go b/server/internal/handlers/checks/disable_incidents.go new file mode 100644 index 000000000..98664e6bb --- /dev/null +++ b/server/internal/handlers/checks/disable_incidents.go @@ -0,0 +1,54 @@ +package checks + +import ( + "context" + "errors" + "fmt" + + "github.com/fclairamb/solidping/server/internal/db/models" +) + +// errResolveOpenNeedsDisable rejects resolveOpenIncidents without enabled:false. +// Listed in isCheckFieldValidationError so it is a 400. +var errResolveOpenNeedsDisable = errors.New("resolveOpenIncidents requires enabled: false") + +// CheckDisableIncidentResolver closes the open incidents of a check that was +// just disabled by hand (spec 2026-10-23-01). It is an interface because the +// implementation lives in handlers/incidents, which this package cannot import. +type CheckDisableIncidentResolver interface { + ResolveOnCheckDisable(ctx context.Context, orgUID, checkUID string) ([]string, error) +} + +// SetCheckDisableIncidentResolver wires the resolver. Every service serving a +// check write path (HTTP routes, MCP) must set it, or resolveOpenIncidents is +// silently ignored. +func (s *Service) SetCheckDisableIncidentResolver(resolver CheckDisableIncidentResolver) { + s.disableResolver = resolver +} + +func validateResolveOpenIncidents(req *UpdateCheckRequest) error { + if req.ResolveOpenIncidents != nil && *req.ResolveOpenIncidents && + (req.Enabled == nil || *req.Enabled) { + return errResolveOpenNeedsDisable + } + + return nil +} + +// resolveIncidentsOnDisable fires on any write that sets enabled:false with +// resolveOpenIncidents:true, not only on a true to false transition, so a retry +// after a failed resolve still closes them. +func (s *Service) resolveIncidentsOnDisable( + ctx context.Context, check *models.Check, req *UpdateCheckRequest, +) error { + if s.disableResolver == nil || req.ResolveOpenIncidents == nil || !*req.ResolveOpenIncidents || + req.Enabled == nil || *req.Enabled { + return nil + } + + if _, err := s.disableResolver.ResolveOnCheckDisable(ctx, check.OrganizationUID, check.UID); err != nil { + return fmt.Errorf("resolve open incidents: %w", err) + } + + return nil +} diff --git a/server/internal/handlers/checks/handler.go b/server/internal/handlers/checks/handler.go index a4e9a0f40..05d93fa04 100644 --- a/server/internal/handlers/checks/handler.go +++ b/server/internal/handlers/checks/handler.go @@ -987,6 +987,7 @@ func isCheckFieldValidationError(err error) bool { errors.Is(err, errFlapBackoffTooSmall) || errors.Is(err, errMaxRecoveryMultTooSmall) || errors.Is(err, errInvalidTraceroutePolicy) || + errors.Is(err, errResolveOpenNeedsDisable) || errors.Is(err, errInvalidFailQuorum) || // Placement (spec 2026-09-25-06): a contradictory or unplaceable // placement request is the caller's to fix. diff --git a/server/internal/handlers/checks/service.go b/server/internal/handlers/checks/service.go index c40dda7f2..64e64d02e 100644 --- a/server/internal/handlers/checks/service.go +++ b/server/internal/handlers/checks/service.go @@ -622,6 +622,9 @@ type Service struct { // degraded detection is turned off (spec 2026-09-24-08). nil = not wired, // in which case nothing is resolved; see SetDegradedIncidentResolver. degradedResolver DegradedIncidentResolver + // disableResolver closes a check's open incidents when it is disabled with + // resolveOpenIncidents (spec 2026-10-23-01). nil = not wired, nothing resolved. + disableResolver CheckDisableIncidentResolver // checkDeletedNotifier tells whoever was paged that a deleted check's // incident closed (spec 2026-10-08-02). nil = not wired: the incidents // still resolve, silently. See SetCheckDeletedIncidentNotifier. @@ -2004,6 +2007,10 @@ type UpdateCheckRequest struct { RegionCount *int `json:"regionCount,omitempty"` RegionPool *[]string `json:"regionPool,omitempty"` Enabled *bool `json:"enabled,omitempty"` + // ResolveOpenIncidents closes the check's open incidents with resolution + // type `check_disabled` in the same write. Only valid alongside + // `enabled: false` (spec 2026-10-23-01). + ResolveOpenIncidents *bool `json:"resolveOpenIncidents,omitempty"` // Internal is decoded only so it can be refused — see // ErrInternalFieldNotWritable (spec 2026-08-27-01). Internal *bool `json:"internal,omitempty"` @@ -2279,6 +2286,9 @@ func (s *Service) UpdateCheck( return CheckResponse{}, coverErr } } + if validateErr := validateResolveOpenIncidents(req); validateErr != nil { + return CheckResponse{}, validateErr + } if req.Enabled != nil { update.Enabled = req.Enabled } @@ -2404,6 +2414,10 @@ func (s *Service) UpdateCheck( return CheckResponse{}, resolveErr } + if resolveErr := s.resolveIncidentsOnDisable(ctx, check, req); resolveErr != nil { + return CheckResponse{}, resolveErr + } + // Reconcile check jobs if regions, period, spread, enabled, or config // changed (a regionSpread-only edit re-levels the per-region phases). if regionsChanged || req.Period != nil || req.RegionSpread != nil || diff --git a/server/internal/handlers/incidents/check_disabled.go b/server/internal/handlers/incidents/check_disabled.go new file mode 100644 index 000000000..0b9660332 --- /dev/null +++ b/server/internal/handlers/incidents/check_disabled.go @@ -0,0 +1,105 @@ +package incidents + +import ( + "context" + "fmt" + "log/slog" + + "github.com/fclairamb/solidping/server/internal/audit" + "github.com/fclairamb/solidping/server/internal/db/models" +) + +// ResolveOnCheckDisable closes every active incident (outage and degraded) of a +// check the user just disabled with resolveOpenIncidents (spec 2026-10-23-01). +// Without it they would stay active forever: a disabled check has no jobs, so +// no result ever arrives to auto-resolve them. +// +// Incidents resolve with resolution_type = "check_disabled" and resolved_by = +// the caller when known, so history and notifications say the check was +// disabled rather than that it recovered. Returns the UIDs it resolved. +func (s *Service) ResolveOnCheckDisable(ctx context.Context, orgUID, checkUID string) ([]string, error) { + active, _, err := s.db.ListIncidents(ctx, &models.ListIncidentsFilter{ + OrganizationUID: orgUID, + CheckUIDs: []string{checkUID}, + States: []models.IncidentState{models.IncidentStateActive}, + }) + if err != nil { + return nil, fmt.Errorf("list active incidents: %w", err) + } + + if len(active) == 0 { + return nil, nil + } + + check, checkErr := s.db.GetCheck(ctx, orgUID, checkUID) + if checkErr != nil { + check = nil + } + + actorUID := audit.ActorFromContext(ctx).UserUID + resolved := make([]string, 0, len(active)) + + for _, incident := range active { + if err := s.resolveForCheckDisable(ctx, orgUID, incident, check, actorUID); err != nil { + return resolved, err + } + + resolved = append(resolved, incident.UID) + } + + return resolved, nil +} + +func (s *Service) resolveForCheckDisable( + ctx context.Context, orgUID string, incident *models.Incident, check *models.Check, actorUID string, +) error { + now := s.clock.Now() + state := models.IncidentStateResolved + resolutionType := models.ResolutionTypeCheckDisabled + update := models.IncidentUpdate{ + State: &state, + ResolvedAt: &now, + ResolutionType: &resolutionType, + } + + if actorUID != "" { + update.ResolvedBy = &actorUID + } + + if err := s.db.UpdateIncident(ctx, incident.UID, &update); err != nil { + return fmt.Errorf("resolve incident %s: %w", incident.UID, err) + } + + incident.State = state + incident.ResolvedAt = &now + incident.ResolutionType = &resolutionType + incident.ResolvedBy = update.ResolvedBy + + // Cancel BEFORE emitting, or the sweep also drops the resolved + // notifications about to be queued. + s.cancelPendingNotifications(ctx, incident.UID, nil) + + payload := models.JSONMap{ + keyCheckUID: incident.CheckUID, + keyResolutionType: resolutionType, + keyResolvedAt: now, + keyDurationSeconds: int64(now.Sub(incident.StartedAt).Seconds()), + } + if check != nil { + payload[keyCheckSlug] = check.Slug + payload[keyCheckName] = check.Name + } + + if actorUID != "" { + payload[payloadKeyActorUID] = actorUID + } + + if err := s.emitEvent(ctx, orgUID, models.EventTypeIncidentResolved, incident, payload); err != nil { + slog.WarnContext(ctx, "Failed to emit check-disabled resolve event", + "incidentUid", incident.UID, "error", err) + } + + s.publishResolved(ctx, incident) + + return nil +} diff --git a/server/internal/i18n/locales/de/incident.json b/server/internal/i18n/locales/de/incident.json index 8169906de..5250e1d75 100644 --- a/server/internal/i18n/locales/de/incident.json +++ b/server/internal/i18n/locales/de/incident.json @@ -20,6 +20,7 @@ "down": "Ausgefallen", "recovered": "Wiederhergestellt", "checkDeleted": "Prüfung gelöscht", + "checkDisabled": "Prüfung deaktiviert", "escalated": "Eskaliert", "reopened": "Wiedereröffnet", "comment": "Kommentar", @@ -94,6 +95,10 @@ "deletedTitle": "{check} wurde gelöscht", "deletedPreheader": "{check} wurde gelöscht, daher wurde der Vorfall geschlossen.", "deletedBody": "Die Prüfung wurde gelöscht, daher wurde dieser Vorfall geschlossen. Das heißt nicht, dass der Dienst wieder läuft.", + "disabledSubject": "{check} wurde deaktiviert, Vorfall geschlossen", + "disabledTitle": "{check} wurde deaktiviert", + "disabledPreheader": "{check} wurde deaktiviert, daher wurde der Vorfall geschlossen.", + "disabledBody": "Die Prüfung wurde deaktiviert, daher wurde dieser Vorfall geschlossen. Sie hat sich nicht unbedingt erholt.", "textTitle": "{check} wiederhergestellt", "textBody": "Der Vorfall für {check} wurde gelöst." }, diff --git a/server/internal/i18n/locales/en/incident.json b/server/internal/i18n/locales/en/incident.json index 305ddf1f1..2adfe5a63 100644 --- a/server/internal/i18n/locales/en/incident.json +++ b/server/internal/i18n/locales/en/incident.json @@ -20,6 +20,7 @@ "down": "Down", "recovered": "Recovered", "checkDeleted": "Check deleted", + "checkDisabled": "Check disabled", "escalated": "Escalated", "reopened": "Reopened", "comment": "Comment", @@ -94,6 +95,10 @@ "deletedTitle": "{check} was deleted", "deletedPreheader": "{check} was deleted, so its incident was closed.", "deletedBody": "The check was deleted, so this incident was closed. It did not necessarily recover.", + "disabledSubject": "{check} was disabled, incident closed", + "disabledTitle": "{check} was disabled", + "disabledPreheader": "{check} was disabled, so its incident was closed.", + "disabledBody": "The check was disabled, so this incident was closed. It did not necessarily recover.", "textTitle": "{check} recovered", "textBody": "The incident for {check} has been resolved." }, diff --git a/server/internal/i18n/locales/es/incident.json b/server/internal/i18n/locales/es/incident.json index 5991c032f..2fabbeb78 100644 --- a/server/internal/i18n/locales/es/incident.json +++ b/server/internal/i18n/locales/es/incident.json @@ -20,6 +20,7 @@ "down": "Caído", "recovered": "Recuperado", "checkDeleted": "Comprobación eliminada", + "checkDisabled": "Comprobación desactivada", "escalated": "Escalado", "reopened": "Reabierto", "comment": "Comentario", @@ -94,6 +95,10 @@ "deletedTitle": "Se eliminó {check}", "deletedPreheader": "Se eliminó {check}, así que su incidente se cerró.", "deletedBody": "Se eliminó la comprobación, así que este incidente se cerró. Eso no significa que el servicio se haya recuperado.", + "disabledSubject": "Se desactivó {check}, incidente cerrado", + "disabledTitle": "Se desactivó {check}", + "disabledPreheader": "Se desactivó {check}, por lo que su incidente se cerró.", + "disabledBody": "La comprobación se desactivó, por lo que este incidente se cerró. No necesariamente se recuperó.", "textTitle": "{check} se ha recuperado", "textBody": "El incidente de {check} se ha resuelto." }, diff --git a/server/internal/i18n/locales/fr/incident.json b/server/internal/i18n/locales/fr/incident.json index adb50c39c..489bd472e 100644 --- a/server/internal/i18n/locales/fr/incident.json +++ b/server/internal/i18n/locales/fr/incident.json @@ -20,6 +20,7 @@ "down": "En panne", "recovered": "Rétabli", "checkDeleted": "Contrôle supprimé", + "checkDisabled": "Contrôle désactivé", "escalated": "Escaladé", "reopened": "Rouvert", "comment": "Commentaire", @@ -94,6 +95,10 @@ "deletedTitle": "Le contrôle {check} a été supprimé", "deletedPreheader": "Le contrôle {check} a été supprimé, son incident a donc été clos.", "deletedBody": "Le contrôle a été supprimé, cet incident a donc été clos. Cela ne veut pas dire que le service est rétabli.", + "disabledSubject": "Le contrôle {check} a été désactivé, incident clos", + "disabledTitle": "Le contrôle {check} a été désactivé", + "disabledPreheader": "Le contrôle {check} a été désactivé, son incident a donc été clos.", + "disabledBody": "Le contrôle a été désactivé, cet incident a donc été clos. Il n'est pas forcément rétabli.", "textTitle": "Rétablissement de {check}", "textBody": "L'incident de {check} est résolu." }, diff --git a/server/internal/jobs/jobtypes/job_incident_resolution_notice.go b/server/internal/jobs/jobtypes/job_incident_resolution_notice.go index fd39807e2..c4faebfe1 100644 --- a/server/internal/jobs/jobtypes/job_incident_resolution_notice.go +++ b/server/internal/jobs/jobtypes/job_incident_resolution_notice.go @@ -202,6 +202,10 @@ func telegramResolvedDetail(incident *models.Incident) string { return "closed because the check was deleted" } + if incident.ResolutionType != nil && *incident.ResolutionType == models.ResolutionTypeCheckDisabled { + return "closed because the check was disabled" + } + if incident.ResolvedAt == nil { return "" } diff --git a/server/internal/mcp/handler.go b/server/internal/mcp/handler.go index 58e7b7a59..6cd3834ed 100644 --- a/server/internal/mcp/handler.go +++ b/server/internal/mcp/handler.go @@ -190,6 +190,7 @@ func NewHandler( // delete_check tells whoever was paged that the check's incident closed, // exactly as over the HTTP API (spec 2026-10-08-02). handler.checksSvc.SetCheckDeletedIncidentNotifier(incidentsSvc) + handler.checksSvc.SetCheckDisableIncidentResolver(incidentsSvc) if cfg != nil { handler.baseURL = cfg.Server.BaseURL diff --git a/server/internal/mcp/tools_checks.go b/server/internal/mcp/tools_checks.go index 3ba865ba8..89081dc1b 100644 --- a/server/internal/mcp/tools_checks.go +++ b/server/internal/mcp/tools_checks.go @@ -337,6 +337,9 @@ func updateCheckDef() ToolDefinition { schemaKeyEnabled: boolProp( "Toggle whether the check runs. Set to false to pause the check, true to resume it.", ), + "resolveOpenIncidents": boolProp( + "With enabled:false, also close the check's open incidents (resolution type check_disabled).", + ), schemaKeyPeriod: stringProp( "New check interval as HH:MM:SS, e.g. \"00:00:30\" for 30 seconds.", ), @@ -392,6 +395,7 @@ func (h *Handler) toolUpdateCheck(ctx context.Context, orgSlug string, args map[ req.RegionPool = &v } req.Enabled = getBoolArg(args, schemaKeyEnabled) + req.ResolveOpenIncidents = getBoolArg(args, "resolveOpenIncidents") if v := getStringArg(args, schemaKeyPeriod); v != "" { req.Period = &v } diff --git a/server/internal/notifications/check_deleted.go b/server/internal/notifications/check_deleted.go index af004cce5..a5956716b 100644 --- a/server/internal/notifications/check_deleted.go +++ b/server/internal/notifications/check_deleted.go @@ -9,8 +9,44 @@ const ( checkDeletedTag = "[CHECK DELETED] " recoveredTag = "[RECOVERED] " checkDeletedSentence = "The check was deleted, so this incident was closed. It did not necessarily recover." + + // Same idea for a check disabled by hand with resolveOpenIncidents (spec + // 2026-10-23-01). + checkDisabledTag = "[CHECK DISABLED] " + checkDisabledSentence = "The check was disabled, so this incident was closed. It did not necessarily recover." ) +// ResolvedByCheckDisable reports whether the incident was closed because its +// check was disabled. +func ResolvedByCheckDisable(incident *models.Incident) bool { + return incident != nil && incident.ResolutionType != nil && + *incident.ResolutionType == models.ResolutionTypeCheckDisabled +} + +// closedWithoutRecovery is true when the incident was closed because its check +// was deleted or disabled: the check did not necessarily recover. +func closedWithoutRecovery(incident *models.Incident) bool { + return ResolvedByCheckDeletion(incident) || ResolvedByCheckDisable(incident) +} + +// closedSentence is the one-line reason for a closedWithoutRecovery incident. +func closedSentence(incident *models.Incident) string { + if ResolvedByCheckDisable(incident) { + return checkDisabledSentence + } + + return checkDeletedSentence +} + +// closedTag is the title prefix for a closedWithoutRecovery incident. +func closedTag(incident *models.Incident) string { + if ResolvedByCheckDisable(incident) { + return checkDisabledTag + } + + return checkDeletedTag +} + // ResolvedByCheckDeletion reports whether the incident was closed because its // check was deleted. func ResolvedByCheckDeletion(incident *models.Incident) bool { @@ -21,9 +57,27 @@ func ResolvedByCheckDeletion(incident *models.Incident) bool { // resolvedTag is the title prefix of a resolved notification: "[RECOVERED] ", // or "[CHECK DELETED] " when the check was deleted rather than recovered. func resolvedTag(incident *models.Incident) string { - if ResolvedByCheckDeletion(incident) { - return checkDeletedTag + if closedWithoutRecovery(incident) { + return closedTag(incident) } return recoveredTag } + +// closedKind ("deleted" or "disabled") and closedPill ("Deleted" or +// "Disabled") pick the email i18n keys of a closedWithoutRecovery incident. +func closedKind(incident *models.Incident) string { + if ResolvedByCheckDisable(incident) { + return "disabled" + } + + return "deleted" +} + +func closedPill(incident *models.Incident) string { + if ResolvedByCheckDisable(incident) { + return "Disabled" + } + + return "Deleted" +} diff --git a/server/internal/notifications/check_disabled_test.go b/server/internal/notifications/check_disabled_test.go new file mode 100644 index 000000000..4a75d82b0 --- /dev/null +++ b/server/internal/notifications/check_disabled_test.go @@ -0,0 +1,26 @@ +package notifications + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/fclairamb/solidping/server/internal/db/models" +) + +func TestResolvedTagCheckDisabled(t *testing.T) { + t.Parallel() + r := require.New(t) + + disabled := models.ResolutionTypeCheckDisabled + incident := &models.Incident{ResolutionType: &disabled} + + r.True(ResolvedByCheckDisable(incident)) + r.False(ResolvedByCheckDeletion(incident)) + r.Equal(checkDisabledTag, resolvedTag(incident)) + r.Equal(checkDisabledSentence, closedSentence(incident)) + r.Equal("disabled", closedKind(incident)) + + auto := models.ResolutionTypeAuto + r.Equal(recoveredTag, resolvedTag(&models.Incident{ResolutionType: &auto})) +} diff --git a/server/internal/notifications/discord.go b/server/internal/notifications/discord.go index c70acafd0..81559b3b8 100644 --- a/server/internal/notifications/discord.go +++ b/server/internal/notifications/discord.go @@ -640,8 +640,8 @@ func (ds *DiscordSender) buildIncidentResolvedEmbed(payload *Payload) discord.Em fields = appendDiscordRegionField(fields, payload) description := "The incident has been automatically resolved." - if ResolvedByCheckDeletion(payload.Incident) { - description = checkDeletedSentence + if closedWithoutRecovery(payload.Incident) { + description = closedSentence(payload.Incident) } return discord.Embed{ @@ -809,6 +809,9 @@ func (ds *DiscordSender) buildResolvedUpdateMessage(payload *Payload) *discord.M if ResolvedByCheckDeletion(payload.Incident) { title = fmt.Sprintf("%sClosed %s incident: the check was deleted", incidentRefPrefix(payload.Incident), checkName) } + if ResolvedByCheckDisable(payload.Incident) { + title = fmt.Sprintf("%sClosed %s incident: the check was disabled", incidentRefPrefix(payload.Incident), checkName) + } return &discord.Message{ Embeds: []discord.Embed{{ diff --git a/server/internal/notifications/email.go b/server/internal/notifications/email.go index 3d08cb745..0a89f769f 100644 --- a/server/internal/notifications/email.go +++ b/server/internal/notifications/email.go @@ -662,7 +662,9 @@ func buildIncidentViewModel( "HasRelapses": payload.Incident.RelapseCount > 1, // a bool: the email job decodes numbers as float64 "DashboardURL": dashboardRootURL(payload.AppBaseURL), "DocsURL": docsURL(payload.AppBaseURL), - "CheckDeleted": ResolvedByCheckDeletion(payload.Incident), + "CheckDeleted": closedWithoutRecovery(payload.Incident), + "ClosedKind": closedKind(payload.Incident), + "ClosedPill": closedPill(payload.Incident), } // The cause and the probed target are what an on-call reader needs to diff --git a/server/internal/notifications/googlechat.go b/server/internal/notifications/googlechat.go index 762af145c..065d64db1 100644 --- a/server/internal/notifications/googlechat.go +++ b/server/internal/notifications/googlechat.go @@ -263,8 +263,8 @@ func (s *GoogleChatSender) buildWidgets(payload *Payload, checkName string) []go } func (s *GoogleChatSender) resolvedSubtitle(payload *Payload) string { - if ResolvedByCheckDeletion(payload.Incident) { - return checkDeletedSentence + if closedWithoutRecovery(payload.Incident) { + return closedSentence(payload.Incident) } if payload.Incident.ResolvedAt != nil { diff --git a/server/internal/notifications/gotify.go b/server/internal/notifications/gotify.go index a15c2a4bd..b1bbc5bda 100644 --- a/server/internal/notifications/gotify.go +++ b/server/internal/notifications/gotify.go @@ -222,8 +222,8 @@ func (s *GotifySender) buildDownBody(payload *Payload, checkName string) string func (s *GotifySender) buildResolvedBody(payload *Payload, checkName string) string { var builder strings.Builder - if ResolvedByCheckDeletion(payload.Incident) { - builder.WriteString(checkDeletedSentence + "\n") + if closedWithoutRecovery(payload.Incident) { + builder.WriteString(closedSentence(payload.Incident) + "\n") } fmt.Fprintf(&builder, "Check: %s (%s)\n", checkName, payload.Check.Type) diff --git a/server/internal/notifications/matrix.go b/server/internal/notifications/matrix.go index 71006c1a2..c6ab6202e 100644 --- a/server/internal/notifications/matrix.go +++ b/server/internal/notifications/matrix.go @@ -316,8 +316,8 @@ func (s *MatrixSender) downLines(payload *Payload, checkName string) []string { func (s *MatrixSender) resolvedLines(payload *Payload, checkName string) []string { lines := []string{fmt.Sprintf("Check: %s (%s)", checkName, payload.Check.Type)} - if ResolvedByCheckDeletion(payload.Incident) { - lines = append([]string{checkDeletedSentence}, lines...) + if closedWithoutRecovery(payload.Incident) { + lines = append([]string{closedSentence(payload.Incident)}, lines...) } if payload.Incident.ResolvedAt != nil { diff --git a/server/internal/notifications/mattermost.go b/server/internal/notifications/mattermost.go index 76ff18eb7..85efe9a61 100644 --- a/server/internal/notifications/mattermost.go +++ b/server/internal/notifications/mattermost.go @@ -277,8 +277,8 @@ func (s *MattermostSender) buildResolvedFields(payload *Payload, checkName strin {Short: true, Title: mmFieldCheck, Value: checkName}, } - if ResolvedByCheckDeletion(payload.Incident) { - fields = append(fields, mattermostField{Short: false, Title: "Reason", Value: checkDeletedSentence}) + if closedWithoutRecovery(payload.Incident) { + fields = append(fields, mattermostField{Short: false, Title: "Reason", Value: closedSentence(payload.Incident)}) } if payload.Incident.ResolvedAt != nil { diff --git a/server/internal/notifications/msteams.go b/server/internal/notifications/msteams.go index fa8bb9e68..6c71c5470 100644 --- a/server/internal/notifications/msteams.go +++ b/server/internal/notifications/msteams.go @@ -247,8 +247,8 @@ func (s *MSTeamsSender) buildFacts(payload *Payload, checkName string) []msTeams } if payload.EventType == eventTypeIncidentResolved { - if ResolvedByCheckDeletion(payload.Incident) { - facts = append(facts, msTeamsFact{Title: "Reason", Value: checkDeletedSentence}) + if closedWithoutRecovery(payload.Incident) { + facts = append(facts, msTeamsFact{Title: "Reason", Value: closedSentence(payload.Incident)}) } if payload.Incident.ResolvedAt != nil { diff --git a/server/internal/notifications/msteamsbot.go b/server/internal/notifications/msteamsbot.go index 64273be68..cacb17160 100644 --- a/server/internal/notifications/msteamsbot.go +++ b/server/internal/notifications/msteamsbot.go @@ -422,6 +422,10 @@ func (s *MSTeamsBotSender) titleAndColor(payload *Payload, checkName string) (st return "⚪ " + checkName + " was DELETED, incident closed", msteams.CardColorGood } + if ResolvedByCheckDisable(payload.Incident) { + return "⚪ " + checkName + " was DISABLED, incident closed", msteams.CardColorGood + } + return "🟢 " + checkName + " has RECOVERED", msteams.CardColorGood case eventTypeIncidentEscalated: return "⚠️ " + checkName + " ESCALATED", msteams.CardColorWarning diff --git a/server/internal/notifications/ntfy.go b/server/internal/notifications/ntfy.go index 152c598f2..43b06753f 100644 --- a/server/internal/notifications/ntfy.go +++ b/server/internal/notifications/ntfy.go @@ -230,8 +230,8 @@ func (s *NtfySender) buildDownBody(payload *Payload, checkName string) string { func (s *NtfySender) buildResolvedBody(payload *Payload, checkName string) string { var builder strings.Builder - if ResolvedByCheckDeletion(payload.Incident) { - builder.WriteString(checkDeletedSentence + "\n") + if closedWithoutRecovery(payload.Incident) { + builder.WriteString(closedSentence(payload.Incident) + "\n") } fmt.Fprintf(&builder, "Check: %s (%s)\n", checkName, payload.Check.Type) diff --git a/server/internal/notifications/pushover.go b/server/internal/notifications/pushover.go index 12d8c8460..945d32f9a 100644 --- a/server/internal/notifications/pushover.go +++ b/server/internal/notifications/pushover.go @@ -281,8 +281,8 @@ func (s *PushoverSender) buildDownBody(payload *Payload, checkName string) strin func (s *PushoverSender) buildResolvedBody(payload *Payload, checkName string) string { var builder strings.Builder - if ResolvedByCheckDeletion(payload.Incident) { - builder.WriteString(checkDeletedSentence + "\n") + if closedWithoutRecovery(payload.Incident) { + builder.WriteString(closedSentence(payload.Incident) + "\n") } fmt.Fprintf(&builder, "Check: %s (%s)\n", checkName, payload.Check.Type) diff --git a/server/internal/notifications/slack.go b/server/internal/notifications/slack.go index d8f0038ab..0b422c770 100644 --- a/server/internal/notifications/slack.go +++ b/server/internal/notifications/slack.go @@ -864,6 +864,14 @@ func (s *SlackSender) buildIncidentResolvedThreadReply(payload *Payload) *slack. )} } + if ResolvedByCheckDisable(payload.Incident) { + return &slack.MessageResponse{Text: fmt.Sprintf( + ":white_circle: %s%s was disabled after %s, so its incident was closed. "+ + "The check did not necessarily recover.", + refLink, nameLink, duration, + )} + } + text := fmt.Sprintf( ":large_green_circle: %s%s — incident resolved after %s.", refLink, nameLink, duration, @@ -1081,6 +1089,9 @@ func (s *SlackSender) buildResolvedUpdateMessage(payload *Payload) *slack.Messag if ResolvedByCheckDeletion(payload.Incident) { headline = fmt.Sprintf("Closed %s incident: the check was deleted", checkName) } + if ResolvedByCheckDisable(payload.Incident) { + headline = fmt.Sprintf("Closed %s incident: the check was disabled", checkName) + } fallbackText := headline diff --git a/server/internal/notifications/twilio.go b/server/internal/notifications/twilio.go index 653638fb7..067dbb5b3 100644 --- a/server/internal/notifications/twilio.go +++ b/server/internal/notifications/twilio.go @@ -116,6 +116,11 @@ func (s *TwilioSender) buildBody( twilio.OptOutFooter } + if ResolvedByCheckDisable(payload.Incident) { + return fmt.Sprintf("[%s] %s: %s was disabled, incident closed.", productName, org, checkName) + + twilio.OptOutFooter + } + return fmt.Sprintf("[%s] %s: %s RECOVERED.", productName, org, checkName) + twilioRegionSentence(payload) + twilio.OptOutFooter case eventTypeIncidentEscalated: diff --git a/server/internal/notifications/webhook.go b/server/internal/notifications/webhook.go index e0fcb9da1..479ab2a66 100644 --- a/server/internal/notifications/webhook.go +++ b/server/internal/notifications/webhook.go @@ -84,7 +84,7 @@ type WebhookIncident struct { RelapseCount int `json:"relapseCount"` RecoveryPeriodSeconds *int `json:"recoveryPeriodSeconds"` // ResolutionType says how a resolved incident closed (auto | manual | - // expired | escalated | disabled | check_deleted). Omitted while active. + // expired | escalated | disabled | check_deleted | check_disabled). Omitted while active. ResolutionType *string `json:"resolutionType,omitempty"` // Regions says which regions saw the failure. A snapshot taken when the // incident opened (or reopened) and refreshed on escalation. Omitted when diff --git a/server/internal/notifications/webpush.go b/server/internal/notifications/webpush.go index c8add8deb..e30fffbdb 100644 --- a/server/internal/notifications/webpush.go +++ b/server/internal/notifications/webpush.go @@ -185,8 +185,8 @@ func buildWebPushEventContent(payload *Payload, checkName string) (string, strin return ref + "[DOWN] " + checkName, fmt.Sprintf("%s is down. Cause: %s", checkName, getFailureReason(payload.Incident)) case eventTypeIncidentResolved: - if ResolvedByCheckDeletion(payload.Incident) { - return ref + checkDeletedTag + checkName, checkDeletedSentence + if closedWithoutRecovery(payload.Incident) { + return ref + closedTag(payload.Incident) + checkName, closedSentence(payload.Incident) } return ref + recoveredTag + checkName, diff --git a/specs/done/2026/10/2026-10-23-01-resolve-incidents-when-check-disabled.md b/specs/done/2026/10/2026-10-23-01-resolve-incidents-when-check-disabled.md new file mode 100644 index 000000000..d699cd104 --- /dev/null +++ b/specs/done/2026/10/2026-10-23-01-resolve-incidents-when-check-disabled.md @@ -0,0 +1,134 @@ +--- +model: sonnet +effort: medium +--- + +# Disabling a check leaves its open incidents open forever + +GitHub Issue: https://github.com/fclairamb/solidping/issues/647 + +## Source + +Issue #647 (filed 2026-10-23): when a check with an open incident is disabled by hand, the +incident stays open and keeps showing as ongoing on the dashboard, so the open-incident count +stops meaning anything. The reporter suggests offering to resolve the open incidents in the +same action: a confirmation in the UI ("This check has 1 open incident. Resolve it?", default +yes) and the same option on the API (`PATCH` with `enabled: false` plus +`resolveOpenIncidents: true`). The resolution must say the incident was closed because the +check was disabled, not because it recovered. Workaround: resolve each incident by hand. + +The report is right. Nothing closes the incident once the check stops running. + +## Problem + +- `UpdateCheck` (`server/internal/handlers/checks/service.go:2282`) copies `req.Enabled` + into the update, writes it, then only calls `resolveDegradedOnDisable` + (`service.go:2403`), which reacts to `degradedEnabled: false`, not to `enabled: false`. + `enabled: false` only feeds the job reconcile (`service.go:2410`), which removes the + check's jobs. +- With no jobs, no result ever arrives, so the adaptive auto-resolution in the incidents + service never runs for that check. An open outage or degraded incident stays + `state = active` indefinitely. +- Two precedents already close incidents on a lifecycle event, and give the shape to copy: + - `ResolveDegradedOnDisable` (`server/internal/handlers/incidents/degraded.go:299`) closes + the open degraded incident with `resolution_type = "disabled"` when degraded detection + is turned off, through the `DegradedIncidentResolver` hook wired into the checks service + (`server/internal/handlers/checks/degraded.go:144-175`). + - Check deletion resolves every active incident with `resolution_type = "check_deleted"` + (`models.ResolutionTypeCheckDeleted`, `server/internal/db/models/incident.go`), then + `NotifyCheckDeletedIncidents` (`server/internal/handlers/incidents/check_deleted.go`) + cancels pending escalation steps and emits one `incident.resolved` event per incident. +- The manual resolve path (`ResolveIncident`, `incidents/service.go:4009`) uses + `resolution_type = "manual"` and records `resolved_by`. Using it here would lose the + "closed because disabled" meaning the reporter asks for. +- The dashboard toggle (`handleToggleEnabled`, + `web/dash0/src/routes/orgs/$org/checks.$checkUid.index.tsx:1147`) sends + `{ enabled: next }` with no confirmation. + +## Why a spec + +- Needs a changed API endpoint: a new `resolveOpenIncidents` field on the check update + request, in `openapi.yaml` (`make generate`), and likely on `/apply`, import and the MCP + `update_check` tool, which all funnel through `UpdateCheck`. +- Needs a product decision the reporter did not settle: opt-in prompt versus always closing, + and which `resolution_type` to record (see Open questions). + +## Proposal + +### Backend + +1. Add `ResolveOpenIncidents *bool` (`resolveOpenIncidents`) to `UpdateCheckRequest` + (`checks/service.go:1991`) and to the OpenAPI schema of the check `PATCH`. Ignored unless + the same request sets `enabled: false`. A request with `resolveOpenIncidents: true` and no + `enabled: false` is a `VALIDATION_ERROR`. +2. Add a new resolution type `models.ResolutionTypeCheckDisabled = "check_disabled"` (see + Open question 2). No migration is needed: `resolution_type` is a free text column (verify + there is no CHECK constraint on either dialect; if there is one, this becomes a migration + in the next `NNN` file). +3. Add `ResolveOnCheckDisable(ctx, orgUID, checkUID string) ([]string, error)` to the + incidents service: resolve every active incident (outage and degraded) of the check with + `resolution_type = "check_disabled"`, `resolved_by` = the caller when known, cancel + pending escalation steps before emitting, emit one `incident.resolved` per incident with + `resolutionType` in the payload, and publish the live update. Reuse the + `NotifyCheckDeletedIncidents` sequence rather than duplicating it. +4. Wire it into `UpdateCheck` next to `resolveDegradedOnDisable`, through a small interface + on the checks service (same pattern as `DegradedIncidentResolver`), called only when + `enabled` is `false` and `resolveOpenIncidents` is `true`. Like the degraded hook, fire on + any write that sets both, not only on a true to false transition, so a retry after a + failed resolve still closes them. +5. Expose the open-incident count the UI needs: `GET` check already returns the check; add + `openIncidentCount` to the check response, or let the UI query + `GET /incidents?checkUid=&state=active&limit=1` (pick the one that needs no new + field if it gives the count). + +### Notifications and UI copy + +6. Resolved notifications (Slack, email, webhook, Telegram) read `resolutionType`: add the + `check_disabled` wording ("resolved: check disabled") wherever `check_deleted` has its own + wording today (`server/internal/notifications/check_deleted.go`, `webhook.go`, + `job_incident_resolution_notice.go`). +7. dash0: add `"check_disabled"` to the `resolutionType` union (`web/dash0/src/api/hooks.ts:748`) + and a label on the incident page next to the `check_deleted` one + (`incidents.$incidentUid.tsx:1224`). +8. dash0: when the user clicks Disable on a check with at least one active incident, open a + confirmation dialog: "This check has N open incident(s). Resolve them?", with a checkbox + checked by default, and send `{ enabled: false, resolveOpenIncidents: }`. With + no open incident, keep today's one-click toggle. `data-testid` on the dialog, checkbox and + buttons. i18n keys in every locale the checks namespace ships. + +### Docs and tests + +9. Docs: the check `PATCH` reference (generated) and the incidents page in `web/docs/` (how + incidents end, the new resolution type). CHANGELOG entry. +10. Go tests (SQLite, `make test`): disabling with `resolveOpenIncidents: true` resolves an + open outage and an open degraded incident with `check_disabled`; disabling without it + leaves them active (positive control for the absence); `resolveOpenIncidents: true` + without `enabled: false` is rejected; the resolved event is emitted once per incident. +11. Playwright: disable a check with an open incident, keep the checkbox, see the incident + resolved with the "check disabled" label. + +## Open questions + +1. **Opt-in or automatic?** Recommended: opt-in on the API (`resolveOpenIncidents`, default + false) and default-checked in the UI, as the reporter suggests. Trade-off: an API or + `/apply` user who disables a check still gets the stale incident unless they pass the flag; + always closing would be simpler but silently changes the meaning of `enabled: false` for + every existing script, and some users disable a check to stop the noise while the outage + is still being worked on. +2. **Which resolution type?** Recommended: a new `check_disabled`. Trade-off: `disabled` + already exists but means "degraded detection turned off" (`incident.go`), and reusing it + would make the history ambiguous; a new value means one more case in every notification + template and in the dash0 union. + +## Closing the issue + +This spec closes #647. The implementing PR body **must** carry one `Closes #647` line so the +squash-merge closes it. When the spec is archived to `specs/done/`, verify the issue is +closed and, if the merge did not close it, close it by hand: + + gh issue close 647 --comment "Implemented by ; spec: " + +## Resolved open questions + +1. Opt-in on the API (`resolveOpenIncidents`, default false), default-checked in the UI. (autopilot: recommended answer) +2. New resolution type `check_disabled`. (autopilot: recommended answer) diff --git a/web/dash0/e2e/check-disable-resolve-incidents.spec.ts b/web/dash0/e2e/check-disable-resolve-incidents.spec.ts new file mode 100644 index 000000000..7220d9a89 --- /dev/null +++ b/web/dash0/e2e/check-disable-resolve-incidents.spec.ts @@ -0,0 +1,105 @@ +import { + test, + expect, + API_BASE, + createHeartbeatCheck, + getAuthToken, + uniqueStamp, +} from "./fixtures"; + +// Spec 2026-10-23-01: disabling a check with an open incident offers to resolve +// it in the same action; the incident then ends as "check_disabled". + +test.describe("Disable a check with open incidents", () => { + test("keeping the checkbox resolves the incident with check_disabled", async ({ + authenticatedPage: page, + }) => { + const token = await getAuthToken(page); + const auth = { Authorization: `Bearer ${token}` }; + const check = await createHeartbeatCheck(page, token, `E2E Disable ${uniqueStamp()}`); + + try { + const beat = await page.request.get( + `${API_BASE}/api/v1/heartbeat/test/${check.uid}?token=${check.hbToken}&status=down`, + ); + expect(beat.status()).toBe(200); + + const listActive = async () => { + const resp = await page.request.get( + `${API_BASE}/api/v1/orgs/test/incidents?checkUid=${check.uid}&state=active`, + { headers: auth }, + ); + return (await resp.json()).data ?? []; + }; + await expect.poll(async () => (await listActive()).length, { timeout: 15_000 }).toBe(1); + + await page.goto(`/d/orgs/test/checks/${check.uid}`); + await page.getByTestId("check-toggle-enabled").click(); + + await expect(page.getByTestId("disable-check-dialog")).toBeVisible(); + await expect(page.getByTestId("disable-check-resolve-checkbox")).toBeChecked(); + await page.getByTestId("disable-check-confirm").click(); + + await expect.poll(async () => (await listActive()).length, { timeout: 10_000 }).toBe(0); + + const resolved = await page.request.get( + `${API_BASE}/api/v1/orgs/test/incidents?checkUid=${check.uid}&state=resolved`, + { headers: auth }, + ); + const rows = (await resolved.json()).data; + expect(rows).toHaveLength(1); + expect(rows[0].resolutionType).toBe("check_disabled"); + + await page.goto(`/d/orgs/test/incidents/${rows[0].uid}`); + await expect(page.getByTestId("incident-timeline-resolved-reason")).toContainText( + /disabled/i, + ); + } finally { + await page.request.delete(`${API_BASE}/api/v1/orgs/test/checks/${check.uid}`, { + headers: auth, + }); + } + }); + + test("unchecking the box disables the check and leaves the incident open", async ({ + authenticatedPage: page, + }) => { + const token = await getAuthToken(page); + const auth = { Authorization: `Bearer ${token}` }; + const check = await createHeartbeatCheck(page, token, `E2E Disable Keep ${uniqueStamp()}`); + + try { + await page.request.get( + `${API_BASE}/api/v1/heartbeat/test/${check.uid}?token=${check.hbToken}&status=down`, + ); + const listActive = async () => { + const resp = await page.request.get( + `${API_BASE}/api/v1/orgs/test/incidents?checkUid=${check.uid}&state=active`, + { headers: auth }, + ); + return (await resp.json()).data ?? []; + }; + await expect.poll(async () => (await listActive()).length, { timeout: 15_000 }).toBe(1); + + await page.goto(`/d/orgs/test/checks/${check.uid}`); + await page.getByTestId("check-toggle-enabled").click(); + await page.getByTestId("disable-check-resolve-checkbox").click(); + await page.getByTestId("disable-check-confirm").click(); + + await expect + .poll(async () => { + const resp = await page.request.get( + `${API_BASE}/api/v1/orgs/test/checks/${check.uid}`, + { headers: auth }, + ); + return (await resp.json()).enabled; + }) + .toBe(false); + expect(await listActive()).toHaveLength(1); + } finally { + await page.request.delete(`${API_BASE}/api/v1/orgs/test/checks/${check.uid}`, { + headers: auth, + }); + } + }); +}); diff --git a/web/dash0/src/api/hooks.ts b/web/dash0/src/api/hooks.ts index 17102bd4e..4491d05e3 100644 --- a/web/dash0/src/api/hooks.ts +++ b/web/dash0/src/api/hooks.ts @@ -455,6 +455,9 @@ export interface CreateCheckRequest { } export interface UpdateCheckRequest { + /** With `enabled: false`, also close the check's open incidents (spec + * 2026-10-23-01). */ + resolveOpenIncidents?: boolean; /** Per-check path-trace policy: `inherit`, `on` or `off` (spec * 2026-08-21-10). `inherit` is what puts a check back under the org default; * omitting the field leaves it unchanged. */ @@ -745,7 +748,8 @@ export interface IncidentDetail { | "expired" | "escalated" | "disabled" - | "check_deleted"; + | "check_deleted" + | "check_disabled"; failureCount?: number; relapseCount?: number; /** @@ -1030,6 +1034,7 @@ export function useUpdateCheck(org: string, uid: string) { queryClient.invalidateQueries({ queryKey: ["checks", org] }); queryClient.invalidateQueries({ queryKey: ["checks", "infinite", org] }); queryClient.invalidateQueries({ queryKey: ["check", org, uid] }); + queryClient.invalidateQueries({ queryKey: ["incidents", org] }); }, }); } diff --git a/web/dash0/src/locales/de/checks.json b/web/dash0/src/locales/de/checks.json index 07aa23852..484010a1d 100644 --- a/web/dash0/src/locales/de/checks.json +++ b/web/dash0/src/locales/de/checks.json @@ -755,6 +755,13 @@ "disable": "Deaktivieren", "enableToast": "Prüfung aktiviert", "disableToast": "Prüfung deaktiviert", + "disableDialog": { + "title": "Diese Prüfung deaktivieren?", + "description_one": "Diese Prüfung hat {{count}} offenen Vorfall.", + "description_other": "Diese Prüfung hat {{count}} offene Vorfälle.", + "resolve_one": "Offenen Vorfall ebenfalls lösen", + "resolve_other": "Offene Vorfälle ebenfalls lösen" + }, "toggleFailed": "Aktualisieren der Prüfung fehlgeschlagen", "descriptionLabel": "Beschreibung", "typeLabel": "Typ", diff --git a/web/dash0/src/locales/de/incidents.json b/web/dash0/src/locales/de/incidents.json index 2a59d0fbc..518329cae 100644 --- a/web/dash0/src/locales/de/incidents.json +++ b/web/dash0/src/locales/de/incidents.json @@ -193,6 +193,7 @@ "reopenedRelapse": "Wieder geöffnet (Rückfall Nr. {{count}})", "resolved": "Aufgelöst", "resolvedCheckDeleted": "Geschlossen, weil die Prüfung gelöscht wurde", + "resolvedCheckDisabled": "Geschlossen, weil die Prüfung deaktiviert wurde", "monitoringInterrupted": "Überwachung unterbrochen", "monitoringInterruptedSince": "Überwachung unterbrochen seit {{time}}", "monitoringResumed": "Überwachung fortgesetzt", diff --git a/web/dash0/src/locales/en/checks.json b/web/dash0/src/locales/en/checks.json index ec45eb074..e31db950c 100644 --- a/web/dash0/src/locales/en/checks.json +++ b/web/dash0/src/locales/en/checks.json @@ -799,6 +799,13 @@ "disable": "Disable", "enableToast": "Check enabled", "disableToast": "Check disabled", + "disableDialog": { + "title": "Disable this check?", + "description_one": "This check has {{count}} open incident.", + "description_other": "This check has {{count}} open incidents.", + "resolve_one": "Also resolve the open incident", + "resolve_other": "Also resolve the open incidents" + }, "toggleFailed": "Failed to update check", "descriptionLabel": "Description", "typeLabel": "Type", diff --git a/web/dash0/src/locales/en/incidents.json b/web/dash0/src/locales/en/incidents.json index afaccf02b..ca35c6dc1 100644 --- a/web/dash0/src/locales/en/incidents.json +++ b/web/dash0/src/locales/en/incidents.json @@ -193,6 +193,7 @@ "reopenedRelapse": "Reopened (relapse #{{count}})", "resolved": "Resolved", "resolvedCheckDeleted": "Closed because the check was deleted", + "resolvedCheckDisabled": "Closed because the check was disabled", "monitoringInterrupted": "Monitoring interrupted", "monitoringInterruptedSince": "Monitoring interrupted since {{time}}", "monitoringResumed": "Monitoring resumed", diff --git a/web/dash0/src/locales/es/checks.json b/web/dash0/src/locales/es/checks.json index 1a8bb82f5..d8e5f34ab 100644 --- a/web/dash0/src/locales/es/checks.json +++ b/web/dash0/src/locales/es/checks.json @@ -755,6 +755,13 @@ "disable": "Desactivar", "enableToast": "Comprobación activada", "disableToast": "Comprobación desactivada", + "disableDialog": { + "title": "¿Desactivar esta comprobación?", + "description_one": "Esta comprobación tiene {{count}} incidente abierto.", + "description_other": "Esta comprobación tiene {{count}} incidentes abiertos.", + "resolve_one": "Resolver también el incidente abierto", + "resolve_other": "Resolver también los incidentes abiertos" + }, "toggleFailed": "No se pudo actualizar la comprobación", "descriptionLabel": "Descripción", "typeLabel": "Tipo", diff --git a/web/dash0/src/locales/es/incidents.json b/web/dash0/src/locales/es/incidents.json index 8063cac4e..4c336bf8d 100644 --- a/web/dash0/src/locales/es/incidents.json +++ b/web/dash0/src/locales/es/incidents.json @@ -193,6 +193,7 @@ "reopenedRelapse": "Reabierto (recaída n.º {{count}})", "resolved": "Resuelto", "resolvedCheckDeleted": "Cerrado porque se eliminó la comprobación", + "resolvedCheckDisabled": "Cerrado porque se desactivó la comprobación", "monitoringInterrupted": "Monitoreo interrumpido", "monitoringInterruptedSince": "Monitoreo interrumpido desde las {{time}}", "monitoringResumed": "Monitoreo reanudado", diff --git a/web/dash0/src/locales/fr/checks.json b/web/dash0/src/locales/fr/checks.json index b8e95fcd1..ee576e8d8 100644 --- a/web/dash0/src/locales/fr/checks.json +++ b/web/dash0/src/locales/fr/checks.json @@ -799,6 +799,13 @@ "disable": "Désactiver", "enableToast": "Contrôle activé", "disableToast": "Contrôle désactivé", + "disableDialog": { + "title": "Désactiver ce contrôle ?", + "description_one": "Ce contrôle a {{count}} incident ouvert.", + "description_other": "Ce contrôle a {{count}} incidents ouverts.", + "resolve_one": "Résoudre aussi l'incident ouvert", + "resolve_other": "Résoudre aussi les incidents ouverts" + }, "toggleFailed": "Échec de la mise à jour du contrôle", "descriptionLabel": "Description", "typeLabel": "Type", diff --git a/web/dash0/src/locales/fr/incidents.json b/web/dash0/src/locales/fr/incidents.json index f3c79d512..8a8d1a630 100644 --- a/web/dash0/src/locales/fr/incidents.json +++ b/web/dash0/src/locales/fr/incidents.json @@ -193,6 +193,7 @@ "reopenedRelapse": "Rouvert (rechute n°{{count}})", "resolved": "Résolu", "resolvedCheckDeleted": "Clôturé car le contrôle a été supprimé", + "resolvedCheckDisabled": "Clôturé car le contrôle a été désactivé", "monitoringInterrupted": "Surveillance interrompue", "monitoringInterruptedSince": "Surveillance interrompue depuis {{time}}", "monitoringResumed": "Surveillance reprise", diff --git a/web/dash0/src/routes/orgs/$org/checks.$checkUid.index.tsx b/web/dash0/src/routes/orgs/$org/checks.$checkUid.index.tsx index d98317b4b..0964af5ce 100644 --- a/web/dash0/src/routes/orgs/$org/checks.$checkUid.index.tsx +++ b/web/dash0/src/routes/orgs/$org/checks.$checkUid.index.tsx @@ -78,6 +78,7 @@ import { Switch } from "@/components/ui/switch"; import { CollapsibleCode } from "@/components/shared/copyable-code"; import { CheckRegionOutageBanner } from "@/components/shared/region-outage-banner"; import { CollapsibleSection } from "@/components/ui/collapsible-section"; +import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { Card, @@ -727,6 +728,8 @@ function CheckDetailPage() { const navigate = useNavigate(); const router = useRouter(); const { user: authUser } = useAuth(); + const [disableDialogOpen, setDisableDialogOpen] = useState(false); + const [resolveOnDisable, setResolveOnDisable] = useState(true); const [deleteOpen, setDeleteOpen] = useState(false); const [publishOpen, setPublishOpen] = useState(publish === true); const [editingSlug, setEditingSlug] = useState(false); @@ -1144,11 +1147,33 @@ function CheckDetailPage() { } }; - const handleToggleEnabled = async () => { + const { data: activeIncidents } = useIncidents(org, { + checkUid: check?.uid, + state: "active", + size: 100, + enabled: !!check?.uid, + }); + const openIncidentCount = activeIncidents?.data?.length ?? 0; + + // Disabling a check with open incidents asks whether to close them too: + // a disabled check has no jobs, so nothing else ever would. + const handleDisableClick = () => { + if (check?.enabled && openIncidentCount > 0) { + setResolveOnDisable(true); + setDisableDialogOpen(true); + return; + } + handleToggleEnabled(); + }; + + const handleToggleEnabled = async (resolveOpenIncidents?: boolean) => { if (!check) return; const next = !check.enabled; try { - await updateCheck.mutateAsync({ enabled: next }); + await updateCheck.mutateAsync({ + enabled: next, + ...(resolveOpenIncidents ? { resolveOpenIncidents: true } : {}), + }); toast.success( next ? (t("checks:detail.enableToast") ?? "Check enabled") @@ -1432,7 +1457,8 @@ function CheckDetailPage() { : (t("checks:detail.enable") ?? "Enable") } disabled={updateCheck.isPending} - onClick={handleToggleEnabled} + onClick={handleDisableClick} + data-testid="check-toggle-enabled" > @@ -1441,6 +1467,47 @@ function CheckDetailPage() { : t("checks:detail.enable")} + + + + + {t("checks:detail.disableDialog.title")} + + + {t("checks:detail.disableDialog.description", { + count: openIncidentCount, + })} + + +
+ setResolveOnDisable(v === true)} + data-testid="disable-check-resolve-checkbox" + /> + +
+ + + {t("common:cancel")} + + handleToggleEnabled(resolveOnDisable)} + data-testid="disable-check-confirm" + > + {t("checks:detail.disable")} + + +
+