diff --git a/.github/workflows/spec-check.yml b/.github/workflows/spec-check.yml index 88e66d1..ecbc247 100644 --- a/.github/workflows/spec-check.yml +++ b/.github/workflows/spec-check.yml @@ -31,10 +31,13 @@ jobs: - run: npm run build - run: npm test + - name: Regenerate types from spec + run: npm run typegen + - name: Check for type changes id: diff run: | - if git diff --quiet src/lib/api.generated.ts docs/openapi/monitoring-api.json; then + if git diff --quiet src/lib/api.generated.ts docs/openapi/; then echo "changed=false" >> "$GITHUB_OUTPUT" else echo "changed=true" >> "$GITHUB_OUTPUT" diff --git a/docs/openapi/monitoring-api.json b/docs/openapi/monitoring-api.json index b35ca5d..0a78680 100644 --- a/docs/openapi/monitoring-api.json +++ b/docs/openapi/monitoring-api.json @@ -1 +1 @@ -{"openapi":"3.0.1","info":{"title":"DevHelm API","description":"DevHelm platform and public API","version":"1.0"},"servers":[{"url":"http://localhost:8081","description":"Generated server url"}],"tags":[{"name":"Heartbeat","description":"Public ping endpoint for heartbeat monitors"},{"name":"Invites","description":"Organization invite management"},{"name":"Onboarding","description":"User onboarding flow"},{"name":"Members","description":"Organization member management"},{"name":"Me","description":"Current user profile and organizations"},{"name":"Incidents","description":"Incident management and lifecycle"},{"name":"Maintenance Windows","description":"Schedule alert-suppression windows for monitors"},{"name":"Organizations","description":"Organization management"},{"name":"Integrations","description":"Static catalog of supported alert channel integrations"},{"name":"Incident Policies","description":"Manage trigger, confirmation, and recovery rules for monitors"},{"name":"Entitlements","description":"Plan entitlements and usage limits"},{"name":"Vault","description":"Organization vault management (admin-only)"},{"name":"Secrets","description":"Organization environment secret management"},{"name":"Transactions","description":"Subscription transaction history"},{"name":"Monitors","description":"Monitor CRUD and lifecycle management"},{"name":"Webhooks","description":"Webhook endpoint management, event catalog, and delivery history"},{"name":"Events","description":"Real-time event stream"},{"name":"Workspaces","description":"Workspace management within an organization"},{"name":"Notifications","description":"In-app notification center"},{"name":"Alert Channels","description":"Alert channel CRUD and connectivity testing"},{"name":"Subscriptions","description":"Organization subscription management"},{"name":"Service Subscriptions","description":"Manage which services an organization tracks"},{"name":"Tags","description":"Org-scoped tag management for monitors"},{"name":"Status Data","description":"Public service status catalog, components, uptime, and incident history"},{"name":"Check Results","description":"Query raw check results, uptime statistics, and summary data"},{"name":"API Keys","description":"Organization API key management"},{"name":"Dashboard","description":"Overview dashboard aggregates"},{"name":"Auth","description":"User registration"},{"name":"Monitor Auth","description":"Manage authentication configuration for a monitor"},{"name":"Audit Log","description":"Organization audit trail"},{"name":"Monitor Alert Channels","description":"Manage alert channel mappings for a monitor"},{"name":"Alert Deliveries","description":"Delivery audit trail: inspect per-attempt details for alert deliveries"},{"name":"Resource Groups","description":"Resource group CRUD and member management"},{"name":"Notification Policies","description":"Org-level notification routing policies with JSONB match rules"},{"name":"Notification Dispatches","description":"Dispatch debugging API: inspect which policies matched an incident and track delivery status"},{"name":"Environments","description":"Variable namespace management for monitors"},{"name":"Monitor Assertions","description":"Manage assertions for a monitor"},{"name":"Billing","description":"Billing plans and pricing"}],"paths":{"/platform/orgs/{orgId}/subscriptions/{subscriptionId}":{"put":{"tags":["Subscriptions"],"summary":"Update subscription","operationId":"updateSubscription","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"subscriptionId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSubscriptionRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseSubscriptionDto"}}}}}}},"/platform/onboarding/orgs/{orgId}/details":{"put":{"tags":["Onboarding"],"summary":"Update organization details","operationId":"updateOrgDetails","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateOrgDetailsRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseOrganizationDto"}}}}}}},"/platform/onboarding/advance":{"put":{"tags":["Onboarding"],"summary":"Advance onboarding stage forward","operationId":"advanceStage","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateOnboardingStageRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUserDto"}}}}}}},"/platform/me":{"get":{"tags":["Me"],"summary":"Get current user","operationId":"me","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUserDto"}}}}}},"put":{"tags":["Me"],"summary":"Update current user profile","operationId":"updateProfile","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProfileRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUserDto"}}}}}}},"/platform/me/notification-preferences":{"get":{"tags":["Me"],"summary":"Get current user's notification preferences","operationId":"getNotificationPreferences","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseNotificationPreferencesDto"}}}}}},"put":{"tags":["Me"],"summary":"Update current user's notification preferences","operationId":"updateNotificationPreferences","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateNotificationPreferencesRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseNotificationPreferencesDto"}}}}}}},"/platform/admin/workspaces/{workspaceId}":{"get":{"tags":["admin-workspace-controller"],"operationId":"getWorkspace","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"workspaceId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWorkspaceDto"}}}}}},"put":{"tags":["admin-workspace-controller"],"operationId":"updateWorkspace","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"workspaceId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWorkspaceDto"}}}}}},"delete":{"tags":["admin-workspace-controller"],"operationId":"deleteWorkspace","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"workspaceId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}}},"/platform/admin/users/{userId}":{"put":{"tags":["admin-controller"],"operationId":"updateUser","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUserDto"}}}}}}},"/platform/admin/orgs/{orgId}":{"put":{"tags":["admin-controller"],"operationId":"updateOrganization","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateOrgDetailsRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseOrganizationDto"}}}}}}},"/platform/admin/orgs/{orgId}/members/{userId}/role":{"put":{"tags":["admin-member-controller"],"operationId":"updateMemberRole","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRoleRequest"}}},"required":true},"responses":{"204":{"description":"No Content"}}}},"/api/v1/workspaces/{workspaceId}":{"get":{"tags":["Workspaces"],"summary":"Get workspace by ID","operationId":"get","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"workspaceId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWorkspaceDto"}}}}}},"put":{"tags":["Workspaces"],"summary":"Update workspace","operationId":"update","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"workspaceId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWorkspaceDto"}}}}}},"delete":{"tags":["Workspaces"],"summary":"Delete workspace","operationId":"delete","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"workspaceId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/webhooks/{id}":{"get":{"tags":["Webhooks"],"summary":"Get a single webhook endpoint","operationId":"get_1","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWebhookEndpointDto"}}}}}},"put":{"tags":["Webhooks"],"summary":"Update a webhook endpoint","operationId":"update_1","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWebhookEndpointRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWebhookEndpointDto"}}}}}},"delete":{"tags":["Webhooks"],"summary":"Delete a webhook endpoint","operationId":"delete_1","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/tags/{id}":{"put":{"tags":["Tags"],"summary":"Update a tag's name and/or color","operationId":"update_2","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTagRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseTagDto"}}}}}},"delete":{"tags":["Tags"],"summary":"Delete a tag (cascades to all monitor associations)","operationId":"delete_2","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/secrets/{key}":{"put":{"tags":["Secrets"],"summary":"Update secret","operationId":"update_3","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"key","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSecretRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseSecretDto"}}}}}},"delete":{"tags":["Secrets"],"summary":"Delete secret","operationId":"delete_3","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"key","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/resource-groups/{id}":{"get":{"tags":["Resource Groups"],"summary":"Get a resource group by id with member statuses and inherited settings","description":"Pass includeMetrics=true to enrich each member with 24h uptime, chart data, and latency metrics.","operationId":"get_2","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"includeMetrics","in":"query","required":false,"schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseResourceGroupDto"}}}}}},"put":{"tags":["Resource Groups"],"summary":"Update a resource group's name, description, alert policy, inherited settings, and health threshold","operationId":"update_4","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateResourceGroupRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseResourceGroupDto"}}}}}},"delete":{"tags":["Resource Groups"],"summary":"Delete a resource group (cascades to member rows)","operationId":"delete_4","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/org":{"get":{"tags":["Organizations"],"summary":"Get the current organization","operationId":"get_3","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseOrganizationDto"}}}}}},"put":{"tags":["Organizations"],"summary":"Update the current organization","operationId":"update_5","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateOrgDetailsRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseOrganizationDto"}}}}}}},"/api/v1/notifications/{id}/read":{"put":{"tags":["Notifications"],"summary":"Mark a notification as read","operationId":"markRead","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/notifications/read-all":{"put":{"tags":["Notifications"],"summary":"Mark all notifications as read","operationId":"markAllRead","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/notification-policies/{id}":{"get":{"tags":["Notification Policies"],"summary":"Get a notification policy by ID","operationId":"getById","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseNotificationPolicyDto"}}}}}},"put":{"tags":["Notification Policies"],"summary":"Update a notification policy","operationId":"update_6","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateNotificationPolicyRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseNotificationPolicyDto"}}}}}},"delete":{"tags":["Notification Policies"],"summary":"Delete a notification policy","operationId":"delete_5","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/monitors/{monitorId}/policy":{"get":{"tags":["Incident Policies"],"summary":"Get incident policy for a monitor","description":"Returns the trigger rules, confirmation settings, and recovery settings for the given monitor.","operationId":"get_4","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"monitorId","in":"path","description":"Monitor UUID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Policy found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IncidentPolicyDto"}}}},"404":{"description":"Monitor or policy not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentPolicyDto"}}}}}},"put":{"tags":["Incident Policies"],"summary":"Update incident policy for a monitor","description":"Replaces the trigger rules, confirmation settings, and recovery settings. All fields are validated before saving.","operationId":"update_7","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"monitorId","in":"path","description":"Monitor UUID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateIncidentPolicyRequest"}}},"required":true},"responses":{"200":{"description":"Policy updated","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IncidentPolicyDto"}}}},"400":{"description":"Validation error in JSONB shape","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentPolicyDto"}}}},"404":{"description":"Monitor or policy not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentPolicyDto"}}}}}}},"/api/v1/monitors/{monitorId}/auth":{"put":{"tags":["Monitor Auth"],"summary":"Update authentication config for a monitor","operationId":"update_8","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"monitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMonitorAuthRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorAuthDto"}}}}}},"post":{"tags":["Monitor Auth"],"summary":"Set authentication config for a monitor","operationId":"set","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"monitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetMonitorAuthRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorAuthDto"}}}}}},"delete":{"tags":["Monitor Auth"],"summary":"Remove authentication config from a monitor","operationId":"remove","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"monitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/monitors/{monitorId}/assertions/{assertionId}":{"put":{"tags":["Monitor Assertions"],"summary":"Update an assertion on a monitor","operationId":"update_9","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"monitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"assertionId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAssertionRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorAssertionDto"}}}}}},"delete":{"tags":["Monitor Assertions"],"summary":"Remove an assertion from a monitor","operationId":"remove_1","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"monitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"assertionId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/monitors/{monitorId}/alert-channels":{"put":{"tags":["Monitor Alert Channels"],"summary":"Replace the linked alert channel set for a monitor","operationId":"setChannels","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"monitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetAlertChannelsRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseListUUID"}}}}}}},"/api/v1/monitors/{id}":{"get":{"tags":["Monitors"],"summary":"Get a single monitor by id","operationId":"get_5","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorDto"}}}}}},"put":{"tags":["Monitors"],"summary":"Update a monitor","operationId":"update_10","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMonitorRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorDto"}}}}}},"delete":{"tags":["Monitors"],"summary":"Soft-delete a monitor","operationId":"delete_6","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/members/{userId}/status":{"put":{"tags":["Members"],"summary":"Change member status","operationId":"changeStatus","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeStatusRequest"}}},"required":true},"responses":{"204":{"description":"No Content"}}}},"/api/v1/members/{userId}/role":{"put":{"tags":["Members"],"summary":"Change member role","operationId":"changeRole","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRoleRequest"}}},"required":true},"responses":{"204":{"description":"No Content"}}}},"/api/v1/maintenance-windows/{id}":{"get":{"tags":["Maintenance Windows"],"summary":"Get a single maintenance window by ID","operationId":"getById_1","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMaintenanceWindowDto"}}}}}},"put":{"tags":["Maintenance Windows"],"summary":"Update a maintenance window","operationId":"update_11","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMaintenanceWindowRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMaintenanceWindowDto"}}}}}},"delete":{"tags":["Maintenance Windows"],"summary":"Delete a maintenance window","operationId":"delete_7","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/environments/{slug}":{"get":{"tags":["Environments"],"summary":"Get environment by slug","operationId":"get_6","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseEnvironmentDto"}}}}}},"put":{"tags":["Environments"],"summary":"Update environment","operationId":"update_12","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEnvironmentRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseEnvironmentDto"}}}}}},"delete":{"tags":["Environments"],"summary":"Delete environment","operationId":"delete_8","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/alert-channels/{id}":{"put":{"tags":["Alert Channels"],"summary":"Update an alert channel's name and re-encrypt config","operationId":"update_13","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAlertChannelRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAlertChannelDto"}}}}}},"delete":{"tags":["Alert Channels"],"summary":"Soft-delete an alert channel and return affected policy summary","operationId":"delete_9","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/DeleteChannelResult"}}}}}}},"/v1/webhooks/paddle":{"post":{"tags":["paddle-webhook-controller"],"operationId":"handleWebhook","parameters":[{"name":"paddle-signature","in":"header","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/v1/internal/workspaces":{"post":{"tags":["workspaces-controller"],"operationId":"create","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceCreateParams"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWorkspaceDto"}}}}}}},"/v1/internal/service-incidents":{"post":{"tags":["service-incident-internal-controller"],"operationId":"createOrResolve","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceIncidentRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultIncidentDto"}}}}}}},"/v1/internal/resource-groups/services/{serviceId}/re-evaluate-health":{"post":{"tags":["resource-groups-internal-controller"],"operationId":"reEvaluateGroupHealthForService","parameters":[{"name":"serviceId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseInteger"}}}}}}},"/v1/internal/resource-groups/monitors/{monitorId}/re-evaluate-health":{"post":{"tags":["resource-groups-internal-controller"],"operationId":"reEvaluateGroupHealthForMonitor","parameters":[{"name":"monitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseInteger"}}}}}}},"/v1/internal/incidents":{"post":{"tags":["incidents-internal-controller"],"operationId":"createAutoIncident","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAutoIncidentRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentDto"}}}}}}},"/v1/internal/incidents/{id}/resolve":{"post":{"tags":["incidents-internal-controller"],"operationId":"resolveAutoIncident","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentDto"}}}}}}},"/v1/internal/incidents/{id}/reopen":{"post":{"tags":["incidents-internal-controller"],"operationId":"reopenAutoIncident","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReopenAutoIncidentRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentDto"}}}}}}},"/v1/internal/escalation-tick":{"post":{"tags":["escalation-internal-controller"],"operationId":"runEscalationTick","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseInteger"}}}}}}},"/v1/internal/billing/sync":{"post":{"tags":["admin-billing-controller"],"operationId":"syncFromPaddle","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseInteger"}}}}}}},"/v1/internal/adapters/health":{"get":{"tags":["adapter-health-internal-controller"],"operationId":"getAllHealth","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultAdapterHealthDto"}}}}}},"post":{"tags":["adapter-health-internal-controller"],"operationId":"reportOutcome","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdapterHealthReportRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAdapterHealthDto"}}}}}}},"/platform/orgs":{"post":{"tags":["Organizations"],"summary":"Create organization","operationId":"create_1","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"ifNotExists","in":"query","required":false,"schema":{"type":"boolean","default":false}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrgRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseOrganizationDto"}}}}}}},"/platform/orgs/{orgId}/transactions":{"get":{"tags":["Transactions"],"summary":"List transactions","operationId":"list","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"limit","in":"query","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer","format":"int32","default":10}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultTransactionDto"}}}}}},"post":{"tags":["Transactions"],"summary":"Create subscription transaction","operationId":"createSubscriptionTransaction","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSubscriptionRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseTransactionDto"}}}}}}},"/platform/onboarding/quick-monitor":{"post":{"tags":["Onboarding"],"summary":"Create a monitor with smart defaults from URL analysis","operationId":"quickMonitor","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuickMonitorRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorDto"}}}}}}},"/platform/onboarding/complete-setup":{"post":{"tags":["Onboarding"],"summary":"Complete onboarding setup (creates org + workspace, advances to FIRST_MONITOR)","operationId":"completeSetup","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OnboardingSetupRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUserDto"}}}}}}},"/platform/onboarding/analyze-url":{"post":{"tags":["Onboarding"],"summary":"Analyze a URL and return suggested monitor configuration","operationId":"analyzeUrl","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeUrlRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAnalyzeUrlResponse"}}}}}}},"/platform/invites/accept":{"post":{"tags":["Invites"],"summary":"Accept invite","operationId":"accept","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AcceptInviteRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAcceptInviteDto"}}}}}}},"/platform/auth/register":{"post":{"tags":["Auth"],"summary":"Register user","operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterUserRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUserDto"}}}}}}},"/platform/admin/orgs/{orgId}/workspaces":{"get":{"tags":["admin-workspace-controller"],"operationId":"listWorkspaces","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultWorkspaceDto"}}}}}},"post":{"tags":["admin-workspace-controller"],"operationId":"createWorkspace","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWorkspaceDto"}}}}}}},"/platform/admin/orgs/{orgId}/members":{"get":{"tags":["admin-member-controller"],"operationId":"listMembers","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultMemberDto"}}}}}},"post":{"tags":["admin-member-controller"],"operationId":"addMember","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddMemberRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMemberDto"}}}}}}},"/platform/admin/adapters/{serviceId}/enable":{"post":{"tags":["admin-adapter-health-controller"],"operationId":"reEnableAdapter","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"serviceId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAdapterHealthDto"}}}}}}},"/api/v1/workspaces":{"get":{"tags":["Workspaces"],"summary":"List workspaces","operationId":"list_1","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultWorkspaceDto"}}}}}},"post":{"tags":["Workspaces"],"summary":"Create workspace","operationId":"create_2","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWorkspaceDto"}}}}}}},"/api/v1/webhooks":{"get":{"tags":["Webhooks"],"summary":"List webhook endpoints for the authenticated org","operationId":"list_2","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultWebhookEndpointDto"}}}}}},"post":{"tags":["Webhooks"],"summary":"Register a new webhook endpoint","operationId":"create_3","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookEndpointRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWebhookEndpointDto"}}}}}}},"/api/v1/webhooks/{id}/test":{"post":{"tags":["Webhooks"],"summary":"Send a test delivery to a webhook endpoint","operationId":"test","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestWebhookEndpointRequest"}}}},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWebhookTestResult"}}}}}}},"/api/v1/webhooks/signing-secret/rotate":{"post":{"tags":["Webhooks"],"summary":"Generate or rotate the organization webhook signing secret","operationId":"rotateSigningSecret","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseString"}}}}}}},"/api/v1/vaults/rotate":{"post":{"tags":["Vault"],"summary":"Rotate DEK","description":"Generates a new Data Encryption Key, re-encrypts all secrets and alert-channel configs, and bumps the vault version. Admin-only. Pipeline DEK caches expire within ~10 minutes.","operationId":"rotateDek","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseDekRotationResultDto"}}}}}}},"/api/v1/tags":{"get":{"tags":["Tags"],"summary":"List tags for the authenticated organization","operationId":"list_3","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultTagDto"}}}}}},"post":{"tags":["Tags"],"summary":"Create a new tag","operationId":"create_4","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTagRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseTagDto"}}}}}}},"/api/v1/service-subscriptions/{slug}":{"post":{"tags":["Service Subscriptions"],"summary":"Subscribe to a service or a component of a service","description":"Idempotent — returns the existing subscription if an identical one exists. Omit the request body or set componentId to null for a whole-service subscription. Free tier: max 10 subscriptions. Paid tier: unlimited.","operationId":"subscribe","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceSubscribeRequest"}}}},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseServiceSubscriptionDto"}}}}}}},"/api/v1/secrets":{"get":{"tags":["Secrets"],"summary":"List secrets","operationId":"list_4","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultSecretDto"}}}}}},"post":{"tags":["Secrets"],"summary":"Create secret","operationId":"create_5","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSecretRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseSecretDto"}}}}}}},"/api/v1/resource-groups":{"get":{"tags":["Resource Groups"],"summary":"List all resource groups for the authenticated org with health summaries","operationId":"list_5","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultResourceGroupDto"}}}}}},"post":{"tags":["Resource Groups"],"summary":"Create a new resource group","operationId":"create_6","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateResourceGroupRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseResourceGroupDto"}}}}}}},"/api/v1/resource-groups/{id}/members":{"post":{"tags":["Resource Groups"],"summary":"Add a monitor or service member to a resource group","operationId":"addMember_1","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddResourceGroupMemberRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseResourceGroupMemberDto"}}}}}}},"/api/v1/notification-policies":{"get":{"tags":["Notification Policies"],"summary":"List all notification policies for the authenticated org","operationId":"list_6","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultNotificationPolicyDto"}}}}}},"post":{"tags":["Notification Policies"],"summary":"Create a notification policy with match rules and escalation chain","operationId":"create_7","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateNotificationPolicyRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseNotificationPolicyDto"}}}}}}},"/api/v1/notification-policies/{id}/test":{"post":{"tags":["Notification Policies"],"summary":"Dry-run: evaluate a policy's match rules against a supplied incident context","operationId":"test_1","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestNotificationPolicyRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseTestMatchResult"}}}}}}},"/api/v1/notification-dispatches/{id}/acknowledge":{"post":{"tags":["Notification Dispatches"],"summary":"Acknowledge a notification dispatch","description":"Marks the dispatch as acknowledged. The dispatch must be in DELIVERED or ESCALATING state. Sets acknowledgedAt, acknowledgedBy (actor email), and acknowledgedVia (DASHBOARD).","operationId":"acknowledge","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseNotificationDispatchDto"}}}}}}},"/api/v1/monitors":{"get":{"tags":["Monitors"],"summary":"List monitors for the authenticated org","operationId":"list_7","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"enabled","in":"query","description":"Filter by enabled state","required":false,"schema":{"type":"boolean"}},{"name":"type","in":"query","description":"Filter by monitor type","required":false,"schema":{"type":"string","enum":["HTTP","DNS","MCP_SERVER","TCP","ICMP","HEARTBEAT"]}},{"name":"managedBy","in":"query","description":"Filter by managed-by source","required":false,"schema":{"type":"string","enum":["DASHBOARD","CLI"]}},{"name":"tags","in":"query","description":"Filter by tag names, comma-separated (e.g. prod,critical)","required":false,"schema":{"type":"string"}},{"name":"search","in":"query","description":"Case-insensitive name search","required":false,"schema":{"type":"string"}},{"name":"environmentId","in":"query","description":"Filter by environment ID","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultMonitorDto"}}}}}},"post":{"tags":["Monitors"],"summary":"Create a new monitor","operationId":"create_8","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateMonitorRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorDto"}}}}}}},"/api/v1/monitors/{monitorId}/assertions":{"post":{"tags":["Monitor Assertions"],"summary":"Add an assertion to a monitor","operationId":"add","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"monitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAssertionRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorAssertionDto"}}}}}}},"/api/v1/monitors/{id}/test":{"post":{"tags":["Monitors"],"summary":"Test an existing monitor","description":"Runs the saved config and assertions of an existing monitor once, without persisting any result. Runs synchronously and returns the same shape as the ad-hoc test.","operationId":"testExisting","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorTestResultDto"}}}}}}},"/api/v1/monitors/{id}/tags":{"get":{"tags":["Monitors"],"summary":"Get all tags applied to a monitor","operationId":"getMonitorTags","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultTagDto"}}}}}},"post":{"tags":["Monitors"],"summary":"Add tags to a monitor; supports existing tag IDs and inline creation of new tags","operationId":"addMonitorTags","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddMonitorTagsRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultTagDto"}}}}}},"delete":{"tags":["Monitors"],"summary":"Remove tags from a monitor by their IDs","operationId":"removeMonitorTags","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveMonitorTagsRequest"}}},"required":true},"responses":{"204":{"description":"No Content"}}}},"/api/v1/monitors/{id}/rotate-token":{"post":{"tags":["Monitors"],"summary":"Rotate the ping token for a heartbeat monitor","description":"Generates a new ping token. The old token remains valid for 24 hours to allow cron jobs to be updated without downtime. Only supported for HEARTBEAT monitors.","operationId":"rotateToken","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorDto"}}}}}}},"/api/v1/monitors/{id}/resume":{"post":{"tags":["Monitors"],"summary":"Resume a monitor (set enabled=true)","operationId":"resume","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorDto"}}}}}}},"/api/v1/monitors/{id}/pause":{"post":{"tags":["Monitors"],"summary":"Pause a monitor (set enabled=false)","operationId":"pause","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorDto"}}}}}}},"/api/v1/monitors/test":{"post":{"tags":["Monitors"],"summary":"Ad-hoc monitor test","description":"Executes a one-off check from an inline config without saving the monitor. Runs synchronously and returns status code, response time, assertion results, body preview, and headers.","operationId":"testAdHoc","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MonitorTestRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorTestResultDto"}}}}}}},"/api/v1/monitors/bulk":{"post":{"tags":["Monitors"],"summary":"Bulk action on monitors","description":"Applies PAUSE, RESUME, DELETE, ADD_TAG, or REMOVE_TAG to a list of monitors. Returns a partial-success response indicating which monitors succeeded and which failed.","operationId":"bulkAction","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkMonitorActionRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseBulkMonitorActionResult"}}}}}}},"/api/v1/maintenance-windows":{"get":{"tags":["Maintenance Windows"],"summary":"List maintenance windows for the authenticated org","description":"Returns maintenance windows for the caller's organisation. Optionally filter by monitor_id, and/or by status: 'active' (currently in window) or 'upcoming' (starts in the future).","operationId":"list_8","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"monitorId","in":"query","description":"Filter by monitor UUID","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"filter","in":"query","description":"Filter by status: 'active' or 'upcoming'","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultMaintenanceWindowDto"}}}}}},"post":{"tags":["Maintenance Windows"],"summary":"Create a maintenance window","description":"Creates a new maintenance window. Set monitorId to null to create an org-wide window that suppresses alerts for all monitors.","operationId":"create_9","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateMaintenanceWindowRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMaintenanceWindowDto"}}}}}}},"/api/v1/invites":{"get":{"tags":["Invites"],"summary":"List invites","operationId":"list_9","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultInviteDto"}}}}}},"post":{"tags":["Invites"],"summary":"Create invite","operationId":"create_10","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInviteRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseInviteDto"}}}}}}},"/api/v1/invites/{inviteId}/revoke":{"post":{"tags":["Invites"],"summary":"Revoke invite","operationId":"revoke","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"inviteId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/invites/{inviteId}/resend":{"post":{"tags":["Invites"],"summary":"Resend invite","operationId":"resend","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"inviteId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseInviteDto"}}}}}}},"/api/v1/incidents":{"get":{"tags":["Incidents"],"summary":"List incidents for the authenticated org","operationId":"list_10","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"params","in":"query","required":true,"schema":{"$ref":"#/components/schemas/IncidentFilterParams"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultIncidentDto"}}}}}},"post":{"tags":["Incidents"],"summary":"Create a manual incident","operationId":"create_11","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateManualIncidentRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentDetailDto"}}}}}}},"/api/v1/incidents/{id}/updates":{"post":{"tags":["Incidents"],"summary":"Add an update to an incident (optionally change status)","operationId":"addUpdate","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddIncidentUpdateRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentDetailDto"}}}}}}},"/api/v1/incidents/{id}/resolve":{"post":{"tags":["Incidents"],"summary":"Resolve an incident","operationId":"resolve","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResolveIncidentRequest"}}}},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentDetailDto"}}}}}}},"/api/v1/heartbeat/{token}":{"get":{"tags":["Heartbeat"],"summary":"Record a heartbeat ping (GET)","description":"Called by external systems (cron jobs, scheduled tasks) to signal liveness. Always returns 200 OK.","operationId":"pingGet","parameters":[{"name":"token","in":"path","description":"Ping endpoint token for the heartbeat monitor","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"boolean"}}}}}}},"post":{"tags":["Heartbeat"],"summary":"Record a heartbeat ping (POST)","description":"Called by external systems to signal liveness with an optional JSON payload. The payload can be inspected by heartbeat_payload_contains assertions. Always returns 200 OK.","operationId":"pingPost","parameters":[{"name":"token","in":"path","description":"Ping endpoint token for the heartbeat monitor","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"string"}},"text/plain":{"schema":{"type":"string"}},"*/*":{"schema":{"type":"string"}}}},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"boolean"}}}}}}}},"/api/v1/environments":{"get":{"tags":["Environments"],"summary":"List environments","operationId":"list_11","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultEnvironmentDto"}}}}}},"post":{"tags":["Environments"],"summary":"Create environment","operationId":"create_12","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEnvironmentRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseEnvironmentDto"}}}}}}},"/api/v1/api-keys":{"get":{"tags":["API Keys"],"summary":"List API keys","operationId":"list_12","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultApiKeyDto"}}}}}},"post":{"tags":["API Keys"],"summary":"Create API key","operationId":"create_13","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseApiKeyCreateResponse"}}}}}}},"/api/v1/api-keys/{id}/revoke":{"post":{"tags":["API Keys"],"summary":"Revoke API key","operationId":"revoke_1","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseApiKeyDto"}}}}}}},"/api/v1/api-keys/{id}/regenerate":{"post":{"tags":["API Keys"],"summary":"Regenerate API key","operationId":"regenerate","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseApiKeyCreateResponse"}}}}}}},"/api/v1/alert-deliveries/{id}/retry":{"post":{"tags":["Alert Deliveries"],"summary":"Retry a failed delivery","description":"Resets a FAILED delivery to RETRY_PENDING so the delivery worker re-attempts it.","operationId":"retry","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAlertDeliveryDto"}}}}}}},"/api/v1/alert-channels":{"get":{"tags":["Alert Channels"],"summary":"List active alert channels for the authenticated org","operationId":"list_13","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultAlertChannelDto"}}}}}},"post":{"tags":["Alert Channels"],"summary":"Create a new alert channel with encrypted config","operationId":"create_14","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAlertChannelRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAlertChannelDto"}}}}}}},"/api/v1/alert-channels/{id}/test":{"post":{"tags":["Alert Channels"],"summary":"Test a saved alert channel's connectivity","operationId":"test_2","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseTestChannelResult"}}}}}}},"/api/v1/alert-channels/test":{"post":{"tags":["Alert Channels"],"summary":"Test alert channel connectivity using raw config (no saved channel required)","operationId":"testConfig","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestAlertChannelRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseTestChannelResult"}}}}}}},"/v1/internal/service-incidents/by-ref/{serviceId}/{externalRef}/components":{"patch":{"tags":["service-incident-internal-controller"],"operationId":"addComponents","parameters":[{"name":"serviceId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"externalRef","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ComponentUpdateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultIncidentDto"}}}}}}},"/api/v1/service-subscriptions/{id}/alert-sensitivity":{"patch":{"tags":["Service Subscriptions"],"summary":"Update alert sensitivity for a subscription","description":"Controls which external incidents trigger alerts: ALL (any status change), INCIDENTS_ONLY (real vendor incidents, default), MAJOR_ONLY (only DOWN-level incidents).","operationId":"updateAlertSensitivity","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAlertSensitivityRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseServiceSubscriptionDto"}}}}}}},"/api/v1/api-keys/{id}":{"delete":{"tags":["API Keys"],"summary":"Delete API key","operationId":"delete_10","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["API Keys"],"summary":"Update API key","operationId":"update_14","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateApiKeyRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseApiKeyDto"}}}}}}},"/v1/internal/workspaces/{id}":{"get":{"tags":["workspaces-controller"],"operationId":"get_7","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWorkspaceDto"}}}}}}},"/v1/internal/orgs/{id}/workspaces":{"get":{"tags":["orgs-controller"],"operationId":"listWorkspaces_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultWorkspaceDto"}}}}}}},"/v1/internal/monitors/{id}/policy":{"get":{"tags":["monitors-internal-controller"],"operationId":"policy","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentPolicyDto"}}}}}}},"/v1/internal/monitors/{id}/env-variables":{"get":{"tags":["monitors-internal-controller"],"operationId":"getEnvVariables","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMapStringString"}}}}}}},"/v1/internal/monitors/{id}/auth":{"get":{"tags":["monitors-internal-controller"],"operationId":"auth","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorAuthDto"}}}}}}},"/v1/internal/monitors/{id}/assertions":{"get":{"tags":["monitors-internal-controller"],"operationId":"getAssertions","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseListMonitorAssertionDto"}}}}}}},"/v1/internal/monitors/{id}/active-incident":{"get":{"tags":["monitors-internal-controller"],"operationId":"activeIncident","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentDto"}}}}}}},"/v1/internal/monitors/schedulable":{"get":{"tags":["monitors-internal-controller"],"operationId":"schedulable","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SchedulableMonitorDto"}}}}}}}},"/platform/plans":{"get":{"tags":["Billing"],"summary":"List public billing plans","operationId":"getPublicPlans","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseListBillingPlanDto"}}}}}}},"/platform/orgs/{orgId}/subscriptions":{"get":{"tags":["Subscriptions"],"summary":"List active subscriptions","operationId":"listActive","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultSubscriptionDto"}}}}}},"delete":{"tags":["Subscriptions"],"operationId":"cancel","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}}},"/platform/orgs/{orgId}/subscriptions/upcoming-charge":{"get":{"tags":["Subscriptions"],"summary":"Get upcoming charge","operationId":"getUpcomingCharge","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"priceId","in":"query","required":true,"schema":{"minimum":1,"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUpcomingChargeResponse"}}}}}}},"/platform/orgs/{orgId}/subscriptions/management-urls":{"get":{"tags":["Subscriptions"],"summary":"Get subscription management URLs","operationId":"getManagementUrls","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMapStringString"}}}}}}},"/platform/orgs/{orgId}/subscriptions/customer-auth-token":{"get":{"tags":["Subscriptions"],"summary":"Get customer auth token","operationId":"getCustomerAuthToken","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseString"}}}}}}},"/platform/orgs/{orgId}/entitlements":{"get":{"tags":["Entitlements"],"summary":"Get resolved entitlements and current usage for the organization","operationId":"getEntitlements","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseEntitlementResponse"}}}}}}},"/platform/orgs/search":{"get":{"tags":["Organizations"],"summary":"Search organizations","operationId":"searchOrganizations","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"query","in":"query","required":true,"schema":{"type":"string"}},{"name":"paginationParams","in":"query","required":true,"schema":{"$ref":"#/components/schemas/PaginationParams"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultIdValuePair"}}}}}}},"/platform/me/orgs":{"get":{"tags":["Me"],"summary":"Get current user's organizations","operationId":"myOrgs","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultMyOrgItemDto"}}}}}}},"/platform/events/stream":{"get":{"tags":["Events"],"summary":"Subscribe to real-time platform events via SSE","operationId":"stream","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"text/event-stream":{"schema":{"$ref":"#/components/schemas/SseEmitter"}}}}}}},"/platform/admin/users":{"get":{"tags":["admin-controller"],"operationId":"listUsers","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultUserDto"}}}}}}},"/platform/admin/stats":{"get":{"tags":["admin-controller"],"operationId":"getStats","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAdminStatsDto"}}}}}}},"/platform/admin/orgs":{"get":{"tags":["admin-controller"],"operationId":"listOrgs","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultOrganizationDto"}}}}}}},"/platform/admin/adapters/health":{"get":{"tags":["admin-adapter-health-controller"],"operationId":"getAdapterHealth","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultAdapterHealthDto"}}}}}}},"/api/v1/webhooks/{id}/deliveries":{"get":{"tags":["Webhooks"],"summary":"List recent deliveries for a webhook endpoint","operationId":"listDeliveries","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultWebhookDeliveryDto"}}}}}}},"/api/v1/webhooks/signing-secret":{"get":{"tags":["Webhooks"],"summary":"Get signing secret metadata for the authenticated org","operationId":"getSigningSecretInfo","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWebhookSigningSecretDto"}}}}}}},"/api/v1/webhooks/events":{"get":{"tags":["Webhooks"],"summary":"List all available webhook event types","description":"Returns the full catalog of supported outbound webhook event types with their surface grouping and human-readable descriptions. Use this to populate subscription checkboxes when creating or updating a webhook endpoint.","operationId":"listEvents","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WebhookEventCatalogResponse"}}}}}}},"/api/v1/services":{"get":{"tags":["Status Data"],"summary":"List all enabled services (cursor-paginated)","operationId":"listServices","parameters":[{"name":"category","in":"query","description":"Filter by category (exact match)","required":false,"schema":{"type":"string"}},{"name":"status","in":"query","description":"Filter by current overall_status (exact match)","required":false,"schema":{"type":"string"}},{"name":"cursor","in":"query","description":"Opaque cursor from a previous response","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Page size (1–100, default 20)","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CursorPageServiceCatalogDto"}}}}}}},"/api/v1/services/{slugOrId}":{"get":{"tags":["Status Data"],"summary":"Get a single service by slug or UUID with current status, components, and recent incidents","operationId":"getService","parameters":[{"name":"slugOrId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseServiceDetailDto"}}}}}}},"/api/v1/services/{slugOrId}/uptime":{"get":{"tags":["Status Data"],"summary":"Get uptime statistics for a service","description":"Uptime data aggregated across active non-group components.","operationId":"getServiceUptime","parameters":[{"name":"slugOrId","in":"path","required":true,"schema":{"type":"string"}},{"name":"period","in":"query","description":"Time window","required":false,"schema":{"type":"string","enum":["24h","7d","30d","90d","1y","2y","all"]}},{"name":"granularity","in":"query","description":"Bucket granularity","required":false,"schema":{"type":"string","enum":["hourly","daily","monthly"]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseServiceUptimeResponse"}}}}},"security":[{"BearerAuth":[]}]}},"/api/v1/services/{slugOrId}/maintenances":{"get":{"tags":["Status Data"],"summary":"List scheduled maintenances for a service","operationId":"getScheduledMaintenances","parameters":[{"name":"slugOrId","in":"path","required":true,"schema":{"type":"string"}},{"name":"status","in":"query","description":"Filter by status (e.g. scheduled, in_progress, verifying, completed)","required":false,"schema":{"type":"array","items":{"type":"string"}}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultScheduledMaintenanceDto"}}}}}}},"/api/v1/services/{slugOrId}/incidents":{"get":{"tags":["Status Data"],"summary":"List incident history for a service (paginated)","operationId":"listIncidents","parameters":[{"name":"slugOrId","in":"path","required":true,"schema":{"type":"string"}},{"name":"from","in":"query","description":"Earliest start date (ISO 8601 date)","required":false,"schema":{"type":"string","format":"date"}},{"name":"status","in":"query","description":"Filter: active (unresolved), resolved, or omit for all","required":false,"schema":{"type":"string","enum":["active","resolved"]}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultServiceIncidentDto"}}}}}}},"/api/v1/services/{slugOrId}/incidents/{incidentId}":{"get":{"tags":["Status Data"],"summary":"Get incident detail with full update timeline","operationId":"getIncident","parameters":[{"name":"slugOrId","in":"path","required":true,"schema":{"type":"string"}},{"name":"incidentId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseServiceIncidentDetailDto"}}}}}}},"/api/v1/services/{slugOrId}/components":{"get":{"tags":["Status Data"],"summary":"List active components for a service with current status and inline uptime","operationId":"getComponents","parameters":[{"name":"slugOrId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultServiceComponentDto"}}}}}}},"/api/v1/services/{slugOrId}/components/{componentId}/uptime":{"get":{"tags":["Status Data"],"summary":"Get daily uptime data for a component","operationId":"getComponentUptime","parameters":[{"name":"slugOrId","in":"path","required":true,"schema":{"type":"string"}},{"name":"componentId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"period","in":"query","description":"Time window","required":false,"schema":{"type":"string","enum":["7d","30d","90d","1y"]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultComponentUptimeDayDto"}}}}}}},"/api/v1/services/summary":{"get":{"tags":["Status Data"],"summary":"Global status summary across all services","description":"Returns aggregate counts of services by status and a list of services currently experiencing issues.","operationId":"getGlobalStatusSummary","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseGlobalStatusSummaryDto"}}}}}}},"/api/v1/services/incidents":{"get":{"tags":["Status Data"],"summary":"List vendor incidents across all services (paginated)","description":"Cross-service vendor incident feed ordered by start date descending.","operationId":"listCrossServiceIncidents","parameters":[{"name":"from","in":"query","description":"Earliest start date (ISO 8601 date)","required":false,"schema":{"type":"string","format":"date"}},{"name":"status","in":"query","description":"Filter: active (unresolved), resolved, or omit for all","required":false,"schema":{"type":"string","enum":["active","resolved"]}},{"name":"category","in":"query","description":"Filter by service category","required":false,"schema":{"type":"string"}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultServiceIncidentDto"}}}}}}},"/api/v1/service-subscriptions":{"get":{"tags":["Service Subscriptions"],"summary":"List all service subscriptions for the organization","operationId":"list_14","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultServiceSubscriptionDto"}}}}}}},"/api/v1/service-subscriptions/{id}":{"get":{"tags":["Service Subscriptions"],"summary":"Get a subscription by its ID","operationId":"get_8","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseServiceSubscriptionDto"}}}}}},"delete":{"tags":["Service Subscriptions"],"summary":"Remove a subscription by its ID","description":"Removes a specific subscription (whole-service or component-level). No-op if not found.","operationId":"unsubscribe","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/resource-groups/{id}/health":{"get":{"tags":["Resource Groups"],"summary":"Get the detailed health breakdown for a resource group","description":"Returns member counts, worst-of status, and threshold-based health evaluation. The thresholdStatus field is populated only when a health threshold is configured.","operationId":"getHealth","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseResourceGroupHealthDto"}}}}}}},"/api/v1/notifications":{"get":{"tags":["Notifications"],"summary":"List notifications for the current user","operationId":"list_15","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"unreadOnly","in":"query","required":false,"schema":{"type":"boolean","default":false}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"size","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultNotificationDto"}}}}}}},"/api/v1/notifications/unread-count":{"get":{"tags":["Notifications"],"summary":"Get unread notification count","operationId":"unreadCount","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseLong"}}}}}}},"/api/v1/notification-policies/{id}/dispatches":{"get":{"tags":["Notification Policies"],"summary":"List all dispatches (firing history) for a notification policy","operationId":"listDispatches","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultNotificationDispatchDto"}}}}}}},"/api/v1/notification-dispatches":{"get":{"tags":["Notification Dispatches"],"summary":"List all dispatches for an incident","description":"Returns all notification dispatches for the given incident that belong to the authenticated org's policies. Each dispatch includes delivery records for all associated channels.","operationId":"listByIncident","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"incident_id","in":"query","description":"UUID of the incident to inspect","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultNotificationDispatchDto"}}}}}}},"/api/v1/notification-dispatches/{id}":{"get":{"tags":["Notification Dispatches"],"summary":"Get a single dispatch with full escalation and delivery history","description":"Returns the dispatch state including current escalation step, acknowledgment info, and all delivery attempts made across every step.","operationId":"getById_2","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseNotificationDispatchDto"}}}}}}},"/api/v1/monitors/{id}/versions":{"get":{"tags":["Monitors"],"summary":"List version history for a monitor","description":"Returns a paginated list of mutation snapshots for the monitor, newest first. Each version captures the full monitor config at the time of a PUT /monitors/{id} call.","operationId":"listVersions","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultMonitorVersionDto"}}}}}}},"/api/v1/monitors/{id}/versions/{version}":{"get":{"tags":["Monitors"],"summary":"Get a specific version snapshot for a monitor","description":"Returns the full monitor config snapshot captured at the given version number.","operationId":"getVersion","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"version","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorVersionDto"}}}}}}},"/api/v1/monitors/{id}/uptime":{"get":{"tags":["Check Results"],"summary":"Get uptime statistics","description":"Returns uptime percentage and latency statistics for the requested time window, computed from continuous aggregates. Uses hourly aggregates for 24h/7d windows and daily aggregates for 30d/90d windows.","operationId":"getUptime","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"window","in":"query","description":"Time window for uptime calculation","required":false,"schema":{"type":"string","enum":["24h","7d","30d","90d"]}}],"responses":{"200":{"description":"Uptime statistics","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UptimeDto"}}}},"400":{"description":"Invalid window parameter","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUptimeDto"}}}},"403":{"description":"Monitor does not belong to the caller's org","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUptimeDto"}}}},"404":{"description":"Monitor not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUptimeDto"}}}}}}},"/api/v1/monitors/{id}/results":{"get":{"tags":["Check Results"],"summary":"List raw check results","description":"Returns check results for the given monitor with optional time-range, region, and pass/fail filtering. Uses cursor-based pagination — pass the returned `cursor` value on subsequent requests to retrieve the next page. The cursor encodes the original time bounds, so `from`/`to` are ignored when a cursor is present.","operationId":"getResults","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"from","in":"query","description":"Start of time range (ISO 8601, inclusive); defaults to 24 hours ago","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","description":"End of time range (ISO 8601, inclusive); defaults to now","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"cursor","in":"query","description":"Opaque cursor from a previous response for pagination","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Maximum results per page (1–200)","required":false,"schema":{"type":"integer","format":"int32","default":50},"example":50},{"name":"region","in":"query","description":"Filter by region (e.g. us-east)","required":false,"schema":{"type":"string"}},{"name":"passed","in":"query","description":"Filter by pass/fail status","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"Paginated check results","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CursorPage"}}}},"400":{"description":"Invalid query parameters","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CursorPageCheckResultDto"}}}},"403":{"description":"Monitor does not belong to the caller's org","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CursorPageCheckResultDto"}}}},"404":{"description":"Monitor not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CursorPageCheckResultDto"}}}}}}},"/api/v1/monitors/{id}/results/summary":{"get":{"tags":["Check Results"],"summary":"Get results summary","description":"Returns a dashboard summary for the monitor: current status derived from the latest result per region, time-bucketed chart data, the 24-hour uptime percentage, and the selected window's uptime percentage.","operationId":"getSummary","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"chartWindow","in":"query","description":"Chart window: 24h returns hourly buckets, 7d/30d/90d return daily buckets","required":false,"schema":{"type":"string","enum":["24h","7d","30d","90d"]}}],"responses":{"200":{"description":"Results summary","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ResultSummaryDto"}}}},"400":{"description":"Invalid chartWindow parameter","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseResultSummaryDto"}}}},"403":{"description":"Monitor does not belong to the caller's org","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseResultSummaryDto"}}}},"404":{"description":"Monitor not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseResultSummaryDto"}}}}}}},"/api/v1/members":{"get":{"tags":["Members"],"summary":"List organization members","operationId":"list_16","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultMemberDto"}}}}}}},"/api/v1/integrations":{"get":{"tags":["Integrations"],"summary":"List all supported integration types","description":"Returns the full static catalog of supported alert channel integration types with their metadata and config field schemas. Used by the frontend to dynamically render the 'Add Alert Channel' form.","operationId":"list_17","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IntegrationCatalogResponse"}}}}}}},"/api/v1/incidents/{id}":{"get":{"tags":["Incidents"],"summary":"Get incident details including update timeline","operationId":"get_9","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentDetailDto"}}}}}}},"/api/v1/dashboard/overview":{"get":{"tags":["Dashboard"],"summary":"Dashboard overview","description":"Returns monitor status counts, average uptime windows, and incident aggregates for the authenticated org. Results are cached for 1 minute.","operationId":"overview","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseDashboardOverviewDto"}}}}}}},"/api/v1/categories":{"get":{"tags":["Status Data"],"summary":"List categories with service counts","operationId":"listCategories","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultCategoryDto"}}}}}}},"/api/v1/audit-log":{"get":{"tags":["Audit Log"],"summary":"List audit events for the current organization","operationId":"list_18","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"action","in":"query","required":false,"schema":{"type":"string"}},{"name":"actorId","in":"query","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"resourceType","in":"query","required":false,"schema":{"type":"string"}},{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"size","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PageResultAuditEventDto"}}}}}}},"/api/v1/alert-deliveries/{id}/attempts":{"get":{"tags":["Alert Deliveries"],"summary":"List delivery attempts for a specific alert delivery","description":"Returns the ordered list of delivery attempts (request/response audit data) for the given delivery ID.","operationId":"listAttempts","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultDeliveryAttemptDto"}}}}}}},"/api/v1/alert-channels/{id}/deliveries":{"get":{"tags":["Alert Channels"],"summary":"List delivery history for an alert channel","operationId":"listDeliveries_1","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultAlertDeliveryDto"}}}}}}},"/platform/orgs/{orgId}":{"delete":{"tags":["Organizations"],"summary":"Delete organization","operationId":"delete_11","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}}},"/platform/admin/orgs/{orgId}/members/{userId}":{"delete":{"tags":["admin-member-controller"],"operationId":"removeMember","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/resource-groups/{id}/members/{memberId}":{"delete":{"tags":["Resource Groups"],"summary":"Remove a member from a resource group","operationId":"removeMember_1","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"memberId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/members/{userId}":{"delete":{"tags":["Members"],"summary":"Remove member from organization","operationId":"remove_2","parameters":[{"name":"actor","in":"query","required":true,"schema":{"oneOf":[{"$ref":"#/components/schemas/ApiKey"},{"$ref":"#/components/schemas/Internal"},{"$ref":"#/components/schemas/UI"}]}},{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}}}},"components":{"schemas":{"Actor":{"type":"object"},"ApiKey":{"type":"object","allOf":[{"$ref":"#/components/schemas/Actor"},{"type":"object","properties":{"orgId":{"type":"integer","format":"int32"},"keyId":{"type":"integer","format":"int32"}}}]},"Internal":{"type":"object","allOf":[{"$ref":"#/components/schemas/Actor"}]},"OrgContext":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"role":{"type":"string","enum":["OWNER","ADMIN","MEMBER"]}}},"UI":{"type":"object","allOf":[{"$ref":"#/components/schemas/Actor"},{"type":"object","properties":{"userContext":{"$ref":"#/components/schemas/UserContext"},"orgContext":{"$ref":"#/components/schemas/OrgContext"},"workspaceId":{"type":"integer","format":"int32","nullable":true}}}]},"UserContext":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"role":{"type":"string","enum":["SUPERADMIN","ADMIN","USER"]}}},"CreateSubscriptionRequest":{"type":"object","properties":{"priceId":{"minimum":1,"type":"integer","format":"int32"}}},"BillingPlanDto":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"paddleId":{"type":"string"},"name":{"type":"string"},"description":{"type":"string","nullable":true},"prices":{"type":"array","nullable":true,"items":{"$ref":"#/components/schemas/BillingPriceDto"}}},"nullable":true},"BillingPriceDto":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"paddleId":{"type":"string"},"amount":{"type":"integer","format":"int32"},"interval":{"type":"string","enum":["DAY","WEEK","MONTH","YEAR"]},"intervalCount":{"type":"integer","format":"int32"},"description":{"type":"string","nullable":true},"billingPlan":{"$ref":"#/components/schemas/BillingPlanDto"}}},"ItemDto":{"type":"object","properties":{"billingPrice":{"$ref":"#/components/schemas/BillingPriceDto"},"quantity":{"type":"integer","format":"int32"},"amount":{"type":"integer","format":"int32"}}},"SingleValueResponseSubscriptionDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/SubscriptionDto"}}},"SubscriptionDto":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"paddleId":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"organizationId":{"type":"integer","format":"int32"},"status":{"type":"string","enum":["ACTIVE","CANCELED","PAST_DUE","PAUSED","TRIALING"]},"nextBilledAt":{"type":"string","format":"date-time","nullable":true},"willCancelAt":{"type":"string","format":"date-time","nullable":true},"items":{"type":"array","items":{"$ref":"#/components/schemas/ItemDto"}}}},"UpdateOrgDetailsRequest":{"required":["email","name"],"type":"object","properties":{"name":{"maxLength":200,"minLength":0,"type":"string"},"email":{"minLength":1,"type":"string","format":"email"},"size":{"maxLength":50,"minLength":0,"type":"string"},"industry":{"maxLength":100,"minLength":0,"type":"string"},"websiteUrl":{"maxLength":255,"minLength":0,"type":"string"}}},"OrganizationDto":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"name":{"type":"string"},"email":{"type":"string","nullable":true},"size":{"type":"string","nullable":true},"industry":{"type":"string","nullable":true},"websiteUrl":{"type":"string","nullable":true}}},"SingleValueResponseOrganizationDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/OrganizationDto"}}},"UpdateOnboardingStageRequest":{"required":["stage"],"type":"object","properties":{"stage":{"type":"string","enum":["WELCOME","FIRST_MONITOR","SETUP_COMPLETE","COMPLETED"]}}},"SingleValueResponseUserDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/UserDto"}}},"UserDto":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"email":{"type":"string"},"emailVerified":{"type":"boolean"},"name":{"type":"string","nullable":true},"userRole":{"type":"string","enum":["SUPERADMIN","ADMIN","USER"]},"onboardingStage":{"type":"string","nullable":true,"enum":["WELCOME","FIRST_MONITOR","SETUP_COMPLETE","COMPLETED"]},"imageUrl":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"UpdateProfileRequest":{"type":"object","properties":{"name":{"maxLength":200,"minLength":0,"type":"string"}}},"UpdateNotificationPreferencesRequest":{"required":["preferences"],"type":"object","properties":{"preferences":{"type":"object","additionalProperties":{"type":"boolean"}}}},"NotificationPreferencesDto":{"type":"object","properties":{"preferences":{"type":"object","additionalProperties":{"type":"boolean"}},"updatedAt":{"type":"string","format":"date-time"}}},"SingleValueResponseNotificationPreferencesDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/NotificationPreferencesDto"}}},"UpdateWorkspaceRequest":{"required":["name"],"type":"object","properties":{"name":{"maxLength":200,"minLength":0,"type":"string"}}},"SingleValueResponseWorkspaceDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WorkspaceDto"}}},"WorkspaceDto":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"name":{"type":"string"},"orgId":{"type":"integer","format":"int32"}}},"UpdateUserRequest":{"type":"object","properties":{"name":{"maxLength":200,"minLength":0,"type":"string"},"email":{"type":"string","format":"email"},"userRole":{"type":"string","enum":["SUPERADMIN","ADMIN","USER"]},"onboardingStage":{"type":"string","enum":["WELCOME","FIRST_MONITOR","SETUP_COMPLETE","COMPLETED"]},"imageUrl":{"maxLength":500,"minLength":0,"type":"string"}}},"ChangeRoleRequest":{"required":["orgRole"],"type":"object","properties":{"orgRole":{"type":"string","enum":["OWNER","ADMIN","MEMBER"]}}},"UpdateWebhookEndpointRequest":{"type":"object","properties":{"url":{"maxLength":2048,"minLength":0,"type":"string","description":"New webhook URL; null preserves current","nullable":true},"description":{"maxLength":255,"minLength":0,"type":"string","description":"New description; null preserves current","nullable":true},"subscribedEvents":{"type":"array","description":"Replace subscribed events; null preserves current","nullable":true,"items":{"type":"string","description":"Replace subscribed events; null preserves current","nullable":true}},"enabled":{"type":"boolean","description":"Enable or disable delivery; null preserves current","nullable":true}}},"SingleValueResponseWebhookEndpointDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WebhookEndpointDto"}}},"WebhookEndpointDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"url":{"type":"string"},"description":{"type":"string","nullable":true},"subscribedEvents":{"type":"array","items":{"type":"string"}},"enabled":{"type":"boolean"},"consecutiveFailures":{"type":"integer","format":"int32"},"disabledReason":{"type":"string","nullable":true},"disabledAt":{"type":"string","format":"date-time","nullable":true},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"UpdateTagRequest":{"type":"object","properties":{"name":{"maxLength":100,"minLength":0,"type":"string","description":"New tag name","nullable":true},"color":{"pattern":"^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$","type":"string","description":"New hex color code","nullable":true}},"description":"Request body for updating a tag; null fields are left unchanged"},"SingleValueResponseTagDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TagDto"}}},"TagDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"organizationId":{"type":"integer","format":"int32"},"name":{"type":"string"},"color":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}}},"UpdateSecretRequest":{"required":["value"],"type":"object","properties":{"value":{"maxLength":32768,"minLength":0,"type":"string","description":"New secret value, stored encrypted (max 32KB)"}}},"MonitorReference":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"}}},"SecretDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"key":{"type":"string"},"dekVersion":{"type":"integer","format":"int32"},"valueHash":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"usedByMonitors":{"type":"array","nullable":true,"items":{"$ref":"#/components/schemas/MonitorReference"}}}},"SingleValueResponseSecretDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/SecretDto"}}},"RetryStrategy":{"required":["type"],"type":"object","properties":{"type":{"type":"string"},"maxRetries":{"type":"integer","format":"int32"},"interval":{"type":"integer","format":"int32"}},"description":"Default retry strategy for member monitors; null clears"},"UpdateResourceGroupRequest":{"required":["name"],"type":"object","properties":{"name":{"maxLength":255,"minLength":0,"type":"string","description":"Human-readable name for this group"},"description":{"type":"string","description":"Optional description; null clears the existing value","nullable":true},"alertPolicyId":{"type":"string","description":"Optional notification policy to apply for this group; null clears the existing value","format":"uuid","nullable":true},"defaultFrequency":{"maximum":86400,"minimum":30,"type":"integer","description":"Default check frequency in seconds for members (30–86400); null clears","format":"int32","nullable":true},"defaultRegions":{"type":"array","description":"Default regions for member monitors; null clears","nullable":true,"items":{"type":"string","description":"Default regions for member monitors; null clears","nullable":true}},"defaultRetryStrategy":{"$ref":"#/components/schemas/RetryStrategy"},"defaultAlertChannels":{"type":"array","description":"Default alert channel IDs for member monitors; null clears","nullable":true,"items":{"type":"string","description":"Default alert channel IDs for member monitors; null clears","format":"uuid","nullable":true}},"defaultEnvironmentId":{"type":"string","description":"Default environment ID for member monitors; null clears","format":"uuid","nullable":true},"healthThresholdType":{"type":"string","description":"Health threshold type: COUNT or PERCENTAGE; null disables threshold","nullable":true,"enum":["COUNT","PERCENTAGE"]},"healthThresholdValue":{"maximum":100,"exclusiveMaximum":false,"minimum":0,"exclusiveMinimum":false,"type":"number","description":"Health threshold value; null disables threshold","nullable":true},"suppressMemberAlerts":{"type":"boolean","description":"Suppress member-level alert notifications; null preserves current value","nullable":true},"confirmationDelaySeconds":{"maximum":600,"minimum":0,"type":"integer","description":"Confirmation delay in seconds; null clears","format":"int32","nullable":true},"recoveryCooldownMinutes":{"maximum":60,"minimum":0,"type":"integer","description":"Recovery cooldown in minutes; null clears","format":"int32","nullable":true}},"description":"Request body for updating a resource group"},"ResourceGroupDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"organizationId":{"type":"integer","format":"int32"},"name":{"type":"string"},"slug":{"type":"string"},"description":{"type":"string","nullable":true},"alertPolicyId":{"type":"string","description":"Notification policy applied to this group","format":"uuid","nullable":true},"defaultFrequency":{"type":"integer","description":"Default check frequency in seconds for member monitors","format":"int32","nullable":true},"defaultRegions":{"type":"array","description":"Default regions for member monitors","nullable":true,"items":{"type":"string","description":"Default regions for member monitors","nullable":true}},"defaultRetryStrategy":{"$ref":"#/components/schemas/RetryStrategy"},"defaultAlertChannels":{"type":"array","description":"Default alert channel IDs for member monitors","nullable":true,"items":{"type":"string","description":"Default alert channel IDs for member monitors","format":"uuid","nullable":true}},"defaultEnvironmentId":{"type":"string","description":"Default environment ID for member monitors","format":"uuid","nullable":true},"healthThresholdType":{"type":"string","description":"Health threshold type: COUNT or PERCENTAGE","nullable":true,"enum":["COUNT","PERCENTAGE"]},"healthThresholdValue":{"type":"number","description":"Health threshold value","nullable":true},"suppressMemberAlerts":{"type":"boolean","description":"When true, member-level incidents skip notification dispatch; only group alerts fire"},"confirmationDelaySeconds":{"type":"integer","description":"Seconds to wait after health threshold breach before creating group incident","format":"int32","nullable":true},"recoveryCooldownMinutes":{"type":"integer","description":"Cooldown minutes after group incident resolves before a new one can open","format":"int32","nullable":true},"health":{"$ref":"#/components/schemas/ResourceGroupHealthDto"},"members":{"type":"array","description":"Member list with individual statuses; populated on detail GET only","nullable":true,"items":{"$ref":"#/components/schemas/ResourceGroupMemberDto"}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"description":"Resource group with health summary and optional member details"},"ResourceGroupHealthDto":{"type":"object","properties":{"status":{"type":"string","description":"Worst-of health status across all members","enum":["operational","maintenance","degraded","down"]},"totalMembers":{"type":"integer","description":"Total number of members in the group","format":"int32"},"operationalCount":{"type":"integer","description":"Number of members currently in operational status","format":"int32"},"activeIncidents":{"type":"integer","description":"Number of members with an active incident or non-operational status","format":"int32"},"thresholdStatus":{"type":"string","description":"Computed group health status based on threshold: 'healthy', 'degraded', or 'down'. Null when no health threshold is configured.","nullable":true,"enum":["healthy","degraded","down"]},"failingCount":{"type":"integer","description":"Number of failing members at time of last evaluation","format":"int32","nullable":true}},"description":"Aggregated health summary for a resource group"},"ResourceGroupMemberDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"groupId":{"type":"string","format":"uuid"},"memberType":{"type":"string","description":"Type of member: 'monitor' or 'service'"},"monitorId":{"type":"string","description":"Monitor ID; set when memberType is 'monitor'","format":"uuid","nullable":true},"serviceId":{"type":"string","description":"Service ID; set when memberType is 'service'","format":"uuid","nullable":true},"name":{"type":"string","description":"Display name of the referenced monitor or service","nullable":true},"slug":{"type":"string","description":"Slug identifier for the service (services only); used for icons and uptime API calls","nullable":true},"subscriptionId":{"type":"string","description":"Subscription ID for the service (services only); used to link to the dependency detail page","format":"uuid","nullable":true},"status":{"type":"string","description":"Computed health status for this member","enum":["operational","maintenance","degraded","down"]},"effectiveFrequency":{"type":"string","description":"Effective check frequency label showing the group default when the monitor inherits it; null for services or when no group default is configured","nullable":true},"createdAt":{"type":"string","format":"date-time"},"uptime24h":{"type":"number","description":"24h uptime percentage; populated when includeMetrics=true","format":"double","nullable":true},"chartData":{"type":"array","description":"Uptime tick values (0-100) for last-24h mini chart; populated when includeMetrics=true","nullable":true,"items":{"type":"number","description":"Uptime tick values (0-100) for last-24h mini chart; populated when includeMetrics=true","format":"double","nullable":true}},"avgLatencyMs":{"type":"number","description":"Average latency in ms (monitors only); populated when includeMetrics=true","format":"double","nullable":true},"p95LatencyMs":{"type":"number","description":"P95 latency in ms (monitors only); populated when includeMetrics=true","format":"double","nullable":true},"lastCheckedAt":{"type":"string","description":"Timestamp of the most recent health check; populated when includeMetrics=true","format":"date-time","nullable":true},"monitorType":{"type":"string","description":"Monitor type (HTTP, DNS, TCP, ICMP, HEARTBEAT, MCP); monitors only","nullable":true},"environmentName":{"type":"string","description":"Environment name; monitors only","nullable":true}},"description":"A single member of a resource group with its computed health status"},"SingleValueResponseResourceGroupDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ResourceGroupDto"}}},"EscalationChain":{"required":["steps"],"type":"object","properties":{"steps":{"minItems":1,"type":"array","description":"Ordered escalation steps, evaluated in sequence","items":{"$ref":"#/components/schemas/EscalationStep"}},"onResolve":{"type":"string","description":"Action when the incident resolves","nullable":true},"onReopen":{"type":"string","description":"Action when a resolved incident reopens","nullable":true}},"description":"Escalation chain defining which channels to notify"},"EscalationStep":{"required":["channelIds"],"type":"object","properties":{"delayMinutes":{"minimum":0,"type":"integer","description":"Minutes to wait before executing this step (0 = immediate)","format":"int32"},"channelIds":{"minItems":1,"type":"array","description":"Alert channel IDs to notify in this step","items":{"type":"string","description":"Alert channel IDs to notify in this step","format":"uuid"}},"requireAck":{"type":"boolean","description":"Whether an acknowledgment is required before escalating","nullable":true},"repeatIntervalSeconds":{"minimum":1,"type":"integer","description":"Repeat notification interval in seconds until acknowledged","format":"int32","nullable":true}},"description":"Ordered escalation steps, evaluated in sequence"},"MatchRule":{"required":["type"],"type":"object","properties":{"type":{"type":"string","description":"Rule type, e.g. severity_gte, monitor_id_in, region_in"},"value":{"type":"string","description":"Comparison value for single-value rules like severity_gte","nullable":true},"monitorIds":{"type":"array","description":"Monitor UUIDs to match for monitor_id_in rules","nullable":true,"items":{"type":"string","description":"Monitor UUIDs to match for monitor_id_in rules","format":"uuid","nullable":true}},"regions":{"type":"array","description":"Region codes to match for region_in rules","nullable":true,"items":{"type":"string","description":"Region codes to match for region_in rules","nullable":true}},"values":{"type":"array","description":"Values list for multi-value rules like monitor_type_in","nullable":true,"items":{"type":"string","description":"Values list for multi-value rules like monitor_type_in","nullable":true}}},"description":"Match rules to evaluate (all must pass; omit or empty for catch-all)"},"UpdateNotificationPolicyRequest":{"required":["enabled","escalation","name","priority"],"type":"object","properties":{"name":{"maxLength":255,"minLength":0,"type":"string","description":"Human-readable name for this policy"},"matchRules":{"type":"array","description":"Match rules to evaluate (all must pass; omit or empty for catch-all)","items":{"$ref":"#/components/schemas/MatchRule"}},"escalation":{"$ref":"#/components/schemas/EscalationChain"},"enabled":{"type":"boolean","description":"Whether this policy is enabled"},"priority":{"type":"integer","description":"Evaluation priority; higher value = evaluated first","format":"int32"}},"description":"Request body for updating a notification policy"},"NotificationPolicyDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"organizationId":{"type":"integer","format":"int32"},"name":{"type":"string","description":"Human-readable name for this policy"},"matchRules":{"type":"array","description":"Match rules (all must pass; empty = catch-all)","items":{"$ref":"#/components/schemas/MatchRule"}},"escalation":{"$ref":"#/components/schemas/EscalationChain"},"enabled":{"type":"boolean","description":"Whether this policy is active"},"priority":{"type":"integer","description":"Evaluation order; higher value = evaluated first","format":"int32"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"description":"Org-level notification policy with match rules and escalation chain"},"SingleValueResponseNotificationPolicyDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/NotificationPolicyDto"}}},"ConfirmationPolicy":{"required":["type"],"type":"object","properties":{"type":{"type":"string","enum":["multi_region"]},"minRegionsFailing":{"type":"integer","format":"int32"},"maxWaitSeconds":{"type":"integer","format":"int32"}},"description":"Multi-region confirmation settings"},"IncidentPolicyDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"monitorId":{"type":"string","format":"uuid"},"triggerRules":{"type":"array","description":"Array of trigger rules defining when an incident should be raised","items":{"$ref":"#/components/schemas/TriggerRule"}},"confirmation":{"$ref":"#/components/schemas/ConfirmationPolicy"},"recovery":{"$ref":"#/components/schemas/RecoveryPolicy"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"monitorRegionCount":{"type":"integer","description":"Number of regions configured on the monitor (only set in internal API responses)","format":"int32","nullable":true},"checkFrequencySeconds":{"type":"integer","description":"Monitor check frequency in seconds (only set in internal API responses)","format":"int32","nullable":true}},"description":"Incident detection, confirmation, and recovery policy for a monitor"},"RecoveryPolicy":{"type":"object","properties":{"consecutiveSuccesses":{"type":"integer","format":"int32"},"minRegionsPassing":{"type":"integer","format":"int32"},"cooldownMinutes":{"type":"integer","format":"int32"}},"description":"Auto-recovery settings"},"TriggerRule":{"required":["scope","severity","type"],"type":"object","properties":{"type":{"type":"string","enum":["consecutive_failures","failures_in_window","response_time"]},"count":{"type":"integer","format":"int32","nullable":true},"windowMinutes":{"type":"integer","format":"int32","nullable":true},"scope":{"type":"string","nullable":true,"enum":["per_region","any_region"]},"thresholdMs":{"type":"integer","format":"int32","nullable":true},"severity":{"type":"string","enum":["down","degraded"]},"aggregationType":{"type":"string","nullable":true,"enum":["all_exceed","average","p95","max"]}},"description":"Array of trigger rules defining when an incident should be raised"},"UpdateIncidentPolicyRequest":{"required":["confirmation","recovery","triggerRules"],"type":"object","properties":{"triggerRules":{"minItems":1,"type":"array","description":"Array of trigger rules; at least one required","items":{"$ref":"#/components/schemas/TriggerRule"}},"confirmation":{"$ref":"#/components/schemas/ConfirmationPolicy"},"recovery":{"$ref":"#/components/schemas/RecoveryPolicy"}},"description":"Request body for updating an incident policy"},"SingleValueResponseIncidentPolicyDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IncidentPolicyDto"}}},"ApiKeyAuthConfig":{"required":["headerName"],"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorAuthConfig"},{"type":"object","properties":{"headerName":{"minLength":1,"pattern":"^[A-Za-z0-9\\-_]+$","type":"string"},"vaultSecretId":{"type":"string","format":"uuid","nullable":true}}}]},"BasicAuthConfig":{"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorAuthConfig"},{"type":"object","properties":{"vaultSecretId":{"type":"string","format":"uuid","nullable":true}}}]},"BearerAuthConfig":{"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorAuthConfig"},{"type":"object","properties":{"vaultSecretId":{"type":"string","format":"uuid","nullable":true}}}]},"HeaderAuthConfig":{"required":["headerName"],"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorAuthConfig"},{"type":"object","properties":{"headerName":{"minLength":1,"pattern":"^[A-Za-z0-9\\-_]+$","type":"string"},"vaultSecretId":{"type":"string","format":"uuid","nullable":true}}}]},"MonitorAuthConfig":{"required":["type"],"type":"object","properties":{"type":{"type":"string"}},"discriminator":{"propertyName":"type"}},"UpdateMonitorAuthRequest":{"required":["config"],"type":"object","properties":{"config":{"oneOf":[{"$ref":"#/components/schemas/ApiKeyAuthConfig"},{"$ref":"#/components/schemas/BasicAuthConfig"},{"$ref":"#/components/schemas/BearerAuthConfig"},{"$ref":"#/components/schemas/HeaderAuthConfig"}]}}},"MonitorAuthDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"monitorId":{"type":"string","format":"uuid"},"authType":{"type":"string","enum":["bearer","basic","header","api_key"]},"config":{"oneOf":[{"$ref":"#/components/schemas/ApiKeyAuthConfig"},{"$ref":"#/components/schemas/BasicAuthConfig"},{"$ref":"#/components/schemas/BearerAuthConfig"},{"$ref":"#/components/schemas/HeaderAuthConfig"}]}}},"SingleValueResponseMonitorAuthDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MonitorAuthDto"}}},"AssertionConfig":{"required":["type"],"type":"object","properties":{"type":{"type":"string"}},"discriminator":{"propertyName":"type"}},"BodyContainsAssertion":{"required":["substring"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"substring":{"minLength":1,"type":"string"}}}]},"DnsExpectedCnameAssertion":{"required":["value"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"value":{"minLength":1,"type":"string"}}}]},"DnsExpectedIpsAssertion":{"required":["ips"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"ips":{"minItems":1,"type":"array","items":{"type":"string"}}}}]},"DnsMaxAnswersAssertion":{"required":["recordType"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"recordType":{"minLength":1,"type":"string"},"max":{"type":"integer","format":"int32"}}}]},"DnsMinAnswersAssertion":{"required":["recordType"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"recordType":{"minLength":1,"type":"string"},"min":{"type":"integer","format":"int32"}}}]},"DnsRecordContainsAssertion":{"required":["recordType","substring"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"recordType":{"minLength":1,"type":"string"},"substring":{"minLength":1,"type":"string"}}}]},"DnsRecordEqualsAssertion":{"required":["recordType","value"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"recordType":{"minLength":1,"type":"string"},"value":{"minLength":1,"type":"string"}}}]},"DnsResolvesAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"}]},"DnsResponseTimeAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxMs":{"type":"integer","format":"int32"}}}]},"DnsResponseTimeWarnAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"warnMs":{"type":"integer","format":"int32"}}}]},"DnsTtlHighAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxTtl":{"type":"integer","format":"int32"}}}]},"DnsTtlLowAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"minTtl":{"type":"integer","format":"int32"}}}]},"DnsTxtContainsAssertion":{"required":["substring"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"substring":{"minLength":1,"type":"string"}}}]},"HeaderValueAssertion":{"required":["expected","headerName","operator"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"headerName":{"minLength":1,"type":"string"},"expected":{"minLength":1,"type":"string"},"operator":{"type":"string","enum":["equals","contains","less_than","greater_than","matches","range"]}}}]},"HeartbeatIntervalDriftAssertion":{"required":["maxDeviationPercent"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxDeviationPercent":{"maximum":100,"minimum":1,"type":"integer","format":"int32"}}}]},"HeartbeatMaxIntervalAssertion":{"required":["maxSeconds"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxSeconds":{"minimum":1,"type":"integer","format":"int32"}}}]},"HeartbeatPayloadContainsAssertion":{"required":["path","value"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"path":{"minLength":1,"type":"string"},"value":{"type":"string"}}}]},"HeartbeatReceivedAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"}]},"IcmpPacketLossAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxPercent":{"maximum":100.0,"exclusiveMaximum":false,"minimum":0.0,"exclusiveMinimum":false,"type":"number","format":"double"}}}]},"IcmpReachableAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"}]},"IcmpResponseTimeAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxMs":{"type":"integer","format":"int32"}}}]},"IcmpResponseTimeWarnAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"warnMs":{"type":"integer","format":"int32"}}}]},"JsonPathAssertion":{"required":["expected","operator","path"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"path":{"minLength":1,"type":"string"},"expected":{"minLength":1,"type":"string"},"operator":{"type":"string","enum":["equals","contains","less_than","greater_than","matches","range"]}}}]},"McpConnectsAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"}]},"McpHasCapabilityAssertion":{"required":["capability"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"capability":{"minLength":1,"type":"string"}}}]},"McpMinToolsAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"min":{"type":"integer","format":"int32"}}}]},"McpProtocolVersionAssertion":{"required":["version"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"version":{"minLength":1,"type":"string"}}}]},"McpResponseTimeAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxMs":{"type":"integer","format":"int32"}}}]},"McpResponseTimeWarnAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"warnMs":{"type":"integer","format":"int32"}}}]},"McpToolAvailableAssertion":{"required":["toolName"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"toolName":{"minLength":1,"type":"string"}}}]},"McpToolCountChangedAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"expectedCount":{"type":"integer","format":"int32"}}}]},"RedirectCountAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxCount":{"type":"integer","format":"int32"}}}]},"RedirectTargetAssertion":{"required":["expected","operator"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"expected":{"minLength":1,"type":"string"},"operator":{"type":"string","enum":["equals","contains","less_than","greater_than","matches","range"]}}}]},"RegexBodyAssertion":{"required":["pattern"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"pattern":{"minLength":1,"type":"string"}}}]},"ResponseSizeAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxBytes":{"type":"integer","format":"int32"}}}]},"ResponseTimeAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"thresholdMs":{"type":"integer","format":"int32"}}}]},"ResponseTimeWarnAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"warnMs":{"type":"integer","format":"int32"}}}]},"SslExpiryAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"minDaysRemaining":{"type":"integer","format":"int32"}}}]},"StatusCodeAssertion":{"required":["expected","operator"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"expected":{"minLength":1,"type":"string"},"operator":{"type":"string","enum":["equals","contains","less_than","greater_than","matches","range"]}}}]},"TcpConnectsAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"}]},"TcpResponseTimeAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxMs":{"type":"integer","format":"int32"}}}]},"TcpResponseTimeWarnAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"warnMs":{"type":"integer","format":"int32"}}}]},"UpdateAssertionRequest":{"required":["config"],"type":"object","properties":{"config":{"oneOf":[{"$ref":"#/components/schemas/BodyContainsAssertion"},{"$ref":"#/components/schemas/DnsExpectedCnameAssertion"},{"$ref":"#/components/schemas/DnsExpectedIpsAssertion"},{"$ref":"#/components/schemas/DnsMaxAnswersAssertion"},{"$ref":"#/components/schemas/DnsMinAnswersAssertion"},{"$ref":"#/components/schemas/DnsRecordContainsAssertion"},{"$ref":"#/components/schemas/DnsRecordEqualsAssertion"},{"$ref":"#/components/schemas/DnsResolvesAssertion"},{"$ref":"#/components/schemas/DnsResponseTimeAssertion"},{"$ref":"#/components/schemas/DnsResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/DnsTtlHighAssertion"},{"$ref":"#/components/schemas/DnsTtlLowAssertion"},{"$ref":"#/components/schemas/DnsTxtContainsAssertion"},{"$ref":"#/components/schemas/HeaderValueAssertion"},{"$ref":"#/components/schemas/HeartbeatIntervalDriftAssertion"},{"$ref":"#/components/schemas/HeartbeatMaxIntervalAssertion"},{"$ref":"#/components/schemas/HeartbeatPayloadContainsAssertion"},{"$ref":"#/components/schemas/HeartbeatReceivedAssertion"},{"$ref":"#/components/schemas/IcmpPacketLossAssertion"},{"$ref":"#/components/schemas/IcmpReachableAssertion"},{"$ref":"#/components/schemas/IcmpResponseTimeAssertion"},{"$ref":"#/components/schemas/IcmpResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/JsonPathAssertion"},{"$ref":"#/components/schemas/McpConnectsAssertion"},{"$ref":"#/components/schemas/McpHasCapabilityAssertion"},{"$ref":"#/components/schemas/McpMinToolsAssertion"},{"$ref":"#/components/schemas/McpProtocolVersionAssertion"},{"$ref":"#/components/schemas/McpResponseTimeAssertion"},{"$ref":"#/components/schemas/McpResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/McpToolAvailableAssertion"},{"$ref":"#/components/schemas/McpToolCountChangedAssertion"},{"$ref":"#/components/schemas/RedirectCountAssertion"},{"$ref":"#/components/schemas/RedirectTargetAssertion"},{"$ref":"#/components/schemas/RegexBodyAssertion"},{"$ref":"#/components/schemas/ResponseSizeAssertion"},{"$ref":"#/components/schemas/ResponseTimeAssertion"},{"$ref":"#/components/schemas/ResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/SslExpiryAssertion"},{"$ref":"#/components/schemas/StatusCodeAssertion"},{"$ref":"#/components/schemas/TcpConnectsAssertion"},{"$ref":"#/components/schemas/TcpResponseTimeAssertion"},{"$ref":"#/components/schemas/TcpResponseTimeWarnAssertion"}]},"severity":{"type":"string","enum":["fail","warn"]}}},"MonitorAssertionDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"monitorId":{"type":"string","format":"uuid"},"assertionType":{"type":"string","enum":["status_code","response_time","body_contains","json_path","header","regex","dns_resolves","dns_response_time","dns_expected_ips","dns_expected_cname","dns_record_contains","dns_record_equals","dns_txt_contains","dns_min_answers","dns_max_answers","dns_response_time_warn","dns_ttl_low","dns_ttl_high","mcp_connects","mcp_response_time","mcp_has_capability","mcp_tool_available","mcp_min_tools","mcp_protocol_version","mcp_response_time_warn","mcp_tool_count_changed","ssl_expiry","response_size","redirect_count","redirect_target","response_time_warn","tcp_connects","tcp_response_time","tcp_response_time_warn","icmp_reachable","icmp_response_time","icmp_response_time_warn","icmp_packet_loss","heartbeat_received","heartbeat_max_interval","heartbeat_interval_drift","heartbeat_payload_contains"]},"config":{"oneOf":[{"$ref":"#/components/schemas/BodyContainsAssertion"},{"$ref":"#/components/schemas/DnsExpectedCnameAssertion"},{"$ref":"#/components/schemas/DnsExpectedIpsAssertion"},{"$ref":"#/components/schemas/DnsMaxAnswersAssertion"},{"$ref":"#/components/schemas/DnsMinAnswersAssertion"},{"$ref":"#/components/schemas/DnsRecordContainsAssertion"},{"$ref":"#/components/schemas/DnsRecordEqualsAssertion"},{"$ref":"#/components/schemas/DnsResolvesAssertion"},{"$ref":"#/components/schemas/DnsResponseTimeAssertion"},{"$ref":"#/components/schemas/DnsResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/DnsTtlHighAssertion"},{"$ref":"#/components/schemas/DnsTtlLowAssertion"},{"$ref":"#/components/schemas/DnsTxtContainsAssertion"},{"$ref":"#/components/schemas/HeaderValueAssertion"},{"$ref":"#/components/schemas/HeartbeatIntervalDriftAssertion"},{"$ref":"#/components/schemas/HeartbeatMaxIntervalAssertion"},{"$ref":"#/components/schemas/HeartbeatPayloadContainsAssertion"},{"$ref":"#/components/schemas/HeartbeatReceivedAssertion"},{"$ref":"#/components/schemas/IcmpPacketLossAssertion"},{"$ref":"#/components/schemas/IcmpReachableAssertion"},{"$ref":"#/components/schemas/IcmpResponseTimeAssertion"},{"$ref":"#/components/schemas/IcmpResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/JsonPathAssertion"},{"$ref":"#/components/schemas/McpConnectsAssertion"},{"$ref":"#/components/schemas/McpHasCapabilityAssertion"},{"$ref":"#/components/schemas/McpMinToolsAssertion"},{"$ref":"#/components/schemas/McpProtocolVersionAssertion"},{"$ref":"#/components/schemas/McpResponseTimeAssertion"},{"$ref":"#/components/schemas/McpResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/McpToolAvailableAssertion"},{"$ref":"#/components/schemas/McpToolCountChangedAssertion"},{"$ref":"#/components/schemas/RedirectCountAssertion"},{"$ref":"#/components/schemas/RedirectTargetAssertion"},{"$ref":"#/components/schemas/RegexBodyAssertion"},{"$ref":"#/components/schemas/ResponseSizeAssertion"},{"$ref":"#/components/schemas/ResponseTimeAssertion"},{"$ref":"#/components/schemas/ResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/SslExpiryAssertion"},{"$ref":"#/components/schemas/StatusCodeAssertion"},{"$ref":"#/components/schemas/TcpConnectsAssertion"},{"$ref":"#/components/schemas/TcpResponseTimeAssertion"},{"$ref":"#/components/schemas/TcpResponseTimeWarnAssertion"}]},"severity":{"type":"string","enum":["fail","warn"]}}},"SingleValueResponseMonitorAssertionDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MonitorAssertionDto"}}},"SetAlertChannelsRequest":{"required":["channelIds"],"type":"object","properties":{"channelIds":{"type":"array","items":{"type":"string","format":"uuid"}}}},"SingleValueResponseListUUID":{"type":"object","properties":{"data":{"type":"array","items":{"type":"string","format":"uuid"}}}},"AddMonitorTagsRequest":{"type":"object","properties":{"tagIds":{"type":"array","description":"IDs of existing org tags to attach","nullable":true,"items":{"type":"string","description":"IDs of existing org tags to attach","format":"uuid","nullable":true}},"newTags":{"type":"array","description":"New tags to create (if not already present) and attach","nullable":true,"items":{"$ref":"#/components/schemas/NewTagRequest"}}},"description":"Request body for adding tags to a monitor. Provide existing tag IDs, inline new tags, or both."},"CreateAssertionRequest":{"required":["config"],"type":"object","properties":{"config":{"oneOf":[{"$ref":"#/components/schemas/BodyContainsAssertion"},{"$ref":"#/components/schemas/DnsExpectedCnameAssertion"},{"$ref":"#/components/schemas/DnsExpectedIpsAssertion"},{"$ref":"#/components/schemas/DnsMaxAnswersAssertion"},{"$ref":"#/components/schemas/DnsMinAnswersAssertion"},{"$ref":"#/components/schemas/DnsRecordContainsAssertion"},{"$ref":"#/components/schemas/DnsRecordEqualsAssertion"},{"$ref":"#/components/schemas/DnsResolvesAssertion"},{"$ref":"#/components/schemas/DnsResponseTimeAssertion"},{"$ref":"#/components/schemas/DnsResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/DnsTtlHighAssertion"},{"$ref":"#/components/schemas/DnsTtlLowAssertion"},{"$ref":"#/components/schemas/DnsTxtContainsAssertion"},{"$ref":"#/components/schemas/HeaderValueAssertion"},{"$ref":"#/components/schemas/HeartbeatIntervalDriftAssertion"},{"$ref":"#/components/schemas/HeartbeatMaxIntervalAssertion"},{"$ref":"#/components/schemas/HeartbeatPayloadContainsAssertion"},{"$ref":"#/components/schemas/HeartbeatReceivedAssertion"},{"$ref":"#/components/schemas/IcmpPacketLossAssertion"},{"$ref":"#/components/schemas/IcmpReachableAssertion"},{"$ref":"#/components/schemas/IcmpResponseTimeAssertion"},{"$ref":"#/components/schemas/IcmpResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/JsonPathAssertion"},{"$ref":"#/components/schemas/McpConnectsAssertion"},{"$ref":"#/components/schemas/McpHasCapabilityAssertion"},{"$ref":"#/components/schemas/McpMinToolsAssertion"},{"$ref":"#/components/schemas/McpProtocolVersionAssertion"},{"$ref":"#/components/schemas/McpResponseTimeAssertion"},{"$ref":"#/components/schemas/McpResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/McpToolAvailableAssertion"},{"$ref":"#/components/schemas/McpToolCountChangedAssertion"},{"$ref":"#/components/schemas/RedirectCountAssertion"},{"$ref":"#/components/schemas/RedirectTargetAssertion"},{"$ref":"#/components/schemas/RegexBodyAssertion"},{"$ref":"#/components/schemas/ResponseSizeAssertion"},{"$ref":"#/components/schemas/ResponseTimeAssertion"},{"$ref":"#/components/schemas/ResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/SslExpiryAssertion"},{"$ref":"#/components/schemas/StatusCodeAssertion"},{"$ref":"#/components/schemas/TcpConnectsAssertion"},{"$ref":"#/components/schemas/TcpResponseTimeAssertion"},{"$ref":"#/components/schemas/TcpResponseTimeWarnAssertion"}]},"severity":{"type":"string","enum":["fail","warn"]}},"description":"Replace all assertions; null preserves current"},"DnsMonitorConfig":{"required":["hostname"],"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorConfig"},{"type":"object","properties":{"hostname":{"minLength":1,"type":"string","description":"Domain name to resolve"},"recordTypes":{"type":"array","description":"DNS record types to query: A, AAAA, CNAME, MX, NS, TXT, SRV, SOA, CAA, PTR","nullable":true,"items":{"type":"string","description":"DNS record types to query: A, AAAA, CNAME, MX, NS, TXT, SRV, SOA, CAA, PTR","nullable":true,"enum":["A","AAAA","CNAME","MX","NS","TXT","SRV","SOA","CAA","PTR"]}},"nameservers":{"type":"array","description":"Custom nameservers to query (uses system defaults if omitted)","nullable":true,"items":{"type":"string","description":"Custom nameservers to query (uses system defaults if omitted)","nullable":true}},"timeoutMs":{"type":"integer","description":"Per-query timeout in milliseconds","format":"int32","nullable":true},"totalTimeoutMs":{"type":"integer","description":"Total timeout for all queries in milliseconds","format":"int32","nullable":true}}}]},"HeartbeatMonitorConfig":{"required":["expectedInterval","gracePeriod"],"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorConfig"},{"type":"object","properties":{"expectedInterval":{"maximum":86400,"minimum":1,"type":"integer","description":"Expected heartbeat interval in seconds","format":"int32"},"gracePeriod":{"minimum":1,"type":"integer","description":"Grace period in seconds before marking as down","format":"int32"}}}]},"HttpMonitorConfig":{"required":["method","url"],"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorConfig"},{"type":"object","properties":{"url":{"minLength":1,"type":"string","description":"Target URL to send requests to"},"method":{"type":"string","description":"HTTP method: GET, POST, PUT, PATCH, DELETE, or HEAD","enum":["GET","POST","PUT","PATCH","DELETE","HEAD"]},"customHeaders":{"type":"object","additionalProperties":{"type":"string","description":"Additional HTTP headers to include in requests","nullable":true},"description":"Additional HTTP headers to include in requests","nullable":true},"requestBody":{"type":"string","description":"Request body content for POST/PUT/PATCH methods","nullable":true},"contentType":{"type":"string","description":"Content-Type header value for the request body","nullable":true},"verifyTls":{"type":"boolean","description":"Whether to verify TLS certificates (default: true)","nullable":true}}}]},"IcmpMonitorConfig":{"required":["host"],"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorConfig"},{"type":"object","properties":{"host":{"minLength":1,"type":"string","description":"Target hostname or IP address to ping"},"packetCount":{"maximum":20,"minimum":1,"type":"integer","description":"Number of ICMP packets to send","format":"int32","nullable":true},"timeoutMs":{"type":"integer","description":"Ping timeout in milliseconds","format":"int32","nullable":true}}}]},"McpServerMonitorConfig":{"required":["command"],"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorConfig"},{"type":"object","properties":{"command":{"minLength":1,"type":"string","description":"Command to execute to start the MCP server"},"args":{"type":"array","description":"Command-line arguments for the MCP server process","nullable":true,"items":{"type":"string","description":"Command-line arguments for the MCP server process","nullable":true}},"env":{"type":"object","additionalProperties":{"type":"string","description":"Environment variables to pass to the MCP server process","nullable":true},"description":"Environment variables to pass to the MCP server process","nullable":true}}}]},"MonitorConfig":{"type":"object","description":"Updated protocol-specific configuration; null preserves current"},"NewTagRequest":{"required":["name"],"type":"object","properties":{"name":{"maxLength":100,"minLength":0,"type":"string","description":"Tag name"},"color":{"pattern":"^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$","type":"string","description":"Hex color code (defaults to #6B7280 if omitted)","nullable":true}},"description":"Inline tag creation — creates the tag if it does not already exist"},"TcpMonitorConfig":{"required":["host"],"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorConfig"},{"type":"object","properties":{"host":{"minLength":1,"type":"string","description":"Target hostname or IP address"},"port":{"maximum":65535,"minimum":1,"type":"integer","description":"TCP port to connect to","format":"int32"},"timeoutMs":{"type":"integer","description":"Connection timeout in milliseconds","format":"int32","nullable":true}}}]},"UpdateMonitorRequest":{"type":"object","properties":{"name":{"maxLength":255,"minLength":0,"type":"string","description":"New monitor name; null preserves current","nullable":true},"config":{"oneOf":[{"$ref":"#/components/schemas/DnsMonitorConfig"},{"$ref":"#/components/schemas/HeartbeatMonitorConfig"},{"$ref":"#/components/schemas/HttpMonitorConfig"},{"$ref":"#/components/schemas/IcmpMonitorConfig"},{"$ref":"#/components/schemas/McpServerMonitorConfig"},{"$ref":"#/components/schemas/TcpMonitorConfig"}]},"frequencySeconds":{"type":"integer","description":"New check frequency in seconds (30–86400); null preserves current","format":"int32","nullable":true},"enabled":{"type":"boolean","description":"Enable or disable the monitor; null preserves current","nullable":true},"regions":{"type":"array","description":"New probe regions; null preserves current","nullable":true,"items":{"type":"string","description":"New probe regions; null preserves current","nullable":true}},"managedBy":{"type":"string","description":"New management source; null preserves current","nullable":true,"enum":["DASHBOARD","CLI"]},"environmentId":{"type":"string","description":"New environment ID; null preserves current (use clearEnvironmentId to unset)","format":"uuid","nullable":true},"clearEnvironmentId":{"type":"boolean","description":"Set to true to remove the environment association","nullable":true},"assertions":{"type":"array","description":"Replace all assertions; null preserves current","nullable":true,"items":{"$ref":"#/components/schemas/CreateAssertionRequest"}},"auth":{"oneOf":[{"$ref":"#/components/schemas/ApiKeyAuthConfig"},{"$ref":"#/components/schemas/BasicAuthConfig"},{"$ref":"#/components/schemas/BearerAuthConfig"},{"$ref":"#/components/schemas/HeaderAuthConfig"}]},"clearAuth":{"type":"boolean","description":"Set to true to remove authentication","nullable":true},"incidentPolicy":{"$ref":"#/components/schemas/UpdateIncidentPolicyRequest"},"alertChannelIds":{"type":"array","description":"Replace alert channel list; null preserves current","nullable":true,"items":{"type":"string","description":"Replace alert channel list; null preserves current","format":"uuid","nullable":true}},"tags":{"$ref":"#/components/schemas/AddMonitorTagsRequest"}}},"MonitorDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"organizationId":{"type":"integer","format":"int32"},"name":{"type":"string"},"type":{"type":"string","enum":["HTTP","DNS","MCP_SERVER","TCP","ICMP","HEARTBEAT"]},"config":{"oneOf":[{"$ref":"#/components/schemas/DnsMonitorConfig"},{"$ref":"#/components/schemas/HeartbeatMonitorConfig"},{"$ref":"#/components/schemas/HttpMonitorConfig"},{"$ref":"#/components/schemas/IcmpMonitorConfig"},{"$ref":"#/components/schemas/McpServerMonitorConfig"},{"$ref":"#/components/schemas/TcpMonitorConfig"}]},"frequencySeconds":{"type":"integer","format":"int32"},"enabled":{"type":"boolean"},"regions":{"type":"array","items":{"type":"string"}},"managedBy":{"type":"string","enum":["DASHBOARD","CLI"]},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"assertions":{"type":"array","nullable":true,"items":{"$ref":"#/components/schemas/MonitorAssertionDto"}},"tags":{"type":"array","nullable":true,"items":{"$ref":"#/components/schemas/TagDto"}},"pingUrl":{"type":"string","nullable":true},"environment":{"$ref":"#/components/schemas/Summary"},"auth":{"$ref":"#/components/schemas/MonitorAuthDto"},"incidentPolicy":{"$ref":"#/components/schemas/IncidentPolicyDto"},"alertChannelIds":{"type":"array","nullable":true,"items":{"type":"string","format":"uuid","nullable":true}}}},"SingleValueResponseMonitorDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MonitorDto"}}},"Summary":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"slug":{"type":"string"}}},"ChangeStatusRequest":{"required":["status"],"type":"object","properties":{"status":{"type":"string","enum":["INVITED","ACTIVE","SUSPENDED","LEFT","REMOVED","DECLINED"]}}},"UpdateMaintenanceWindowRequest":{"required":["endsAt","startsAt"],"type":"object","properties":{"monitorId":{"type":"string","format":"uuid"},"startsAt":{"type":"string","format":"date-time"},"endsAt":{"type":"string","format":"date-time"},"repeatRule":{"maxLength":100,"minLength":0,"type":"string"},"reason":{"type":"string"},"suppressAlerts":{"type":"boolean"}}},"MaintenanceWindowDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"monitorId":{"type":"string","format":"uuid","nullable":true},"organizationId":{"type":"integer","format":"int32"},"startsAt":{"type":"string","format":"date-time"},"endsAt":{"type":"string","format":"date-time"},"repeatRule":{"type":"string","nullable":true},"reason":{"type":"string","nullable":true},"suppressAlerts":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}}},"SingleValueResponseMaintenanceWindowDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MaintenanceWindowDto"}}},"UpdateEnvironmentRequest":{"type":"object","properties":{"name":{"maxLength":100,"minLength":0,"type":"string","description":"New environment name; null preserves current","nullable":true},"variables":{"type":"object","additionalProperties":{"type":"string","description":"Replace all variables; null preserves current","nullable":true},"description":"Replace all variables; null preserves current","nullable":true},"isDefault":{"type":"boolean","description":"Whether this is the default environment; null preserves current","nullable":true}}},"EnvironmentDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"orgId":{"type":"integer","format":"int32"},"name":{"type":"string"},"slug":{"type":"string"},"variables":{"type":"object","additionalProperties":{"type":"string"}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"monitorCount":{"type":"integer","format":"int32"},"isDefault":{"type":"boolean"}}},"SingleValueResponseEnvironmentDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EnvironmentDto"}}},"ChannelConfig":{"required":["channelType"],"type":"object","properties":{"channelType":{"type":"string"}},"description":"New channel configuration (full replacement, not partial update)","discriminator":{"propertyName":"channelType"}},"DiscordChannelConfig":{"required":["webhookUrl"],"type":"object","allOf":[{"$ref":"#/components/schemas/ChannelConfig"},{"type":"object","properties":{"webhookUrl":{"minLength":1,"type":"string","description":"Discord webhook URL"},"mentionRoleId":{"type":"string","description":"Optional Discord role ID to mention in notifications","nullable":true}}}]},"EmailChannelConfig":{"required":["recipients"],"type":"object","allOf":[{"$ref":"#/components/schemas/ChannelConfig"},{"type":"object","properties":{"recipients":{"minItems":1,"type":"array","description":"Email addresses to send notifications to","items":{"type":"string","description":"Email addresses to send notifications to","format":"email"}}}}]},"OpsGenieChannelConfig":{"required":["apiKey"],"type":"object","allOf":[{"$ref":"#/components/schemas/ChannelConfig"},{"type":"object","properties":{"apiKey":{"minLength":1,"type":"string","description":"OpsGenie API key for alert creation"},"region":{"type":"string","description":"OpsGenie API region: us or eu","nullable":true}}}]},"PagerDutyChannelConfig":{"required":["routingKey"],"type":"object","allOf":[{"$ref":"#/components/schemas/ChannelConfig"},{"type":"object","properties":{"routingKey":{"minLength":1,"type":"string","description":"PagerDuty Events API v2 routing (integration) key"},"severityOverride":{"type":"string","description":"Override PagerDuty severity mapping","nullable":true}}}]},"SlackChannelConfig":{"required":["webhookUrl"],"type":"object","allOf":[{"$ref":"#/components/schemas/ChannelConfig"},{"type":"object","properties":{"webhookUrl":{"minLength":1,"type":"string","description":"Slack incoming webhook URL"},"mentionText":{"type":"string","description":"Optional mention text included in notifications, e.g. @channel","nullable":true}}}]},"TeamsChannelConfig":{"required":["webhookUrl"],"type":"object","allOf":[{"$ref":"#/components/schemas/ChannelConfig"},{"type":"object","properties":{"webhookUrl":{"minLength":1,"type":"string","description":"Microsoft Teams incoming webhook URL"}}}]},"UpdateAlertChannelRequest":{"required":["config","name"],"type":"object","properties":{"name":{"maxLength":255,"minLength":0,"type":"string","description":"New channel name (full replacement, not partial update)"},"config":{"oneOf":[{"$ref":"#/components/schemas/DiscordChannelConfig"},{"$ref":"#/components/schemas/EmailChannelConfig"},{"$ref":"#/components/schemas/OpsGenieChannelConfig"},{"$ref":"#/components/schemas/PagerDutyChannelConfig"},{"$ref":"#/components/schemas/SlackChannelConfig"},{"$ref":"#/components/schemas/TeamsChannelConfig"},{"$ref":"#/components/schemas/WebhookChannelConfig"}]}}},"WebhookChannelConfig":{"required":["url"],"type":"object","allOf":[{"$ref":"#/components/schemas/ChannelConfig"},{"type":"object","properties":{"url":{"minLength":1,"type":"string","description":"Webhook endpoint URL that receives alert payloads"},"signingSecret":{"type":"string","description":"Optional HMAC signing secret for payload verification","nullable":true},"customHeaders":{"type":"object","additionalProperties":{"type":"string","description":"Additional HTTP headers to include in webhook requests","nullable":true},"description":"Additional HTTP headers to include in webhook requests","nullable":true}}}]},"AlertChannelDto":{"required":["channelType","createdAt","id","name","updatedAt"],"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"channelType":{"type":"string","enum":["email","webhook","slack","pagerduty","opsgenie","teams","discord"]},"displayConfig":{"type":"object","additionalProperties":{"type":"object","nullable":true},"nullable":true},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"lastDeliveryAt":{"type":"string","format":"date-time","nullable":true},"lastDeliveryStatus":{"type":"string","nullable":true}}},"SingleValueResponseAlertChannelDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AlertChannelDto"}}},"WorkspaceCreateParams":{"required":["name"],"type":"object","properties":{"organizationId":{"type":"integer","format":"int32"},"name":{"minLength":1,"type":"string"}}},"ServiceIncidentRequest":{"required":["action","externalRef","serviceId","title"],"type":"object","properties":{"serviceId":{"type":"string","format":"uuid"},"externalRef":{"minLength":1,"type":"string"},"severity":{"type":"string","nullable":true},"title":{"minLength":1,"type":"string"},"shortlink":{"type":"string","nullable":true},"affectedComponents":{"type":"array","nullable":true,"items":{"type":"string","nullable":true}},"serviceIncidentId":{"type":"string","format":"uuid","nullable":true},"action":{"minLength":1,"type":"string"},"statusText":{"type":"string","nullable":true}}},"IncidentDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"monitorId":{"type":"string","format":"uuid","nullable":true},"organizationId":{"type":"integer","format":"int32"},"source":{"type":"string","enum":["AUTOMATIC","MANUAL","MONITORS","STATUS_DATA","RESOURCE_GROUP"]},"status":{"type":"string","enum":["WATCHING","TRIGGERED","CONFIRMED","RESOLVED"]},"severity":{"type":"string","enum":["DOWN","DEGRADED","MAINTENANCE"]},"title":{"type":"string","nullable":true},"triggeredByRule":{"type":"string","nullable":true},"affectedRegions":{"type":"array","items":{"type":"string"}},"reopenCount":{"type":"integer","format":"int32"},"createdByUserId":{"type":"integer","format":"int32","nullable":true},"statusPageVisible":{"type":"boolean"},"serviceIncidentId":{"type":"string","format":"uuid","nullable":true},"serviceId":{"type":"string","format":"uuid","nullable":true},"externalRef":{"type":"string","nullable":true},"affectedComponents":{"type":"array","nullable":true,"items":{"type":"string","nullable":true}},"shortlink":{"type":"string","nullable":true},"resolutionReason":{"type":"string","nullable":true,"enum":["MANUAL","AUTO_RECOVERED","AUTO_RESOLVED"]},"startedAt":{"type":"string","format":"date-time","nullable":true},"confirmedAt":{"type":"string","format":"date-time","nullable":true},"resolvedAt":{"type":"string","format":"date-time","nullable":true},"cooldownUntil":{"type":"string","format":"date-time","nullable":true},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"monitorName":{"type":"string","nullable":true},"serviceName":{"type":"string","nullable":true},"serviceSlug":{"type":"string","nullable":true},"monitorType":{"type":"string","nullable":true},"resourceGroupId":{"type":"string","format":"uuid","nullable":true},"resourceGroupName":{"type":"string","nullable":true}}},"TableValueResultIncidentDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/IncidentDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"SingleValueResponseInteger":{"type":"object","properties":{"data":{"type":"integer","format":"int32"}}},"CreateAutoIncidentRequest":{"required":["monitorId"],"type":"object","properties":{"monitorId":{"type":"string","format":"uuid"},"severity":{"type":"string","nullable":true},"triggeredByRule":{"type":"string","nullable":true},"affectedRegions":{"type":"array","nullable":true,"items":{"type":"string","nullable":true}},"startedAt":{"type":"string","format":"date-time","nullable":true}}},"SingleValueResponseIncidentDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IncidentDto"}}},"ReopenAutoIncidentRequest":{"type":"object","properties":{"affectedRegions":{"type":"array","items":{"type":"string"}},"severity":{"type":"string","nullable":true}}},"AdapterHealthReportRequest":{"required":["serviceId","success"],"type":"object","properties":{"serviceId":{"type":"string","format":"uuid"},"success":{"type":"boolean"},"errorMessage":{"type":"string","nullable":true}}},"AdapterHealthDto":{"type":"object","properties":{"serviceId":{"type":"string","format":"uuid"},"serviceSlug":{"type":"string"},"serviceName":{"type":"string"},"adapterType":{"type":"string","nullable":true},"lastSuccessAt":{"type":"string","format":"date-time","nullable":true},"lastFailureAt":{"type":"string","format":"date-time","nullable":true},"consecutiveFailures":{"type":"integer","format":"int32"},"lastErrorMessage":{"type":"string","nullable":true},"disabledByHealth":{"type":"boolean"},"updatedAt":{"type":"string","format":"date-time"}}},"SingleValueResponseAdapterHealthDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AdapterHealthDto"}}},"CreateOrgRequest":{"required":["name"],"type":"object","properties":{"name":{"minLength":1,"type":"string"},"email":{"type":"string","format":"email","nullable":true}}},"SingleValueResponseTransactionDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TransactionDto"}}},"TransactionDto":{"type":"object","properties":{"id":{"type":"string"},"status":{"type":"string","nullable":true},"currencyCode":{"type":"string","nullable":true},"invoiceNumber":{"type":"string","nullable":true},"billedAt":{"type":"string","format":"date-time","nullable":true},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"total":{"type":"string","nullable":true},"subtotal":{"type":"string","nullable":true},"tax":{"type":"string","nullable":true}}},"QuickMonitorRequest":{"required":["url"],"type":"object","properties":{"url":{"minLength":1,"type":"string"},"name":{"type":"string","nullable":true},"frequencySeconds":{"type":"integer","format":"int32","nullable":true}}},"OnboardingSetupRequest":{"required":["name"],"type":"object","properties":{"name":{"maxLength":200,"minLength":0,"type":"string"},"role":{"maxLength":50,"minLength":0,"type":"string","nullable":true},"teamSize":{"maxLength":50,"minLength":0,"type":"string","nullable":true}}},"AnalyzeUrlRequest":{"required":["url"],"type":"object","properties":{"url":{"minLength":1,"type":"string"}}},"AnalyzeUrlResponse":{"type":"object","properties":{"reachable":{"type":"boolean"},"responseTimeMs":{"type":"integer","format":"int64"},"statusCode":{"type":"integer","format":"int32"},"tlsExpiry":{"type":"string","format":"date-time","nullable":true},"tlsDaysRemaining":{"type":"integer","format":"int32","nullable":true},"contentType":{"type":"string","nullable":true},"suggestedName":{"type":"string"},"suggestedAssertions":{"type":"array","items":{"$ref":"#/components/schemas/SuggestedAssertion"}},"suggestedFrequencySeconds":{"type":"integer","format":"int32"}}},"SingleValueResponseAnalyzeUrlResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AnalyzeUrlResponse"}}},"SuggestedAssertion":{"type":"object","properties":{"type":{"type":"string"},"operator":{"type":"string"},"value":{"type":"string"}}},"AcceptInviteRequest":{"required":["token"],"type":"object","properties":{"token":{"minLength":1,"type":"string"}}},"AcceptInviteDto":{"type":"object","properties":{"orgId":{"type":"integer","format":"int32"},"userId":{"type":"integer","format":"int32"},"orgRole":{"type":"string","enum":["OWNER","ADMIN","MEMBER"]},"status":{"type":"string","enum":["INVITED","ACTIVE","SUSPENDED","LEFT","REMOVED","DECLINED"]}}},"SingleValueResponseAcceptInviteDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AcceptInviteDto"}}},"RegisterUserRequest":{"type":"object","properties":{"nickname":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"picture":{"type":"string","nullable":true}}},"CreateWorkspaceRequest":{"required":["name"],"type":"object","properties":{"name":{"minLength":1,"type":"string"}}},"AddMemberRequest":{"required":["orgRole","userId"],"type":"object","properties":{"userId":{"type":"integer","format":"int32"},"orgRole":{"type":"string","enum":["OWNER","ADMIN","MEMBER"]}}},"MemberDto":{"type":"object","properties":{"userId":{"type":"integer","format":"int32"},"email":{"type":"string"},"name":{"type":"string","nullable":true},"orgRole":{"type":"string","enum":["OWNER","ADMIN","MEMBER"]},"status":{"type":"string","enum":["INVITED","ACTIVE","SUSPENDED","LEFT","REMOVED","DECLINED"]},"createdAt":{"type":"string","format":"date-time"}}},"SingleValueResponseMemberDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MemberDto"}}},"CreateWebhookEndpointRequest":{"required":["subscribedEvents","url"],"type":"object","properties":{"url":{"maxLength":2048,"minLength":0,"type":"string","description":"HTTPS endpoint that receives webhook event payloads"},"description":{"maxLength":255,"minLength":0,"type":"string","description":"Optional human-readable description"},"subscribedEvents":{"minItems":1,"type":"array","description":"Event types to deliver, e.g. monitor.created, incident.resolved","items":{"minLength":1,"type":"string","description":"Event types to deliver, e.g. monitor.created, incident.resolved"}}}},"TestWebhookEndpointRequest":{"type":"object","properties":{"eventType":{"type":"string","nullable":true}}},"SingleValueResponseWebhookTestResult":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WebhookTestResult"}}},"WebhookTestResult":{"type":"object","properties":{"success":{"type":"boolean"},"statusCode":{"type":"integer","format":"int32","nullable":true},"message":{"type":"string"},"durationMs":{"type":"integer","format":"int64","nullable":true}}},"SingleValueResponseString":{"type":"object","properties":{"data":{"type":"string"}}},"DekRotationResultDto":{"type":"object","properties":{"previousDekVersion":{"type":"integer","format":"int32"},"newDekVersion":{"type":"integer","format":"int32"},"secretsReEncrypted":{"type":"integer","format":"int32"},"channelsReEncrypted":{"type":"integer","format":"int32"},"rotatedAt":{"type":"string","format":"date-time"}}},"SingleValueResponseDekRotationResultDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DekRotationResultDto"}}},"CreateTagRequest":{"required":["name"],"type":"object","properties":{"name":{"maxLength":100,"minLength":0,"type":"string","description":"Tag name, unique within the org"},"color":{"pattern":"^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$","type":"string","description":"Hex color code (defaults to #6B7280 if omitted)","nullable":true}},"description":"Request body for creating a tag"},"ServiceSubscribeRequest":{"type":"object","properties":{"componentId":{"type":"string","description":"ID of the component to subscribe to. Omit or null for whole-service subscription.","format":"uuid","nullable":true},"alertSensitivity":{"type":"string","description":"Alert sensitivity level. Defaults to INCIDENTS_ONLY when not provided.","nullable":true}},"description":"Optional body for subscribing to a specific component of a service"},"ComponentUptimeSummaryDto":{"type":"object","properties":{"day":{"type":"number","description":"Uptime percentage over the last 24 hours","format":"double","nullable":true,"example":99.95},"week":{"type":"number","description":"Uptime percentage over the last 7 days","format":"double","nullable":true,"example":99.98},"month":{"type":"number","description":"Uptime percentage over the last 30 days","format":"double","nullable":true,"example":99.92},"source":{"type":"string","description":"Data source: vendor_reported or incident_derived","example":"vendor_reported"}},"description":"Inline uptime percentages for 24h, 7d, 30d"},"ServiceComponentDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"externalId":{"type":"string"},"name":{"type":"string"},"status":{"type":"string"},"description":{"type":"string","nullable":true},"groupId":{"type":"string","format":"uuid","nullable":true},"position":{"type":"integer","format":"int32","nullable":true},"showcase":{"type":"boolean"},"onlyShowIfDegraded":{"type":"boolean"},"startDate":{"type":"string","format":"date-time","nullable":true},"vendorCreatedAt":{"type":"string","format":"date-time","nullable":true},"lifecycleStatus":{"type":"string"},"dataType":{"type":"string","description":"Data classification: full, status_only, or metric_only","example":"full"},"hasUptime":{"type":"boolean","description":"Whether uptime data is available for this component"},"region":{"type":"string","description":"Geographic region for regional components (AWS, GCP, Azure)","nullable":true},"groupName":{"type":"string","description":"Display name of the parent group","nullable":true},"uptime":{"$ref":"#/components/schemas/ComponentUptimeSummaryDto"},"statusChangedAt":{"type":"string","format":"date-time","nullable":true},"firstSeenAt":{"type":"string","format":"date-time"},"lastSeenAt":{"type":"string","format":"date-time"},"group":{"type":"boolean"}},"description":"A first-class service component with lifecycle and uptime data"},"ServiceSubscriptionDto":{"type":"object","properties":{"subscriptionId":{"type":"string","description":"Unique subscription identifier","format":"uuid"},"serviceId":{"type":"string","description":"Service identifier","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"category":{"type":"string","nullable":true},"officialStatusUrl":{"type":"string","nullable":true},"adapterType":{"type":"string"},"pollingIntervalSeconds":{"type":"integer","format":"int32"},"enabled":{"type":"boolean"},"logoUrl":{"type":"string","description":"Logo URL from the service catalog","nullable":true},"overallStatus":{"type":"string","description":"Current overall status; null when the service has never been polled","nullable":true},"componentId":{"type":"string","description":"Subscribed component id; null for whole-service subscription","format":"uuid","nullable":true},"component":{"$ref":"#/components/schemas/ServiceComponentDto"},"alertSensitivity":{"type":"string","description":"Alert sensitivity: ALL (synthetic + real incidents), INCIDENTS_ONLY (real vendor incidents, default), MAJOR_ONLY (real + DOWN severity)","enum":["ALL","INCIDENTS_ONLY","MAJOR_ONLY"]},"subscribedAt":{"type":"string","description":"When the organization subscribed to this service","format":"date-time"}},"description":"An org-level service subscription with current status information"},"SingleValueResponseServiceSubscriptionDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ServiceSubscriptionDto"}}},"CreateSecretRequest":{"required":["key","value"],"type":"object","properties":{"key":{"maxLength":255,"minLength":0,"type":"string","description":"Unique secret key within the workspace (max 255 chars)"},"value":{"maxLength":32768,"minLength":0,"type":"string","description":"Secret value, stored encrypted (max 32KB)"}}},"CreateResourceGroupRequest":{"required":["name"],"type":"object","properties":{"name":{"maxLength":255,"minLength":0,"type":"string","description":"Human-readable name for this group"},"description":{"type":"string","description":"Optional description","nullable":true},"alertPolicyId":{"type":"string","description":"Optional notification policy to apply for this group","format":"uuid","nullable":true},"defaultFrequency":{"maximum":86400,"minimum":30,"type":"integer","description":"Default check frequency in seconds applied to members (30–86400)","format":"int32","nullable":true},"defaultRegions":{"type":"array","description":"Default regions applied to member monitors","nullable":true,"items":{"type":"string","description":"Default regions applied to member monitors","nullable":true}},"defaultRetryStrategy":{"$ref":"#/components/schemas/RetryStrategy"},"defaultAlertChannels":{"type":"array","description":"Default alert channel IDs applied to member monitors","nullable":true,"items":{"type":"string","description":"Default alert channel IDs applied to member monitors","format":"uuid","nullable":true}},"defaultEnvironmentId":{"type":"string","description":"Default environment ID applied to member monitors","format":"uuid","nullable":true},"healthThresholdType":{"type":"string","description":"Health threshold type: COUNT or PERCENTAGE","nullable":true,"enum":["COUNT","PERCENTAGE"]},"healthThresholdValue":{"maximum":100,"exclusiveMaximum":false,"minimum":0,"exclusiveMinimum":false,"type":"number","description":"Health threshold value: count (0+) or percentage (0–100)","nullable":true},"suppressMemberAlerts":{"type":"boolean","description":"Suppress member-level alert notifications when group manages alerting","nullable":true},"confirmationDelaySeconds":{"maximum":600,"minimum":0,"type":"integer","description":"Confirmation delay in seconds before group incident creation (0–600)","format":"int32","nullable":true},"recoveryCooldownMinutes":{"maximum":60,"minimum":0,"type":"integer","description":"Recovery cooldown in minutes after group incident resolves (0–60)","format":"int32","nullable":true}},"description":"Request body for creating a resource group"},"AddResourceGroupMemberRequest":{"required":["memberId","memberType"],"type":"object","properties":{"memberType":{"minLength":1,"pattern":"monitor|service","type":"string","description":"Type of member: 'monitor' or 'service'"},"memberId":{"type":"string","description":"ID of the monitor or service to add","format":"uuid"}},"description":"Request body for adding a member to a resource group"},"SingleValueResponseResourceGroupMemberDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ResourceGroupMemberDto"}}},"CreateNotificationPolicyRequest":{"required":["escalation","name"],"type":"object","properties":{"name":{"maxLength":255,"minLength":0,"type":"string","description":"Human-readable name for this policy"},"matchRules":{"type":"array","description":"Match rules to evaluate (all must pass; omit or empty for catch-all)","items":{"$ref":"#/components/schemas/MatchRule"}},"escalation":{"$ref":"#/components/schemas/EscalationChain"},"enabled":{"type":"boolean","description":"Whether this policy is enabled (default true)","default":true},"priority":{"type":"integer","description":"Evaluation priority; higher value = evaluated first (default 0)","format":"int32","default":0}},"description":"Request body for creating a notification policy"},"TestNotificationPolicyRequest":{"type":"object","properties":{"severity":{"type":"string","description":"Incident severity to test against (e.g. DOWN, DEGRADED, MAINTENANCE)","nullable":true},"monitorId":{"type":"string","description":"Monitor UUID to test against (monitoring events)","format":"uuid","nullable":true},"regions":{"type":"array","description":"Affected region identifiers to test against (monitoring events)","nullable":true,"items":{"type":"string","description":"Affected region identifiers to test against (monitoring events)","nullable":true}},"eventType":{"type":"string","description":"Incident event type to test against — short form (e.g. created, resolved, reopened) or full form (e.g. incident.created)","nullable":true},"monitorType":{"type":"string","description":"Monitor check type to test against (e.g. HTTP, DNS, MCP_SERVER)","nullable":true},"serviceId":{"type":"string","description":"Service catalog UUID to test against (status data events)","format":"uuid","nullable":true},"componentName":{"type":"string","description":"Component name to test against (status data events, e.g. \"Actions\")","nullable":true},"resourceGroupIds":{"type":"array","description":"Resource group UUIDs the entity belongs to, for resource_group_id_in rules","nullable":true,"items":{"type":"string","description":"Resource group UUIDs the entity belongs to, for resource_group_id_in rules","format":"uuid","nullable":true}}},"description":"Event context for a dry-run match evaluation against a notification policy"},"SingleValueResponseTestMatchResult":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TestMatchResult"}}},"TestMatchResult":{"type":"object","properties":{"matched":{"type":"boolean","description":"Whether the policy would match the supplied incident context"},"matchedRules":{"type":"array","description":"Rules that passed evaluation","items":{"type":"string","description":"Rules that passed evaluation"}},"unmatchedRules":{"type":"array","description":"Rules that did not pass evaluation","items":{"type":"string","description":"Rules that did not pass evaluation"}}},"description":"Result of a dry-run match evaluation against a notification policy"},"AlertDeliveryDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"incidentId":{"type":"string","description":"Incident that triggered this delivery","format":"uuid"},"dispatchId":{"type":"string","description":"Notification dispatch that created this delivery","format":"uuid","nullable":true},"channelId":{"type":"string","description":"Alert channel ID","format":"uuid"},"channel":{"type":"string","description":"Human-readable channel name"},"channelType":{"type":"string","description":"Alert channel type (e.g. slack, email, webhook)"},"status":{"type":"string","description":"Current delivery status","enum":["PENDING","DELIVERED","RETRY_PENDING","FAILED","CANCELLED"]},"eventType":{"type":"string","description":"Incident lifecycle event that triggered this delivery","enum":["INCIDENT_CREATED","INCIDENT_RESOLVED","INCIDENT_REOPENED"]},"stepNumber":{"type":"integer","description":"1-based escalation step this delivery belongs to","format":"int32"},"fireCount":{"type":"integer","description":"Fire sequence within the step: 1 = initial, 2+ = repeat re-fires","format":"int32"},"attemptCount":{"type":"integer","description":"Number of delivery attempts made","format":"int32"},"lastAttemptAt":{"type":"string","description":"When the last attempt was made","format":"date-time","nullable":true},"nextRetryAt":{"type":"string","description":"When the next retry is scheduled (null if not retrying)","format":"date-time","nullable":true},"deliveredAt":{"type":"string","description":"Timestamp when the delivery was confirmed (null if not yet delivered)","format":"date-time","nullable":true},"errorMessage":{"type":"string","description":"Error message from the last failed attempt","nullable":true},"createdAt":{"type":"string","format":"date-time"}},"description":"Delivery record for a single channel within a notification dispatch"},"NotificationDispatchDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"incidentId":{"type":"string","format":"uuid"},"policyId":{"type":"string","format":"uuid"},"policyName":{"type":"string","description":"Human-readable name of the matched policy (null if policy has been deleted)","nullable":true},"status":{"type":"string","description":"Current dispatch state","enum":["PENDING","DISPATCHING","DELIVERED","ESCALATING","ACKNOWLEDGED","COMPLETED"]},"completionReason":{"type":"string","description":"Why the dispatch reached COMPLETED: EXHAUSTED (all steps ran, no ack), RESOLVED (incident resolved), NO_STEPS (policy had no steps). Null for non-terminal states.","nullable":true,"enum":["EXHAUSTED","RESOLVED","NO_STEPS"]},"currentStep":{"type":"integer","description":"1-based index of the currently active escalation step","format":"int32"},"totalSteps":{"type":"integer","description":"Total number of escalation steps in the policy (null if policy has been deleted)","format":"int32","nullable":true},"acknowledgedAt":{"type":"string","description":"Timestamp when this dispatch was acknowledged (null if not acknowledged)","format":"date-time","nullable":true},"nextEscalationAt":{"type":"string","description":"Timestamp when the next escalation step will fire (null if not scheduled)","format":"date-time","nullable":true},"lastNotifiedAt":{"type":"string","description":"Timestamp of the most recent notification delivery","format":"date-time","nullable":true},"deliveries":{"type":"array","description":"Delivery records for all channels associated with this dispatch","items":{"$ref":"#/components/schemas/AlertDeliveryDto"}},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"}},"description":"Dispatch state for a single (incident, notification policy) pair, with delivery history"},"SingleValueResponseNotificationDispatchDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/NotificationDispatchDto"}}},"CreateMonitorRequest":{"required":["config","managedBy","name","type"],"type":"object","properties":{"name":{"maxLength":255,"minLength":0,"type":"string","description":"Human-readable name for this monitor"},"type":{"type":"string","description":"Monitor protocol type","enum":["HTTP","DNS","MCP_SERVER","TCP","ICMP","HEARTBEAT"]},"config":{"oneOf":[{"$ref":"#/components/schemas/DnsMonitorConfig"},{"$ref":"#/components/schemas/HeartbeatMonitorConfig"},{"$ref":"#/components/schemas/HttpMonitorConfig"},{"$ref":"#/components/schemas/IcmpMonitorConfig"},{"$ref":"#/components/schemas/McpServerMonitorConfig"},{"$ref":"#/components/schemas/TcpMonitorConfig"}]},"frequencySeconds":{"type":"integer","description":"Check frequency in seconds (30–86400, default: 60)","format":"int32"},"enabled":{"type":"boolean","description":"Whether the monitor is active (default: true)","nullable":true},"regions":{"type":"array","description":"Probe regions to run checks from, e.g. us-east, eu-west","nullable":true,"items":{"type":"string","description":"Probe regions to run checks from, e.g. us-east, eu-west","nullable":true}},"managedBy":{"type":"string","description":"Who manages this monitor: DASHBOARD or CLI","enum":["DASHBOARD","CLI"]},"environmentId":{"type":"string","description":"Environment to associate with this monitor","format":"uuid","nullable":true},"assertions":{"type":"array","description":"Assertions to evaluate against each check result","nullable":true,"items":{"$ref":"#/components/schemas/CreateAssertionRequest"}},"auth":{"oneOf":[{"$ref":"#/components/schemas/ApiKeyAuthConfig"},{"$ref":"#/components/schemas/BasicAuthConfig"},{"$ref":"#/components/schemas/BearerAuthConfig"},{"$ref":"#/components/schemas/HeaderAuthConfig"}]},"incidentPolicy":{"$ref":"#/components/schemas/UpdateIncidentPolicyRequest"},"alertChannelIds":{"type":"array","description":"Alert channels to notify when this monitor triggers","nullable":true,"items":{"type":"string","description":"Alert channels to notify when this monitor triggers","format":"uuid","nullable":true}},"tags":{"$ref":"#/components/schemas/AddMonitorTagsRequest"}}},"SetMonitorAuthRequest":{"required":["config"],"type":"object","properties":{"config":{"oneOf":[{"$ref":"#/components/schemas/ApiKeyAuthConfig"},{"$ref":"#/components/schemas/BasicAuthConfig"},{"$ref":"#/components/schemas/BearerAuthConfig"},{"$ref":"#/components/schemas/HeaderAuthConfig"}]}}},"AssertionTestResultDto":{"type":"object","properties":{"assertionType":{"type":"string","enum":["status_code","response_time","body_contains","json_path","header","regex","dns_resolves","dns_response_time","dns_expected_ips","dns_expected_cname","dns_record_contains","dns_record_equals","dns_txt_contains","dns_min_answers","dns_max_answers","dns_response_time_warn","dns_ttl_low","dns_ttl_high","mcp_connects","mcp_response_time","mcp_has_capability","mcp_tool_available","mcp_min_tools","mcp_protocol_version","mcp_response_time_warn","mcp_tool_count_changed","ssl_expiry","response_size","redirect_count","redirect_target","response_time_warn","tcp_connects","tcp_response_time","tcp_response_time_warn","icmp_reachable","icmp_response_time","icmp_response_time_warn","icmp_packet_loss","heartbeat_received","heartbeat_max_interval","heartbeat_interval_drift","heartbeat_payload_contains"]},"passed":{"type":"boolean"},"severity":{"type":"string","enum":["fail","warn"]},"message":{"type":"string"},"expected":{"type":"string","nullable":true},"actual":{"type":"string","nullable":true}}},"MonitorTestResultDto":{"type":"object","properties":{"passed":{"type":"boolean"},"error":{"type":"string","nullable":true},"statusCode":{"type":"integer","format":"int32","nullable":true},"responseTimeMs":{"type":"integer","format":"int64","nullable":true},"responseHeaders":{"type":"object","additionalProperties":{"type":"array","nullable":true,"items":{"type":"string","nullable":true}},"nullable":true},"bodyPreview":{"type":"string","nullable":true},"responseSizeBytes":{"type":"integer","format":"int64","nullable":true},"redirectCount":{"type":"integer","format":"int32","nullable":true},"finalUrl":{"type":"string","nullable":true},"assertionResults":{"type":"array","items":{"$ref":"#/components/schemas/AssertionTestResultDto"}},"warnings":{"type":"array","nullable":true,"items":{"type":"string","nullable":true}}}},"SingleValueResponseMonitorTestResultDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MonitorTestResultDto"}}},"TableValueResultTagDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TagDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"MonitorTestRequest":{"required":["config","type"],"type":"object","properties":{"type":{"type":"string","description":"Monitor protocol type to test","enum":["HTTP","DNS","MCP_SERVER","TCP","ICMP","HEARTBEAT"]},"config":{"oneOf":[{"$ref":"#/components/schemas/DnsMonitorConfig"},{"$ref":"#/components/schemas/HeartbeatMonitorConfig"},{"$ref":"#/components/schemas/HttpMonitorConfig"},{"$ref":"#/components/schemas/IcmpMonitorConfig"},{"$ref":"#/components/schemas/McpServerMonitorConfig"},{"$ref":"#/components/schemas/TcpMonitorConfig"}]},"assertions":{"type":"array","description":"Optional assertions to evaluate against the test result","nullable":true,"items":{"$ref":"#/components/schemas/CreateAssertionRequest"}}}},"BulkMonitorActionRequest":{"required":["action","monitorIds"],"type":"object","properties":{"monitorIds":{"maxItems":200,"minItems":0,"type":"array","description":"IDs of monitors to act on (max 200)","items":{"type":"string","description":"IDs of monitors to act on (max 200)","format":"uuid"}},"action":{"type":"string","description":"Action to perform: PAUSE, RESUME, DELETE, ADD_TAG, REMOVE_TAG","enum":["PAUSE","RESUME","DELETE","ADD_TAG","REMOVE_TAG"]},"tagIds":{"type":"array","description":"Tag IDs to attach or detach (required for ADD_TAG and REMOVE_TAG)","nullable":true,"items":{"type":"string","description":"Tag IDs to attach or detach (required for ADD_TAG and REMOVE_TAG)","format":"uuid","nullable":true}},"newTags":{"type":"array","description":"New tags to create and attach (only for ADD_TAG)","nullable":true,"items":{"$ref":"#/components/schemas/NewTagRequest"}}},"description":"Request body for performing a bulk action on multiple monitors"},"BulkMonitorActionResult":{"type":"object","properties":{"succeeded":{"type":"array","description":"IDs of monitors on which the action succeeded","items":{"type":"string","description":"IDs of monitors on which the action succeeded","format":"uuid"}},"failed":{"type":"array","description":"Monitors on which the action failed, with the reason for each failure","items":{"$ref":"#/components/schemas/FailureDetail"}}},"description":"Result of a bulk monitor action, including partial-success details"},"FailureDetail":{"type":"object","properties":{"monitorId":{"type":"string","description":"Monitor ID that failed","format":"uuid"},"reason":{"type":"string","description":"Human-readable reason for the failure"}},"description":"Details about a single monitor that failed the bulk action"},"SingleValueResponseBulkMonitorActionResult":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/BulkMonitorActionResult"}}},"CreateMaintenanceWindowRequest":{"required":["endsAt","startsAt"],"type":"object","properties":{"monitorId":{"type":"string","format":"uuid"},"startsAt":{"type":"string","format":"date-time"},"endsAt":{"type":"string","format":"date-time"},"repeatRule":{"maxLength":100,"minLength":0,"type":"string"},"reason":{"type":"string"},"suppressAlerts":{"type":"boolean"}}},"CreateInviteRequest":{"required":["email","roleOffered"],"type":"object","properties":{"email":{"minLength":1,"type":"string","format":"email"},"roleOffered":{"type":"string","enum":["OWNER","ADMIN","MEMBER"]}}},"InviteDto":{"type":"object","properties":{"inviteId":{"type":"integer","format":"int32"},"email":{"type":"string"},"roleOffered":{"type":"string","enum":["OWNER","ADMIN","MEMBER"]},"expiresAt":{"type":"string","format":"date-time"},"consumedAt":{"type":"string","format":"date-time","nullable":true},"revokedAt":{"type":"string","format":"date-time","nullable":true}}},"SingleValueResponseInviteDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/InviteDto"}}},"CreateManualIncidentRequest":{"required":["severity","title"],"type":"object","properties":{"title":{"minLength":1,"type":"string","description":"Short summary of the incident"},"severity":{"type":"string","description":"Incident severity: DOWN, DEGRADED, or MAINTENANCE","enum":["DOWN","DEGRADED","MAINTENANCE"]},"monitorId":{"type":"string","description":"Monitor to associate with this incident","format":"uuid","nullable":true},"body":{"type":"string","description":"Detailed description or context for the incident","nullable":true}}},"IncidentDetailDto":{"type":"object","properties":{"incident":{"$ref":"#/components/schemas/IncidentDto"},"updates":{"type":"array","items":{"$ref":"#/components/schemas/IncidentUpdateDto"}}}},"IncidentUpdateDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"incidentId":{"type":"string","format":"uuid"},"oldStatus":{"type":"string","nullable":true,"enum":["WATCHING","TRIGGERED","CONFIRMED","RESOLVED"]},"newStatus":{"type":"string","nullable":true,"enum":["WATCHING","TRIGGERED","CONFIRMED","RESOLVED"]},"body":{"type":"string","nullable":true},"createdBy":{"type":"string","enum":["SYSTEM","USER"]},"notifySubscribers":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}}},"SingleValueResponseIncidentDetailDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IncidentDetailDto"}}},"AddIncidentUpdateRequest":{"type":"object","properties":{"body":{"type":"string"},"newStatus":{"type":"string","enum":["WATCHING","TRIGGERED","CONFIRMED","RESOLVED"]},"notifySubscribers":{"type":"boolean"}}},"ResolveIncidentRequest":{"type":"object","properties":{"body":{"type":"string","description":"Optional resolution message or post-mortem notes"}}},"CreateEnvironmentRequest":{"required":["name","slug"],"type":"object","properties":{"name":{"maxLength":100,"minLength":0,"type":"string","description":"Human-readable environment name"},"slug":{"maxLength":100,"minLength":0,"pattern":"^[a-z0-9][a-z0-9_-]*$","type":"string","description":"URL-safe identifier (lowercase alphanumeric, hyphens, underscores)"},"variables":{"type":"object","additionalProperties":{"type":"string","description":"Initial key-value variable pairs for this environment","nullable":true},"description":"Initial key-value variable pairs for this environment","nullable":true},"isDefault":{"type":"boolean","description":"Whether this is the default environment for new monitors"}}},"CreateApiKeyRequest":{"required":["name"],"type":"object","properties":{"name":{"maxLength":200,"minLength":0,"type":"string","description":"Human-readable name to identify this API key"},"expiresAt":{"type":"string","description":"Optional expiration timestamp in ISO 8601 format","format":"date-time","nullable":true}}},"ApiKeyCreateResponse":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"name":{"type":"string"},"key":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"expiresAt":{"type":"string","format":"date-time","nullable":true}}},"SingleValueResponseApiKeyCreateResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ApiKeyCreateResponse"}}},"ApiKeyDto":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"name":{"type":"string"},"key":{"type":"string"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"lastUsedAt":{"type":"string","format":"date-time","nullable":true},"revokedAt":{"type":"string","format":"date-time","nullable":true},"expiresAt":{"type":"string","format":"date-time","nullable":true}}},"SingleValueResponseApiKeyDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ApiKeyDto"}}},"SingleValueResponseAlertDeliveryDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AlertDeliveryDto"}}},"CreateAlertChannelRequest":{"required":["config","name"],"type":"object","properties":{"name":{"maxLength":255,"minLength":0,"type":"string","description":"Human-readable name for this alert channel"},"config":{"oneOf":[{"$ref":"#/components/schemas/DiscordChannelConfig"},{"$ref":"#/components/schemas/EmailChannelConfig"},{"$ref":"#/components/schemas/OpsGenieChannelConfig"},{"$ref":"#/components/schemas/PagerDutyChannelConfig"},{"$ref":"#/components/schemas/SlackChannelConfig"},{"$ref":"#/components/schemas/TeamsChannelConfig"},{"$ref":"#/components/schemas/WebhookChannelConfig"}]}}},"SingleValueResponseTestChannelResult":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TestChannelResult"}}},"TestChannelResult":{"type":"object","properties":{"success":{"type":"boolean"},"message":{"type":"string"}}},"TestAlertChannelRequest":{"required":["config"],"type":"object","properties":{"config":{"oneOf":[{"$ref":"#/components/schemas/DiscordChannelConfig"},{"$ref":"#/components/schemas/EmailChannelConfig"},{"$ref":"#/components/schemas/OpsGenieChannelConfig"},{"$ref":"#/components/schemas/PagerDutyChannelConfig"},{"$ref":"#/components/schemas/SlackChannelConfig"},{"$ref":"#/components/schemas/TeamsChannelConfig"},{"$ref":"#/components/schemas/WebhookChannelConfig"}]}}},"ComponentUpdateRequest":{"required":["addComponents"],"type":"object","properties":{"addComponents":{"minItems":1,"type":"array","items":{"type":"string"}}}},"UpdateAlertSensitivityRequest":{"required":["alertSensitivity"],"type":"object","properties":{"alertSensitivity":{"minLength":1,"pattern":"ALL|INCIDENTS_ONLY|MAJOR_ONLY","type":"string","description":"Alert sensitivity: ALL (any status change), INCIDENTS_ONLY (real vendor incidents, default), MAJOR_ONLY (only DOWN-level incidents)"}},"description":"Request body for updating alert sensitivity on a service subscription"},"UpdateApiKeyRequest":{"required":["name"],"type":"object","properties":{"name":{"maxLength":200,"minLength":0,"type":"string","description":"New name for this API key"}}},"TableValueResultWorkspaceDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"SingleValueResponseMapStringString":{"type":"object","properties":{"data":{"type":"object","additionalProperties":{"type":"string"}}}},"SingleValueResponseListMonitorAssertionDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MonitorAssertionDto"}}}},"SchedulableMonitorDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"type":{"type":"string","enum":["HTTP","DNS","MCP_SERVER","TCP","ICMP","HEARTBEAT"]},"config":{"oneOf":[{"$ref":"#/components/schemas/DnsMonitorConfig"},{"$ref":"#/components/schemas/HeartbeatMonitorConfig"},{"$ref":"#/components/schemas/HttpMonitorConfig"},{"$ref":"#/components/schemas/IcmpMonitorConfig"},{"$ref":"#/components/schemas/McpServerMonitorConfig"},{"$ref":"#/components/schemas/TcpMonitorConfig"}]},"frequencySeconds":{"type":"integer","format":"int32"},"regions":{"type":"array","items":{"type":"string"}},"organizationId":{"type":"integer","format":"int32"}}},"TableValueResultAdapterHealthDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AdapterHealthDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"SingleValueResponseListBillingPlanDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/BillingPlanDto"}}}},"TableValueResultTransactionDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TransactionDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultSubscriptionDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/SubscriptionDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"SingleValueResponseUpcomingChargeResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/UpcomingChargeResponse"}}},"UpcomingChargeResponse":{"type":"object","properties":{"action":{"type":"string","enum":["UPGRADE","DOWNGRADE","NOOP"]},"immediateAmount":{"type":"integer","format":"int32"},"nextBillingAmount":{"type":"integer","format":"int32"},"nextBillingDate":{"type":"string","format":"date-time","nullable":true}}},"EntitlementDto":{"type":"object","properties":{"key":{"type":"string","description":"Entitlement key"},"value":{"type":"integer","description":"Effective limit value (overrides applied)","format":"int64"},"defaultValue":{"type":"integer","description":"Plan-tier default value before overrides","format":"int64"},"overridden":{"type":"boolean","description":"Whether this entitlement has an org-level override"}},"description":"A single resolved entitlement for the organization"},"EntitlementResponse":{"type":"object","properties":{"tier":{"type":"string","description":"Resolved billing plan tier","enum":["FREE","STARTER","PRO","TEAM","BUSINESS","ENTERPRISE"]},"entitlements":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/EntitlementDto"},"description":"All entitlements keyed by entitlement key"},"usage":{"type":"object","additionalProperties":{"type":"integer","description":"Current usage counters keyed by entitlement key (only for countable resources)","format":"int64"},"description":"Current usage counters keyed by entitlement key (only for countable resources)"},"trialActive":{"type":"boolean","description":"Whether the org is currently on a trial"},"trialExpiresAt":{"type":"string","description":"Trial expiry date (null if not trialing)","format":"date-time","nullable":true},"subscriptionStatus":{"type":"string","description":"Current subscription status (null if no subscription)","nullable":true}},"description":"Full entitlement state for an organization: resolved limits, usage, and trial info"},"SingleValueResponseEntitlementResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntitlementResponse"}}},"PaginationParams":{"required":["sortBy","sortOrder"],"type":"object","properties":{"sortBy":{"type":"string"},"sortOrder":{"type":"string","enum":["ASC","DESC"]},"page":{"minimum":0,"type":"integer","format":"int32"},"size":{"maximum":200,"minimum":1,"type":"integer","format":"int32"}}},"IdValuePair":{"type":"object","properties":{"id":{"type":"integer","format":"int32"},"value":{"type":"string"}}},"TableValueResultIdValuePair":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/IdValuePair"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"MyOrgItemDto":{"type":"object","properties":{"orgId":{"type":"integer","format":"int32"},"orgName":{"type":"string"},"orgRole":{"type":"string","enum":["OWNER","ADMIN","MEMBER"]},"status":{"type":"string","enum":["INVITED","ACTIVE","SUSPENDED","LEFT","REMOVED","DECLINED"]}}},"TableValueResultMyOrgItemDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MyOrgItemDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"SseEmitter":{"type":"object","properties":{"timeout":{"type":"integer","format":"int64","nullable":true}}},"Pageable":{"type":"object","properties":{"page":{"minimum":0,"type":"integer","format":"int32"},"size":{"minimum":1,"type":"integer","format":"int32"},"sort":{"type":"array","items":{"type":"string"}}}},"TableValueResultUserDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/UserDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"AdminStatsDto":{"type":"object","properties":{"userCount":{"type":"integer","format":"int64"},"orgCount":{"type":"integer","format":"int64"},"memberCount":{"type":"integer","format":"int64"}}},"SingleValueResponseAdminStatsDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AdminStatsDto"}}},"TableValueResultOrganizationDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/OrganizationDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultMemberDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MemberDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultWebhookEndpointDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEndpointDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultWebhookDeliveryDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDeliveryDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"WebhookDeliveryDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"endpointId":{"type":"string","format":"uuid"},"eventId":{"type":"string"},"eventType":{"type":"string"},"status":{"type":"string"},"attemptCount":{"type":"integer","format":"int32"},"maxAttempts":{"type":"integer","format":"int32"},"responseStatus":{"type":"integer","format":"int32","nullable":true},"responseLatencyMs":{"type":"integer","format":"int32","nullable":true},"errorMessage":{"type":"string","nullable":true},"deliveredAt":{"type":"string","format":"date-time","nullable":true},"failedAt":{"type":"string","format":"date-time","nullable":true},"nextRetryAt":{"type":"string","format":"date-time","nullable":true},"createdAt":{"type":"string","format":"date-time"}}},"SingleValueResponseWebhookSigningSecretDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WebhookSigningSecretDto"}}},"WebhookSigningSecretDto":{"type":"object","properties":{"configured":{"type":"boolean"},"maskedSecret":{"type":"string","nullable":true}}},"WebhookEventCatalogEntry":{"type":"object","properties":{"type":{"type":"string","description":"Dot-notation event type identifier, e.g. \"monitor.created\""},"surface":{"type":"string","description":"Product surface this event belongs to, e.g. \"monitoring\" or \"status_data\""},"description":{"type":"string","description":"Human-readable description of when this event fires"}}},"WebhookEventCatalogResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEventCatalogEntry"}}}},"CursorPageServiceCatalogDto":{"type":"object","properties":{"data":{"type":"array","description":"Items on this page","items":{"$ref":"#/components/schemas/ServiceCatalogDto"}},"nextCursor":{"type":"string","description":"Opaque cursor for the next page; null when there are no more results","nullable":true},"hasMore":{"type":"boolean","description":"Whether more results exist beyond this page"}},"description":"Cursor-paginated response for time-series and append-only data"},"ServiceCatalogDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"category":{"type":"string","nullable":true},"officialStatusUrl":{"type":"string","nullable":true},"developerContext":{"type":"string","nullable":true},"logoUrl":{"type":"string","nullable":true},"adapterType":{"type":"string"},"pollingIntervalSeconds":{"type":"integer","format":"int32"},"enabled":{"type":"boolean"},"overallStatus":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"componentCount":{"type":"integer","format":"int64"},"activeIncidentCount":{"type":"integer","format":"int64"},"dataCompleteness":{"type":"string"}},"description":"Items on this page"},"MaintenanceComponentRef":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"status":{"type":"string"}}},"MaintenanceUpdateDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"status":{"type":"string"},"body":{"type":"string","nullable":true},"displayAt":{"type":"string","format":"date-time","nullable":true}},"description":"A status update within a scheduled maintenance lifecycle"},"ScheduledMaintenanceDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"externalId":{"type":"string"},"title":{"type":"string"},"status":{"type":"string"},"impact":{"type":"string","nullable":true},"shortlink":{"type":"string","nullable":true},"scheduledFor":{"type":"string","format":"date-time","nullable":true},"scheduledUntil":{"type":"string","format":"date-time","nullable":true},"startedAt":{"type":"string","format":"date-time","nullable":true},"completedAt":{"type":"string","format":"date-time","nullable":true},"affectedComponents":{"type":"array","items":{"$ref":"#/components/schemas/MaintenanceComponentRef"}},"updates":{"type":"array","items":{"$ref":"#/components/schemas/MaintenanceUpdateDto"}}},"description":"A scheduled maintenance window from a vendor status page"},"ServiceDetailDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"category":{"type":"string","nullable":true},"officialStatusUrl":{"type":"string","nullable":true},"developerContext":{"type":"string","nullable":true},"logoUrl":{"type":"string","nullable":true},"adapterType":{"type":"string"},"pollingIntervalSeconds":{"type":"integer","format":"int32"},"enabled":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"currentStatus":{"$ref":"#/components/schemas/ServiceStatusDto"},"recentIncidents":{"type":"array","items":{"$ref":"#/components/schemas/ServiceIncidentDto"}},"components":{"type":"array","items":{"$ref":"#/components/schemas/ServiceComponentDto"}},"uptime":{"$ref":"#/components/schemas/ComponentUptimeSummaryDto"},"activeMaintenances":{"type":"array","items":{"$ref":"#/components/schemas/ScheduledMaintenanceDto"}},"dataCompleteness":{"type":"string"}}},"ServiceIncidentDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"serviceId":{"type":"string","format":"uuid"},"serviceSlug":{"type":"string","nullable":true},"serviceName":{"type":"string","nullable":true},"externalId":{"type":"string","nullable":true},"title":{"type":"string"},"status":{"type":"string"},"impact":{"type":"string","nullable":true},"startedAt":{"type":"string","format":"date-time","nullable":true},"resolvedAt":{"type":"string","format":"date-time","nullable":true},"updatedAt":{"type":"string","format":"date-time","nullable":true},"shortlink":{"type":"string","nullable":true},"detectedAt":{"type":"string","format":"date-time","nullable":true},"vendorCreatedAt":{"type":"string","format":"date-time","nullable":true}}},"ServiceStatusDto":{"type":"object","properties":{"overallStatus":{"type":"string"},"lastPolledAt":{"type":"string","format":"date-time","nullable":true}}},"SingleValueResponseServiceDetailDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ServiceDetailDto"}}},"ServiceUptimeResponse":{"type":"object","properties":{"overallUptimePct":{"type":"number","description":"Overall uptime percentage across the entire period; null when no polling data exists","format":"double","nullable":true,"example":99.95},"period":{"type":"string","description":"Requested period","example":"7d"},"granularity":{"type":"string","description":"Requested granularity","example":"hourly"},"buckets":{"type":"array","description":"Per-bucket breakdown ordered by time ascending","items":{"$ref":"#/components/schemas/UptimeBucketDto"}},"source":{"type":"string","description":"Data source: vendor_reported, incident_derived, or poll_derived","nullable":true,"example":"vendor_reported"}},"description":"Uptime response with per-bucket breakdown and overall percentage for the period"},"SingleValueResponseServiceUptimeResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ServiceUptimeResponse"}}},"UptimeBucketDto":{"type":"object","properties":{"timestamp":{"type":"string","description":"Start of the bucket interval (ISO 8601)","format":"date-time","example":"2024-01-01T00:00:00Z"},"uptimePct":{"type":"number","description":"Uptime percentage for this bucket; null when no polls occurred","format":"double","nullable":true,"example":100.0},"totalPolls":{"type":"integer","description":"Total number of polls recorded in this bucket","format":"int64","example":12}},"description":"Uptime statistics for a single time bucket"},"TableValueResultScheduledMaintenanceDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ScheduledMaintenanceDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultServiceIncidentDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ServiceIncidentDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"ServiceIncidentDetailDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"title":{"type":"string"},"status":{"type":"string"},"impact":{"type":"string","nullable":true},"startedAt":{"type":"string","format":"date-time","nullable":true},"resolvedAt":{"type":"string","format":"date-time","nullable":true},"detectedAt":{"type":"string","format":"date-time","nullable":true},"shortlink":{"type":"string","nullable":true},"affectedComponents":{"type":"array","nullable":true,"items":{"type":"string","nullable":true}},"updates":{"type":"array","items":{"$ref":"#/components/schemas/ServiceIncidentUpdateDto"}}}},"ServiceIncidentUpdateDto":{"type":"object","properties":{"status":{"type":"string"},"body":{"type":"string","nullable":true},"displayAt":{"type":"string","format":"date-time","nullable":true}}},"SingleValueResponseServiceIncidentDetailDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ServiceIncidentDetailDto"}}},"TableValueResultServiceComponentDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ServiceComponentDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"ComponentUptimeDayDto":{"type":"object","properties":{"date":{"type":"string","format":"date-time"},"partialOutageSeconds":{"type":"integer","format":"int32"},"majorOutageSeconds":{"type":"integer","format":"int32"},"uptimePercentage":{"type":"number","format":"double"},"eventsJson":{"type":"string","description":"Incident event references for this day as raw JSON","nullable":true},"source":{"type":"string"}},"description":"Daily uptime data for a component"},"TableValueResultComponentUptimeDayDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ComponentUptimeDayDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"GlobalStatusSummaryDto":{"type":"object","properties":{"totalServices":{"type":"integer","format":"int32"},"operationalCount":{"type":"integer","format":"int32"},"degradedCount":{"type":"integer","format":"int32"},"partialOutageCount":{"type":"integer","format":"int32"},"majorOutageCount":{"type":"integer","format":"int32"},"maintenanceCount":{"type":"integer","format":"int32"},"activeIncidentCount":{"type":"integer","format":"int64"},"servicesWithIssues":{"type":"array","items":{"$ref":"#/components/schemas/ServiceCatalogDto"}}}},"SingleValueResponseGlobalStatusSummaryDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/GlobalStatusSummaryDto"}}},"TableValueResultServiceSubscriptionDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ServiceSubscriptionDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultSecretDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/SecretDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultResourceGroupDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ResourceGroupDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"SingleValueResponseResourceGroupHealthDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ResourceGroupHealthDto"}}},"NotificationDto":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"type":{"type":"string"},"title":{"type":"string"},"body":{"type":"string","nullable":true},"resourceType":{"type":"string","nullable":true},"resourceId":{"type":"string","nullable":true},"read":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}}},"TableValueResultNotificationDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/NotificationDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"SingleValueResponseLong":{"type":"object","properties":{"data":{"type":"integer","format":"int64"}}},"TableValueResultNotificationPolicyDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/NotificationPolicyDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultNotificationDispatchDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/NotificationDispatchDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultMonitorDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MonitorDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"MonitorVersionDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"monitorId":{"type":"string","format":"uuid"},"version":{"type":"integer","format":"int32"},"snapshot":{"$ref":"#/components/schemas/MonitorDto"},"changedById":{"type":"integer","format":"int32","nullable":true},"changedVia":{"type":"string","enum":["API","DASHBOARD","CLI","TERRAFORM"]},"changeSummary":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time"}}},"TableValueResultMonitorVersionDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MonitorVersionDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"SingleValueResponseMonitorVersionDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MonitorVersionDto"}}},"UptimeDto":{"type":"object","properties":{"uptimePercentage":{"type":"number","description":"Uptime percentage over the requested window; null when no data","format":"double","nullable":true,"example":99.95},"totalChecks":{"type":"integer","description":"Total number of checks executed","format":"int64","example":1440},"passedChecks":{"type":"integer","description":"Number of checks that passed","format":"int64","example":1439},"avgLatencyMs":{"type":"number","description":"Weighted average latency in milliseconds; null when no data","format":"double","nullable":true,"example":142.5},"p95LatencyMs":{"type":"number","description":"95th-percentile latency in milliseconds (upper bound across regions); null when no data","format":"double","nullable":true,"example":312.0}},"description":"Uptime statistics aggregated from continuous aggregates"},"SingleValueResponseUptimeDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/UptimeDto"}}},"CursorPage":{"type":"object","properties":{"data":{"type":"array","description":"Items on this page","items":{"type":"object","description":"Items on this page"}},"nextCursor":{"type":"string","description":"Opaque cursor for the next page; null when there are no more results","nullable":true},"hasMore":{"type":"boolean","description":"Whether more results exist beyond this page"}},"description":"Cursor-paginated response for time-series and append-only data"},"AssertionResultDto":{"type":"object","properties":{"type":{"type":"string","description":"Assertion type","example":"status_code"},"passed":{"type":"boolean","description":"Whether the assertion passed"},"severity":{"type":"string","description":"Assertion severity","enum":["fail","warn"]},"message":{"type":"string","description":"Human-readable result message","nullable":true},"expected":{"type":"string","description":"Expected value","nullable":true,"example":"200"},"actual":{"type":"string","description":"Actual value observed","nullable":true,"example":"503"}},"description":"Result of evaluating a single assertion against a check result"},"CheckResultDetailsDto":{"type":"object","properties":{"statusCode":{"type":"integer","description":"HTTP status code of the response","format":"int32","nullable":true,"example":200},"responseHeaders":{"type":"object","additionalProperties":{"type":"array","description":"HTTP response headers","nullable":true,"items":{"type":"string","description":"HTTP response headers","nullable":true}},"description":"HTTP response headers","nullable":true},"responseBodySnapshot":{"type":"string","description":"Raw response body snapshot (may be HTML, XML, JSON, or plain text)","nullable":true},"assertionResults":{"type":"array","description":"Individual assertion evaluation results","nullable":true,"items":{"$ref":"#/components/schemas/AssertionResultDto"}},"tlsInfo":{"$ref":"#/components/schemas/TlsInfoDto"},"redirectCount":{"type":"integer","description":"Number of HTTP redirects followed","format":"int32","nullable":true,"example":2},"redirectTarget":{"type":"string","description":"Final URL after redirects","nullable":true},"responseSizeBytes":{"type":"integer","description":"Response body size in bytes","format":"int32","nullable":true,"example":4096},"checkDetails":{"oneOf":[{"$ref":"#/components/schemas/Dns"},{"$ref":"#/components/schemas/Http"},{"$ref":"#/components/schemas/Icmp"},{"$ref":"#/components/schemas/McpServer"},{"$ref":"#/components/schemas/Tcp"}]}},"description":"Type-specific details captured during a check execution"},"CheckResultDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique identifier of the check result","format":"uuid"},"timestamp":{"type":"string","description":"Timestamp when the check was executed (ISO 8601)","format":"date-time"},"region":{"type":"string","description":"Region where the check was executed","example":"us-east"},"responseTimeMs":{"type":"integer","description":"Response time in milliseconds","format":"int32","nullable":true,"example":123},"passed":{"type":"boolean","description":"Whether the check passed","example":true},"failureReason":{"type":"string","description":"Reason for failure when passed=false","nullable":true},"severityHint":{"type":"string","description":"Severity hint: 'down' for hard failures, 'degraded' for warn-only failures, null when passing","nullable":true},"details":{"$ref":"#/components/schemas/CheckResultDetailsDto"}},"description":"A single check result from a monitor run"},"CheckTypeDetailsDto":{"required":["check_type"],"type":"object","properties":{"check_type":{"type":"string"}},"description":"Check-type-specific details — polymorphic by check_type discriminator","discriminator":{"propertyName":"check_type"}},"CursorPageCheckResultDto":{"type":"object","properties":{"data":{"type":"array","description":"Items on this page","items":{"$ref":"#/components/schemas/CheckResultDto"}},"nextCursor":{"type":"string","description":"Opaque cursor for the next page; null when there are no more results","nullable":true},"hasMore":{"type":"boolean","description":"Whether more results exist beyond this page"}},"description":"Cursor-paginated response for time-series and append-only data"},"Dns":{"type":"object","description":"DNS check-type-specific details","allOf":[{"$ref":"#/components/schemas/CheckTypeDetailsDto"},{"type":"object","properties":{"hostname":{"type":"string","description":"Target hostname","nullable":true},"requestedTypes":{"type":"array","description":"Requested DNS record types","nullable":true,"items":{"type":"string","description":"Requested DNS record types","nullable":true}},"usedResolver":{"type":"string","description":"Resolver used for lookup","nullable":true},"records":{"type":"object","additionalProperties":{"type":"array","description":"Resolved DNS records keyed by record type","nullable":true,"items":{"type":"object","additionalProperties":{"type":"object","description":"Resolved DNS records keyed by record type","nullable":true},"description":"Resolved DNS records keyed by record type","nullable":true}},"description":"Resolved DNS records keyed by record type","nullable":true},"attempts":{"type":"array","description":"DNS resolution attempts","nullable":true,"items":{"type":"object","additionalProperties":{"type":"object","description":"DNS resolution attempts","nullable":true},"description":"DNS resolution attempts","nullable":true}},"failureKind":{"type":"string","description":"Kind of DNS failure, if any","nullable":true}}}]},"Http":{"type":"object","description":"HTTP check-type-specific details","allOf":[{"$ref":"#/components/schemas/CheckTypeDetailsDto"},{"type":"object","properties":{"timing":{"type":"object","additionalProperties":{"type":"object","description":"Request phase timing breakdown","nullable":true},"description":"Request phase timing breakdown","nullable":true},"bodyTruncated":{"type":"boolean","description":"Whether the response body was truncated before storage","nullable":true}}}]},"Icmp":{"type":"object","description":"ICMP (ping) check-type-specific details","allOf":[{"$ref":"#/components/schemas/CheckTypeDetailsDto"},{"type":"object","properties":{"host":{"type":"string","description":"Target host","example":"1.1.1.1"},"packetsSent":{"type":"integer","description":"Number of ICMP packets sent","format":"int32","nullable":true},"packetsReceived":{"type":"integer","description":"Number of ICMP packets received","format":"int32","nullable":true},"packetLoss":{"type":"number","description":"Packet loss percentage","format":"double","nullable":true,"example":0.0},"avgRttMs":{"type":"number","description":"Average round-trip time in ms","format":"double","nullable":true},"minRttMs":{"type":"number","description":"Minimum round-trip time in ms","format":"double","nullable":true},"maxRttMs":{"type":"number","description":"Maximum round-trip time in ms","format":"double","nullable":true},"jitterMs":{"type":"number","description":"Jitter in ms","format":"double","nullable":true}}}]},"McpServer":{"type":"object","description":"MCP server check-type-specific details","allOf":[{"$ref":"#/components/schemas/CheckTypeDetailsDto"},{"type":"object","properties":{"url":{"type":"string","description":"MCP server URL","nullable":true},"protocolVersion":{"type":"string","description":"MCP protocol version","nullable":true},"serverInfo":{"type":"object","additionalProperties":{"type":"object","description":"MCP server info (name, version, etc.)","nullable":true},"description":"MCP server info (name, version, etc.)","nullable":true},"toolCount":{"type":"integer","description":"Number of tools exposed","format":"int32","nullable":true},"resourceCount":{"type":"integer","description":"Number of resources exposed","format":"int32","nullable":true},"promptCount":{"type":"integer","description":"Number of prompts exposed","format":"int32","nullable":true}}}]},"Tcp":{"type":"object","description":"TCP check-type-specific details","allOf":[{"$ref":"#/components/schemas/CheckTypeDetailsDto"},{"type":"object","properties":{"host":{"type":"string","description":"Target host","example":"db.example.com"},"port":{"type":"integer","description":"Target port","format":"int32","example":5432},"connected":{"type":"boolean","description":"Whether a TCP connection was established"}}}]},"TlsInfoDto":{"type":"object","properties":{"subjectCn":{"type":"string","description":"Certificate subject common name","nullable":true,"example":"*.example.com"},"subjectSan":{"type":"array","description":"Subject Alternative Names","nullable":true,"items":{"type":"string","description":"Subject Alternative Names","nullable":true}},"issuerCn":{"type":"string","description":"Issuer common name","nullable":true,"example":"R3"},"issuerOrg":{"type":"string","description":"Issuer organisation","nullable":true,"example":"Let's Encrypt"},"notBefore":{"type":"string","description":"Certificate validity start (ISO 8601 UTC)","nullable":true},"notAfter":{"type":"string","description":"Certificate validity end (ISO 8601 UTC)","nullable":true},"serialNumber":{"type":"string","description":"Certificate serial number","nullable":true},"tlsVersion":{"type":"string","description":"TLS protocol version","nullable":true,"example":"TLSv1.3"},"cipherSuite":{"type":"string","description":"Negotiated cipher suite","nullable":true},"chainValid":{"type":"boolean","description":"Whether the chain validated against the OS trust store","nullable":true}},"description":"TLS/SSL certificate details for HTTPS targets"},"ChartBucketDto":{"type":"object","properties":{"bucket":{"type":"string","description":"Start of the time bucket (ISO 8601)","format":"date-time","example":"2026-03-12T10:00:00Z"},"uptimePercent":{"type":"number","description":"Uptime percentage for this bucket; null when no data","format":"double","nullable":true,"example":100.0},"avgLatencyMs":{"type":"number","description":"Weighted average latency in milliseconds for this bucket","format":"double","nullable":true,"example":120.3},"p95LatencyMs":{"type":"number","description":"95th percentile latency in milliseconds (max across regions)","format":"double","nullable":true,"example":250.0},"p99LatencyMs":{"type":"number","description":"99th percentile latency in milliseconds (max across regions)","format":"double","nullable":true,"example":480.0}},"description":"Aggregated metrics for a time bucket"},"RegionStatusDto":{"type":"object","properties":{"region":{"type":"string","description":"Region identifier","example":"us-east"},"passed":{"type":"boolean","description":"Whether the last check in this region passed","example":true},"responseTimeMs":{"type":"integer","description":"Response time in milliseconds for the last check","format":"int32","nullable":true,"example":95},"timestamp":{"type":"string","description":"Timestamp of the last check in this region (ISO 8601)","format":"date-time"},"severityHint":{"type":"string","description":"Severity hint: 'down' for hard failures, 'degraded' for warn-only failures, null when passing","nullable":true}},"description":"Latest check result for a single region"},"ResultSummaryDto":{"type":"object","properties":{"currentStatus":{"type":"string","description":"Derived current status across all regions","enum":["up","degraded","down","unknown"]},"latestPerRegion":{"type":"array","description":"Latest check result per region","items":{"$ref":"#/components/schemas/RegionStatusDto"}},"chartData":{"type":"array","description":"Time-bucketed chart data for the requested window","items":{"$ref":"#/components/schemas/ChartBucketDto"}},"uptime24h":{"type":"number","description":"Uptime percentage over the last 24 hours; null when no data","format":"double","nullable":true,"example":99.95},"uptimeWindow":{"type":"number","description":"Uptime percentage for the selected chart window; null when no data","format":"double","nullable":true,"example":99.8}},"description":"Dashboard summary: current status, per-region latest results, and chart data"},"SingleValueResponseResultSummaryDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ResultSummaryDto"}}},"TableValueResultMaintenanceWindowDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MaintenanceWindowDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultInviteDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/InviteDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"IntegrationCatalogResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/IntegrationDto"}}}},"IntegrationConfigSchemaDto":{"type":"object","properties":{"connectionFields":{"type":"array","items":{"$ref":"#/components/schemas/IntegrationFieldDto"}},"channelFields":{"type":"array","items":{"$ref":"#/components/schemas/IntegrationFieldDto"}}}},"IntegrationDto":{"type":"object","properties":{"type":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"logoUrl":{"type":"string"},"authType":{"type":"string"},"tierAvailability":{"type":"string","enum":["FREE","STARTER","PRO","TEAM","BUSINESS","ENTERPRISE"]},"lifecycle":{"type":"string"},"setupGuideUrl":{"type":"string"},"configSchema":{"$ref":"#/components/schemas/IntegrationConfigSchemaDto"}}},"IntegrationFieldDto":{"required":["key","label","required","sensitive","type"],"type":"object","properties":{"key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"required":{"type":"boolean"},"sensitive":{"type":"boolean"},"placeholder":{"type":"string","nullable":true},"helpText":{"type":"string","nullable":true},"options":{"type":"array","nullable":true,"items":{"type":"string","nullable":true}},"default":{"type":"string","nullable":true}}},"IncidentFilterParams":{"type":"object","properties":{"status":{"type":"string","enum":["WATCHING","TRIGGERED","CONFIRMED","RESOLVED"]},"severity":{"type":"string","enum":["DOWN","DEGRADED","MAINTENANCE"]},"source":{"type":"string","enum":["AUTOMATIC","MANUAL","MONITORS","STATUS_DATA","RESOURCE_GROUP"]},"monitorId":{"type":"string","format":"uuid"},"serviceId":{"type":"string","format":"uuid"},"resourceGroupId":{"type":"string","format":"uuid"},"tagId":{"type":"string","format":"uuid","nullable":true},"environmentId":{"type":"string","format":"uuid","nullable":true},"startedFrom":{"type":"string","format":"date-time","nullable":true},"startedTo":{"type":"string","format":"date-time","nullable":true},"page":{"minimum":0,"type":"integer","format":"int32"},"size":{"maximum":200,"minimum":1,"type":"integer","format":"int32"}}},"TableValueResultEnvironmentDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/EnvironmentDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"DashboardOverviewDto":{"type":"object","properties":{"monitors":{"$ref":"#/components/schemas/MonitorsSummaryDto"},"incidents":{"$ref":"#/components/schemas/IncidentsSummaryDto"}}},"IncidentsSummaryDto":{"type":"object","properties":{"active":{"type":"integer","format":"int64"},"resolvedToday":{"type":"integer","format":"int64"},"mttr30d":{"type":"number","format":"double","nullable":true}}},"MonitorsSummaryDto":{"type":"object","properties":{"total":{"type":"integer","format":"int64"},"up":{"type":"integer","format":"int64"},"down":{"type":"integer","format":"int64"},"degraded":{"type":"integer","format":"int64"},"paused":{"type":"integer","format":"int64"},"avgUptime24h":{"type":"number","format":"double","nullable":true},"avgUptime30d":{"type":"number","format":"double","nullable":true}}},"SingleValueResponseDashboardOverviewDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DashboardOverviewDto"}}},"CategoryDto":{"type":"object","properties":{"category":{"type":"string"},"serviceCount":{"type":"integer","format":"int64"}}},"TableValueResultCategoryDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/CategoryDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"AuditEventDto":{"type":"object","properties":{"id":{"type":"integer","format":"int64"},"actorId":{"type":"integer","format":"int32","nullable":true},"actorEmail":{"type":"string","nullable":true},"action":{"type":"string"},"resourceType":{"type":"string","nullable":true},"resourceId":{"type":"string","nullable":true},"resourceName":{"type":"string","nullable":true},"metadata":{"type":"object","additionalProperties":{"type":"object","nullable":true},"nullable":true},"createdAt":{"type":"string","format":"date-time"}}},"PageResultAuditEventDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AuditEventDto"}},"page":{"type":"integer","format":"int32"},"size":{"type":"integer","format":"int32"},"totalElements":{"type":"integer","format":"int64"},"totalPages":{"type":"integer","format":"int32"},"hasNext":{"type":"boolean"}}},"TableValueResultApiKeyDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ApiKeyDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"DeliveryAttemptDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"deliveryId":{"type":"string","format":"uuid"},"attemptNumber":{"type":"integer","description":"1-based attempt number","format":"int32"},"status":{"type":"string","description":"Outcome: SUCCESS, FAILED, TIMEOUT, ERROR"},"responseStatusCode":{"type":"integer","description":"HTTP response status code from the external service","format":"int32","nullable":true},"requestPayload":{"type":"string","description":"JSON payload sent to the external service","nullable":true},"responseBody":{"type":"string","description":"Response body from the external service (truncated)","nullable":true},"errorMessage":{"type":"string","description":"Error message if the attempt failed","nullable":true},"responseTimeMs":{"type":"integer","description":"Round-trip time in milliseconds","format":"int32","nullable":true},"externalId":{"type":"string","description":"External identifier (e.g. PagerDuty dedup_key, SES MessageId, webhook delivery UUID)","nullable":true},"requestHeaders":{"type":"object","additionalProperties":{"type":"string","description":"HTTP request headers sent to the external service","nullable":true},"description":"HTTP request headers sent to the external service","nullable":true},"attemptedAt":{"type":"string","format":"date-time"}},"description":"Single delivery attempt with request/response audit data"},"TableValueResultDeliveryAttemptDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/DeliveryAttemptDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultAlertChannelDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AlertChannelDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultAlertDeliveryDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AlertDeliveryDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"RemoveMonitorTagsRequest":{"required":["tagIds"],"type":"object","properties":{"tagIds":{"minItems":1,"type":"array","description":"IDs of the tags to detach from the monitor","items":{"type":"string","description":"IDs of the tags to detach from the monitor","format":"uuid"}}},"description":"Request body for removing tags from a monitor"},"DeleteChannelResult":{"type":"object","properties":{"affectedPolicies":{"type":"integer","description":"Number of notification policies whose escalation steps were modified","format":"int32"},"disabledPolicies":{"type":"integer","description":"Number of notification policies disabled because they had no remaining channels","format":"int32"}},"description":"Summary of policies affected by channel deletion"}},"securitySchemes":{"BearerAuth":{"type":"http","description":"API key (dh_live_...) or Auth0 JWT token","scheme":"bearer","bearerFormat":"JWT"}}}} \ No newline at end of file +{"openapi":"3.0.1","info":{"title":"DevHelm API","description":"DevHelm platform and public API","version":"1.0"},"servers":[{"url":"http://localhost:8080","description":"Generated server url"}],"tags":[{"name":"Heartbeat","description":"Public ping endpoint for heartbeat monitors"},{"name":"Invites","description":"Organization invite management"},{"name":"Onboarding","description":"User onboarding flow"},{"name":"Members","description":"Organization member management"},{"name":"Me","description":"Current user profile and organizations"},{"name":"Incidents","description":"Incident management and lifecycle"},{"name":"Maintenance Windows","description":"Schedule alert-suppression windows for monitors"},{"name":"Organizations","description":"Organization management"},{"name":"Integrations","description":"Static catalog of supported alert channel integrations"},{"name":"Incident Policies","description":"Manage trigger, confirmation, and recovery rules for monitors"},{"name":"Entitlements","description":"Plan entitlements and usage limits"},{"name":"Vault","description":"Organization vault management (admin-only)"},{"name":"Secrets","description":"Organization environment secret management"},{"name":"Transactions","description":"Subscription transaction history"},{"name":"Monitors","description":"Monitor CRUD and lifecycle management"},{"name":"Webhooks","description":"Webhook endpoint management, event catalog, and delivery history"},{"name":"Events","description":"Real-time event stream"},{"name":"Workspaces","description":"Workspace management within an organization"},{"name":"Notifications","description":"In-app notification center"},{"name":"Alert Channels","description":"Alert channel CRUD and connectivity testing"},{"name":"Subscriptions","description":"Organization subscription management"},{"name":"Service Subscriptions","description":"Manage which services an organization tracks"},{"name":"Tags","description":"Org-scoped tag management for monitors"},{"name":"Status Data","description":"Public service status catalog, components, uptime, and incident history"},{"name":"Check Results","description":"Query raw check results, uptime statistics, and summary data"},{"name":"API Keys","description":"Organization API key management"},{"name":"Dashboard","description":"Overview dashboard aggregates"},{"name":"Auth","description":"User registration"},{"name":"Monitor Auth","description":"Manage authentication configuration for a monitor"},{"name":"Audit Log","description":"Organization audit trail"},{"name":"Monitor Alert Channels","description":"Manage alert channel mappings for a monitor"},{"name":"Alert Deliveries","description":"Delivery audit trail: inspect per-attempt details for alert deliveries"},{"name":"API Auth","description":"Identity and quota info for API key authentication"},{"name":"Resource Groups","description":"Resource group CRUD and member management"},{"name":"Notification Policies","description":"Org-level notification routing policies with JSONB match rules"},{"name":"Notification Dispatches","description":"Dispatch debugging API: inspect which policies matched an incident and track delivery status"},{"name":"Environments","description":"Variable namespace management for monitors"},{"name":"Monitor Assertions","description":"Manage assertions for a monitor"},{"name":"Deploy Lock","description":"Mutex for CLI deploy operations"},{"name":"Billing","description":"Billing plans and pricing"}],"paths":{"/platform/orgs/{orgId}/subscriptions/{subscriptionId}":{"put":{"tags":["Subscriptions"],"summary":"Update subscription","operationId":"updateSubscription","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"subscriptionId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSubscriptionRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseSubscriptionDto"}}}}}}},"/platform/onboarding/orgs/{orgId}/details":{"put":{"tags":["Onboarding"],"summary":"Update organization details","operationId":"updateOrgDetails","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateOrgDetailsRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseOrganizationDto"}}}}}}},"/platform/onboarding/advance":{"put":{"tags":["Onboarding"],"summary":"Advance onboarding stage forward","operationId":"advanceStage","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateOnboardingStageRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUserDto"}}}}}}},"/platform/me":{"get":{"tags":["Me"],"summary":"Get current user","operationId":"me","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUserDto"}}}}}},"put":{"tags":["Me"],"summary":"Update current user profile","operationId":"updateProfile","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateProfileRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUserDto"}}}}}}},"/platform/me/notification-preferences":{"get":{"tags":["Me"],"summary":"Get current user's notification preferences","operationId":"getNotificationPreferences","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseNotificationPreferencesDto"}}}}}},"put":{"tags":["Me"],"summary":"Update current user's notification preferences","operationId":"updateNotificationPreferences","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateNotificationPreferencesRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseNotificationPreferencesDto"}}}}}}},"/platform/admin/workspaces/{workspaceId}":{"get":{"tags":["admin-workspace-controller"],"operationId":"getWorkspace","parameters":[{"name":"workspaceId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWorkspaceDto"}}}}}},"put":{"tags":["admin-workspace-controller"],"operationId":"updateWorkspace","parameters":[{"name":"workspaceId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWorkspaceDto"}}}}}},"delete":{"tags":["admin-workspace-controller"],"operationId":"deleteWorkspace","parameters":[{"name":"workspaceId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}}},"/platform/admin/users/{userId}":{"put":{"tags":["admin-controller"],"operationId":"updateUser","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUserDto"}}}}}}},"/platform/admin/orgs/{orgId}":{"put":{"tags":["admin-controller"],"operationId":"updateOrganization","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateOrgDetailsRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseOrganizationDto"}}}}}}},"/platform/admin/orgs/{orgId}/members/{userId}/role":{"put":{"tags":["admin-member-controller"],"operationId":"updateMemberRole","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRoleRequest"}}},"required":true},"responses":{"204":{"description":"No Content"}}}},"/api/v1/workspaces/{workspaceId}":{"get":{"tags":["Workspaces"],"summary":"Get workspace by ID","operationId":"get","parameters":[{"name":"workspaceId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWorkspaceDto"}}}}}},"put":{"tags":["Workspaces"],"summary":"Update workspace","operationId":"update","parameters":[{"name":"workspaceId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWorkspaceDto"}}}}}},"delete":{"tags":["Workspaces"],"summary":"Delete workspace","operationId":"delete","parameters":[{"name":"workspaceId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/webhooks/{id}":{"get":{"tags":["Webhooks"],"summary":"Get a single webhook endpoint","operationId":"get_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWebhookEndpointDto"}}}}}},"put":{"tags":["Webhooks"],"summary":"Update a webhook endpoint","operationId":"update_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWebhookEndpointRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWebhookEndpointDto"}}}}}},"delete":{"tags":["Webhooks"],"summary":"Delete a webhook endpoint","operationId":"delete_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/tags/{id}":{"get":{"tags":["Tags"],"summary":"Get a tag by ID","operationId":"getById","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseTagDto"}}}}}},"put":{"tags":["Tags"],"summary":"Update a tag's name and/or color","operationId":"update_2","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateTagRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseTagDto"}}}}}},"delete":{"tags":["Tags"],"summary":"Delete a tag (cascades to all monitor associations)","operationId":"delete_2","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/secrets/{key}":{"put":{"tags":["Secrets"],"summary":"Update secret","operationId":"update_3","parameters":[{"name":"key","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateSecretRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseSecretDto"}}}}}},"delete":{"tags":["Secrets"],"summary":"Delete secret","operationId":"delete_3","parameters":[{"name":"key","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/resource-groups/{id}":{"get":{"tags":["Resource Groups"],"summary":"Get a resource group by id with member statuses and inherited settings","description":"Pass includeMetrics=true to enrich each member with 24h uptime, chart data, and latency metrics.","operationId":"get_2","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"includeMetrics","in":"query","required":false,"schema":{"type":"boolean","default":false}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseResourceGroupDto"}}}}}},"put":{"tags":["Resource Groups"],"summary":"Update a resource group's name, description, alert policy, inherited settings, and health threshold","operationId":"update_4","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateResourceGroupRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseResourceGroupDto"}}}}}},"delete":{"tags":["Resource Groups"],"summary":"Delete a resource group (cascades to member rows)","operationId":"delete_4","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/org":{"get":{"tags":["Organizations"],"summary":"Get the current organization","operationId":"get_3","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseOrganizationDto"}}}}}},"put":{"tags":["Organizations"],"summary":"Update the current organization","operationId":"update_5","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateOrgDetailsRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseOrganizationDto"}}}}}}},"/api/v1/notifications/{id}/read":{"put":{"tags":["Notifications"],"summary":"Mark a notification as read","operationId":"markRead","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int64"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/notifications/read-all":{"put":{"tags":["Notifications"],"summary":"Mark all notifications as read","operationId":"markAllRead","responses":{"204":{"description":"No Content"}}}},"/api/v1/notification-policies/{id}":{"get":{"tags":["Notification Policies"],"summary":"Get a notification policy by ID","operationId":"getById_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseNotificationPolicyDto"}}}}}},"put":{"tags":["Notification Policies"],"summary":"Update a notification policy","operationId":"update_6","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateNotificationPolicyRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseNotificationPolicyDto"}}}}}},"delete":{"tags":["Notification Policies"],"summary":"Delete a notification policy","operationId":"delete_5","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/monitors/{monitorId}/policy":{"get":{"tags":["Incident Policies"],"summary":"Get incident policy for a monitor","description":"Returns the trigger rules, confirmation settings, and recovery settings for the given monitor.","operationId":"get_4","parameters":[{"name":"monitorId","in":"path","description":"Monitor UUID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Policy found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IncidentPolicyDto"}}}},"404":{"description":"Monitor or policy not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentPolicyDto"}}}}}},"put":{"tags":["Incident Policies"],"summary":"Update incident policy for a monitor","description":"Replaces the trigger rules, confirmation settings, and recovery settings. All fields are validated before saving.","operationId":"update_7","parameters":[{"name":"monitorId","in":"path","description":"Monitor UUID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateIncidentPolicyRequest"}}},"required":true},"responses":{"200":{"description":"Policy updated","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IncidentPolicyDto"}}}},"400":{"description":"Validation error in JSONB shape","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentPolicyDto"}}}},"404":{"description":"Monitor or policy not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentPolicyDto"}}}}}}},"/api/v1/monitors/{monitorId}/auth":{"put":{"tags":["Monitor Auth"],"summary":"Update authentication config for a monitor","operationId":"update_8","parameters":[{"name":"monitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMonitorAuthRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorAuthDto"}}}}}},"post":{"tags":["Monitor Auth"],"summary":"Set authentication config for a monitor","operationId":"set","parameters":[{"name":"monitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetMonitorAuthRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorAuthDto"}}}}}},"delete":{"tags":["Monitor Auth"],"summary":"Remove authentication config from a monitor","operationId":"remove","parameters":[{"name":"monitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/monitors/{monitorId}/assertions/{assertionId}":{"put":{"tags":["Monitor Assertions"],"summary":"Update an assertion on a monitor","operationId":"update_9","parameters":[{"name":"monitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"assertionId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAssertionRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorAssertionDto"}}}}}},"delete":{"tags":["Monitor Assertions"],"summary":"Remove an assertion from a monitor","operationId":"remove_1","parameters":[{"name":"monitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"assertionId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/monitors/{monitorId}/alert-channels":{"put":{"tags":["Monitor Alert Channels"],"summary":"Replace the linked alert channel set for a monitor","operationId":"setChannels","parameters":[{"name":"monitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/SetAlertChannelsRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseListUUID"}}}}}}},"/api/v1/monitors/{id}":{"get":{"tags":["Monitors"],"summary":"Get a single monitor by id","operationId":"get_5","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorDto"}}}}}},"put":{"tags":["Monitors"],"summary":"Update a monitor","operationId":"update_10","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMonitorRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorDto"}}}}}},"delete":{"tags":["Monitors"],"summary":"Soft-delete a monitor","operationId":"delete_6","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/members/{userId}/status":{"put":{"tags":["Members"],"summary":"Change member status","operationId":"changeStatus","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeStatusRequest"}}},"required":true},"responses":{"204":{"description":"No Content"}}}},"/api/v1/members/{userId}/role":{"put":{"tags":["Members"],"summary":"Change member role","operationId":"changeRole","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ChangeRoleRequest"}}},"required":true},"responses":{"204":{"description":"No Content"}}}},"/api/v1/maintenance-windows/{id}":{"get":{"tags":["Maintenance Windows"],"summary":"Get a single maintenance window by ID","operationId":"getById_2","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMaintenanceWindowDto"}}}}}},"put":{"tags":["Maintenance Windows"],"summary":"Update a maintenance window","operationId":"update_11","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMaintenanceWindowRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMaintenanceWindowDto"}}}}}},"delete":{"tags":["Maintenance Windows"],"summary":"Delete a maintenance window","operationId":"delete_7","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/environments/{slug}":{"get":{"tags":["Environments"],"summary":"Get environment by slug","operationId":"get_6","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseEnvironmentDto"}}}}}},"put":{"tags":["Environments"],"summary":"Update environment","operationId":"update_12","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateEnvironmentRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseEnvironmentDto"}}}}}},"delete":{"tags":["Environments"],"summary":"Delete environment","operationId":"delete_8","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/alert-channels/{id}":{"put":{"tags":["Alert Channels"],"summary":"Update an alert channel's name and re-encrypt config","operationId":"update_13","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAlertChannelRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAlertChannelDto"}}}}}},"delete":{"tags":["Alert Channels"],"summary":"Soft-delete an alert channel and return affected policy summary","operationId":"delete_9","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/DeleteChannelResult"}}}}}}},"/v1/webhooks/paddle":{"post":{"tags":["paddle-webhook-controller"],"operationId":"handleWebhook","parameters":[{"name":"paddle-signature","in":"header","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"string"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/v1/internal/workspaces":{"post":{"tags":["workspaces-controller"],"operationId":"create","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceCreateParams"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWorkspaceDto"}}}}}}},"/v1/internal/service-incidents":{"post":{"tags":["service-incident-internal-controller"],"operationId":"createOrResolve","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceIncidentRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultIncidentDto"}}}}}}},"/v1/internal/resource-groups/services/{serviceId}/re-evaluate-health":{"post":{"tags":["resource-groups-internal-controller"],"operationId":"reEvaluateGroupHealthForService","parameters":[{"name":"serviceId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseInteger"}}}}}}},"/v1/internal/resource-groups/monitors/{monitorId}/re-evaluate-health":{"post":{"tags":["resource-groups-internal-controller"],"operationId":"reEvaluateGroupHealthForMonitor","parameters":[{"name":"monitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseInteger"}}}}}}},"/v1/internal/incidents":{"post":{"tags":["incidents-internal-controller"],"operationId":"createAutoIncident","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAutoIncidentRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentDto"}}}}}}},"/v1/internal/incidents/{id}/resolve":{"post":{"tags":["incidents-internal-controller"],"operationId":"resolveAutoIncident","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentDto"}}}}}}},"/v1/internal/incidents/{id}/reopen":{"post":{"tags":["incidents-internal-controller"],"operationId":"reopenAutoIncident","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ReopenAutoIncidentRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentDto"}}}}}}},"/v1/internal/escalation-tick":{"post":{"tags":["escalation-internal-controller"],"operationId":"runEscalationTick","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseInteger"}}}}}}},"/v1/internal/billing/sync":{"post":{"tags":["admin-billing-controller"],"operationId":"syncFromPaddle","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseInteger"}}}}}}},"/v1/internal/adapters/health":{"get":{"tags":["adapter-health-internal-controller"],"operationId":"getAllHealth","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultAdapterHealthDto"}}}}}},"post":{"tags":["adapter-health-internal-controller"],"operationId":"reportOutcome","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdapterHealthReportRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAdapterHealthDto"}}}}}}},"/platform/orgs":{"post":{"tags":["Organizations"],"summary":"Create organization","operationId":"create_1","parameters":[{"name":"ifNotExists","in":"query","required":false,"schema":{"type":"boolean","default":false}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateOrgRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseOrganizationDto"}}}}}}},"/platform/orgs/{orgId}/transactions":{"get":{"tags":["Transactions"],"summary":"List transactions","operationId":"list","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"limit","in":"query","required":false,"schema":{"maximum":100,"minimum":1,"type":"integer","format":"int32","default":10}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultTransactionDto"}}}}}},"post":{"tags":["Transactions"],"summary":"Create subscription transaction","operationId":"createSubscriptionTransaction","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSubscriptionRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseTransactionDto"}}}}}}},"/platform/onboarding/quick-monitor":{"post":{"tags":["Onboarding"],"summary":"Create a monitor with smart defaults from URL analysis","operationId":"quickMonitor","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/QuickMonitorRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorDto"}}}}}}},"/platform/onboarding/complete-setup":{"post":{"tags":["Onboarding"],"summary":"Complete onboarding setup (creates org + workspace, advances to FIRST_MONITOR)","operationId":"completeSetup","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OnboardingSetupRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUserDto"}}}}}}},"/platform/onboarding/analyze-url":{"post":{"tags":["Onboarding"],"summary":"Analyze a URL and return suggested monitor configuration","operationId":"analyzeUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyzeUrlRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAnalyzeUrlResponse"}}}}}}},"/platform/invites/accept":{"post":{"tags":["Invites"],"summary":"Accept invite","operationId":"accept","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AcceptInviteRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAcceptInviteDto"}}}}}}},"/platform/auth/register":{"post":{"tags":["Auth"],"summary":"Register user","operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterUserRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUserDto"}}}}}}},"/platform/admin/orgs/{orgId}/workspaces":{"get":{"tags":["admin-workspace-controller"],"operationId":"listWorkspaces","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultWorkspaceDto"}}}}}},"post":{"tags":["admin-workspace-controller"],"operationId":"createWorkspace","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWorkspaceDto"}}}}}}},"/platform/admin/orgs/{orgId}/members":{"get":{"tags":["admin-member-controller"],"operationId":"listMembers","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultMemberDto"}}}}}},"post":{"tags":["admin-member-controller"],"operationId":"addMember","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddMemberRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMemberDto"}}}}}}},"/platform/admin/adapters/{serviceId}/enable":{"post":{"tags":["admin-adapter-health-controller"],"operationId":"reEnableAdapter","parameters":[{"name":"serviceId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAdapterHealthDto"}}}}}}},"/api/v1/workspaces":{"get":{"tags":["Workspaces"],"summary":"List workspaces","operationId":"list_1","parameters":[{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultWorkspaceDto"}}}}}},"post":{"tags":["Workspaces"],"summary":"Create workspace","operationId":"create_2","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWorkspaceDto"}}}}}}},"/api/v1/webhooks":{"get":{"tags":["Webhooks"],"summary":"List webhook endpoints for the authenticated org","operationId":"list_2","parameters":[{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultWebhookEndpointDto"}}}}}},"post":{"tags":["Webhooks"],"summary":"Register a new webhook endpoint","operationId":"create_3","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWebhookEndpointRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWebhookEndpointDto"}}}}}}},"/api/v1/webhooks/{id}/test":{"post":{"tags":["Webhooks"],"summary":"Send a test delivery to a webhook endpoint","operationId":"test","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestWebhookEndpointRequest"}}}},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWebhookTestResult"}}}}}}},"/api/v1/webhooks/signing-secret/rotate":{"post":{"tags":["Webhooks"],"summary":"Generate or rotate the organization webhook signing secret","operationId":"rotateSigningSecret","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseString"}}}}}}},"/api/v1/vaults/rotate":{"post":{"tags":["Vault"],"summary":"Rotate DEK","description":"Generates a new Data Encryption Key, re-encrypts all secrets and alert-channel configs, and bumps the vault version. Admin-only. Pipeline DEK caches expire within ~10 minutes.","operationId":"rotateDek","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseDekRotationResultDto"}}}}}}},"/api/v1/tags":{"get":{"tags":["Tags"],"summary":"List tags for the authenticated organization","operationId":"list_3","parameters":[{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultTagDto"}}}}}},"post":{"tags":["Tags"],"summary":"Create a new tag","operationId":"create_4","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateTagRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseTagDto"}}}}}}},"/api/v1/service-subscriptions/{slug}":{"post":{"tags":["Service Subscriptions"],"summary":"Subscribe to a service or a component of a service","description":"Idempotent — returns the existing subscription if an identical one exists. Omit the request body or set componentId to null for a whole-service subscription. Free tier: max 10 subscriptions. Paid tier: unlimited.","operationId":"subscribe","parameters":[{"name":"slug","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ServiceSubscribeRequest"}}}},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseServiceSubscriptionDto"}}}}}}},"/api/v1/secrets":{"get":{"tags":["Secrets"],"summary":"List secrets","operationId":"list_4","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultSecretDto"}}}}}},"post":{"tags":["Secrets"],"summary":"Create secret","operationId":"create_5","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateSecretRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseSecretDto"}}}}}}},"/api/v1/resource-groups":{"get":{"tags":["Resource Groups"],"summary":"List all resource groups for the authenticated org with health summaries","operationId":"list_5","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultResourceGroupDto"}}}}}},"post":{"tags":["Resource Groups"],"summary":"Create a new resource group","operationId":"create_6","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateResourceGroupRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseResourceGroupDto"}}}}}}},"/api/v1/resource-groups/{id}/members":{"post":{"tags":["Resource Groups"],"summary":"Add a monitor or service member to a resource group","operationId":"addMember_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddResourceGroupMemberRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseResourceGroupMemberDto"}}}}}}},"/api/v1/notification-policies":{"get":{"tags":["Notification Policies"],"summary":"List all notification policies for the authenticated org","operationId":"list_6","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultNotificationPolicyDto"}}}}}},"post":{"tags":["Notification Policies"],"summary":"Create a notification policy with match rules and escalation chain","operationId":"create_7","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateNotificationPolicyRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseNotificationPolicyDto"}}}}}}},"/api/v1/notification-policies/{id}/test":{"post":{"tags":["Notification Policies"],"summary":"Dry-run: evaluate a policy's match rules against a supplied incident context","operationId":"test_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestNotificationPolicyRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseTestMatchResult"}}}}}}},"/api/v1/notification-dispatches/{id}/acknowledge":{"post":{"tags":["Notification Dispatches"],"summary":"Acknowledge a notification dispatch","description":"Marks the dispatch as acknowledged. The dispatch must be in DELIVERED or ESCALATING state. Sets acknowledgedAt, acknowledgedBy (actor email), and acknowledgedVia (DASHBOARD).","operationId":"acknowledge","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseNotificationDispatchDto"}}}}}}},"/api/v1/monitors":{"get":{"tags":["Monitors"],"summary":"List monitors for the authenticated org","operationId":"list_7","parameters":[{"name":"enabled","in":"query","description":"Filter by enabled state","required":false,"schema":{"type":"boolean"}},{"name":"type","in":"query","description":"Filter by monitor type","required":false,"schema":{"type":"string","enum":["HTTP","DNS","MCP_SERVER","TCP","ICMP","HEARTBEAT"]}},{"name":"managedBy","in":"query","description":"Filter by managed-by source","required":false,"schema":{"type":"string","enum":["DASHBOARD","CLI"]}},{"name":"tags","in":"query","description":"Filter by tag names, comma-separated (e.g. prod,critical)","required":false,"schema":{"type":"string"}},{"name":"search","in":"query","description":"Case-insensitive name search","required":false,"schema":{"type":"string"}},{"name":"environmentId","in":"query","description":"Filter by environment ID","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultMonitorDto"}}}}}},"post":{"tags":["Monitors"],"summary":"Create a new monitor","operationId":"create_8","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateMonitorRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorDto"}}}}}}},"/api/v1/monitors/{monitorId}/assertions":{"post":{"tags":["Monitor Assertions"],"summary":"Add an assertion to a monitor","operationId":"add","parameters":[{"name":"monitorId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAssertionRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorAssertionDto"}}}}}}},"/api/v1/monitors/{id}/test":{"post":{"tags":["Monitors"],"summary":"Test an existing monitor","description":"Runs the saved config and assertions of an existing monitor once, without persisting any result. Runs synchronously and returns the same shape as the ad-hoc test.","operationId":"testExisting","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorTestResultDto"}}}}}}},"/api/v1/monitors/{id}/tags":{"get":{"tags":["Monitors"],"summary":"Get all tags applied to a monitor","operationId":"getMonitorTags","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultTagDto"}}}}}},"post":{"tags":["Monitors"],"summary":"Add tags to a monitor; supports existing tag IDs and inline creation of new tags","operationId":"addMonitorTags","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddMonitorTagsRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultTagDto"}}}}}},"delete":{"tags":["Monitors"],"summary":"Remove tags from a monitor by their IDs","operationId":"removeMonitorTags","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RemoveMonitorTagsRequest"}}},"required":true},"responses":{"204":{"description":"No Content"}}}},"/api/v1/monitors/{id}/rotate-token":{"post":{"tags":["Monitors"],"summary":"Rotate the ping token for a heartbeat monitor","description":"Generates a new ping token. The old token remains valid for 24 hours to allow cron jobs to be updated without downtime. Only supported for HEARTBEAT monitors.","operationId":"rotateToken","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorDto"}}}}}}},"/api/v1/monitors/{id}/resume":{"post":{"tags":["Monitors"],"summary":"Resume a monitor (set enabled=true)","operationId":"resume","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorDto"}}}}}}},"/api/v1/monitors/{id}/pause":{"post":{"tags":["Monitors"],"summary":"Pause a monitor (set enabled=false)","operationId":"pause","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorDto"}}}}}}},"/api/v1/monitors/test":{"post":{"tags":["Monitors"],"summary":"Ad-hoc monitor test","description":"Executes a one-off check from an inline config without saving the monitor. Runs synchronously and returns status code, response time, assertion results, body preview, and headers.","operationId":"testAdHoc","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/MonitorTestRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorTestResultDto"}}}}}}},"/api/v1/monitors/bulk":{"post":{"tags":["Monitors"],"summary":"Bulk action on monitors","description":"Applies PAUSE, RESUME, DELETE, ADD_TAG, or REMOVE_TAG to a list of monitors. Returns a partial-success response indicating which monitors succeeded and which failed.","operationId":"bulkAction","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BulkMonitorActionRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseBulkMonitorActionResult"}}}}}}},"/api/v1/maintenance-windows":{"get":{"tags":["Maintenance Windows"],"summary":"List maintenance windows for the authenticated org","description":"Returns maintenance windows for the caller's organisation. Optionally filter by monitor_id, and/or by status: 'active' (currently in window) or 'upcoming' (starts in the future).","operationId":"list_8","parameters":[{"name":"monitorId","in":"query","description":"Filter by monitor UUID","required":false,"schema":{"type":"string","format":"uuid"}},{"name":"filter","in":"query","description":"Filter by status: 'active' or 'upcoming'","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultMaintenanceWindowDto"}}}}}},"post":{"tags":["Maintenance Windows"],"summary":"Create a maintenance window","description":"Creates a new maintenance window. Set monitorId to null to create an org-wide window that suppresses alerts for all monitors.","operationId":"create_9","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateMaintenanceWindowRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMaintenanceWindowDto"}}}}}}},"/api/v1/invites":{"get":{"tags":["Invites"],"summary":"List invites","operationId":"list_9","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultInviteDto"}}}}}},"post":{"tags":["Invites"],"summary":"Create invite","operationId":"create_10","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateInviteRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseInviteDto"}}}}}}},"/api/v1/invites/{inviteId}/revoke":{"post":{"tags":["Invites"],"summary":"Revoke invite","operationId":"revoke","parameters":[{"name":"inviteId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/invites/{inviteId}/resend":{"post":{"tags":["Invites"],"summary":"Resend invite","operationId":"resend","parameters":[{"name":"inviteId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseInviteDto"}}}}}}},"/api/v1/incidents":{"get":{"tags":["Incidents"],"summary":"List incidents for the authenticated org","operationId":"list_10","parameters":[{"name":"params","in":"query","required":true,"schema":{"$ref":"#/components/schemas/IncidentFilterParams"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultIncidentDto"}}}}}},"post":{"tags":["Incidents"],"summary":"Create a manual incident","operationId":"create_11","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateManualIncidentRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentDetailDto"}}}}}}},"/api/v1/incidents/{id}/updates":{"post":{"tags":["Incidents"],"summary":"Add an update to an incident (optionally change status)","operationId":"addUpdate","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddIncidentUpdateRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentDetailDto"}}}}}}},"/api/v1/incidents/{id}/resolve":{"post":{"tags":["Incidents"],"summary":"Resolve an incident","operationId":"resolve","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ResolveIncidentRequest"}}}},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentDetailDto"}}}}}}},"/api/v1/heartbeat/{token}":{"get":{"tags":["Heartbeat"],"summary":"Record a heartbeat ping (GET)","description":"Called by external systems (cron jobs, scheduled tasks) to signal liveness. Always returns 200 OK.","operationId":"pingGet","parameters":[{"name":"token","in":"path","description":"Ping endpoint token for the heartbeat monitor","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"boolean"}}}}}}},"post":{"tags":["Heartbeat"],"summary":"Record a heartbeat ping (POST)","description":"Called by external systems to signal liveness with an optional JSON payload. The payload can be inspected by heartbeat_payload_contains assertions. Always returns 200 OK.","operationId":"pingPost","parameters":[{"name":"token","in":"path","description":"Ping endpoint token for the heartbeat monitor","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"type":"string"}},"text/plain":{"schema":{"type":"string"}},"*/*":{"schema":{"type":"string"}}}},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"object","additionalProperties":{"type":"boolean"}}}}}}}},"/api/v1/environments":{"get":{"tags":["Environments"],"summary":"List environments","operationId":"list_11","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultEnvironmentDto"}}}}}},"post":{"tags":["Environments"],"summary":"Create environment","operationId":"create_12","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateEnvironmentRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseEnvironmentDto"}}}}}}},"/api/v1/deploy/lock":{"get":{"tags":["Deploy Lock"],"summary":"Get current deploy lock","description":"Returns the active deploy lock for the current workspace, if any.","operationId":"current","parameters":[{"name":"x-phelm-workspace-id","in":"header","required":false,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseDeployLockDto"}}}}}},"post":{"tags":["Deploy Lock"],"summary":"Acquire deploy lock","description":"Acquires an exclusive deploy lock for the current workspace. Returns 409 Conflict if the workspace is already locked by another session.","operationId":"acquire","parameters":[{"name":"x-phelm-workspace-id","in":"header","description":"Target workspace ID (defaults to 1)","required":false,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AcquireDeployLockRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseDeployLockDto"}}}}}}},"/api/v1/api-keys":{"get":{"tags":["API Keys"],"summary":"List API keys","operationId":"list_12","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultApiKeyDto"}}}}}},"post":{"tags":["API Keys"],"summary":"Create API key","operationId":"create_13","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateApiKeyRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseApiKeyCreateResponse"}}}}}}},"/api/v1/api-keys/{id}/revoke":{"post":{"tags":["API Keys"],"summary":"Revoke API key","operationId":"revoke_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseApiKeyDto"}}}}}}},"/api/v1/api-keys/{id}/regenerate":{"post":{"tags":["API Keys"],"summary":"Regenerate API key","operationId":"regenerate","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseApiKeyCreateResponse"}}}}}}},"/api/v1/alert-deliveries/{id}/retry":{"post":{"tags":["Alert Deliveries"],"summary":"Retry a failed delivery","description":"Resets a FAILED delivery to RETRY_PENDING so the delivery worker re-attempts it.","operationId":"retry","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAlertDeliveryDto"}}}}}}},"/api/v1/alert-channels":{"get":{"tags":["Alert Channels"],"summary":"List active alert channels for the authenticated org","operationId":"list_13","parameters":[{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultAlertChannelDto"}}}}}},"post":{"tags":["Alert Channels"],"summary":"Create a new alert channel with encrypted config","operationId":"create_14","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateAlertChannelRequest"}}},"required":true},"responses":{"201":{"description":"Created","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAlertChannelDto"}}}}}}},"/api/v1/alert-channels/{id}/test":{"post":{"tags":["Alert Channels"],"summary":"Test a saved alert channel's connectivity","operationId":"test_2","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseTestChannelResult"}}}}}}},"/api/v1/alert-channels/test":{"post":{"tags":["Alert Channels"],"summary":"Test alert channel connectivity using raw config (no saved channel required)","operationId":"testConfig","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/TestAlertChannelRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseTestChannelResult"}}}}}}},"/v1/internal/service-incidents/by-ref/{serviceId}/{externalRef}/components":{"patch":{"tags":["service-incident-internal-controller"],"operationId":"addComponents","parameters":[{"name":"serviceId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"externalRef","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ComponentUpdateRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultIncidentDto"}}}}}}},"/api/v1/service-subscriptions/{id}/alert-sensitivity":{"patch":{"tags":["Service Subscriptions"],"summary":"Update alert sensitivity for a subscription","description":"Controls which external incidents trigger alerts: ALL (any status change), INCIDENTS_ONLY (real vendor incidents, default), MAJOR_ONLY (only DOWN-level incidents).","operationId":"updateAlertSensitivity","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateAlertSensitivityRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseServiceSubscriptionDto"}}}}}}},"/api/v1/api-keys/{id}":{"delete":{"tags":["API Keys"],"summary":"Delete API key","operationId":"delete_10","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}},"patch":{"tags":["API Keys"],"summary":"Update API key","operationId":"update_14","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateApiKeyRequest"}}},"required":true},"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseApiKeyDto"}}}}}}},"/v1/internal/workspaces/{id}":{"get":{"tags":["workspaces-controller"],"operationId":"get_7","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWorkspaceDto"}}}}}}},"/v1/internal/orgs/{id}/workspaces":{"get":{"tags":["orgs-controller"],"operationId":"listWorkspaces_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultWorkspaceDto"}}}}}}},"/v1/internal/monitors/{id}/policy":{"get":{"tags":["monitors-internal-controller"],"operationId":"policy","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentPolicyDto"}}}}}}},"/v1/internal/monitors/{id}/env-variables":{"get":{"tags":["monitors-internal-controller"],"operationId":"getEnvVariables","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMapStringString"}}}}}}},"/v1/internal/monitors/{id}/auth":{"get":{"tags":["monitors-internal-controller"],"operationId":"auth","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorAuthDto"}}}}}}},"/v1/internal/monitors/{id}/assertions":{"get":{"tags":["monitors-internal-controller"],"operationId":"getAssertions","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseListMonitorAssertionDto"}}}}}}},"/v1/internal/monitors/{id}/active-incident":{"get":{"tags":["monitors-internal-controller"],"operationId":"activeIncident","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentDto"}}}}}}},"/v1/internal/monitors/schedulable":{"get":{"tags":["monitors-internal-controller"],"operationId":"schedulable","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SchedulableMonitorDto"}}}}}}}},"/platform/plans":{"get":{"tags":["Billing"],"summary":"List public billing plans","operationId":"getPublicPlans","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseListBillingPlanDto"}}}}}}},"/platform/orgs/{orgId}/subscriptions":{"get":{"tags":["Subscriptions"],"summary":"List active subscriptions","operationId":"listActive","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultSubscriptionDto"}}}}}},"delete":{"tags":["Subscriptions"],"operationId":"cancel","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}}},"/platform/orgs/{orgId}/subscriptions/upcoming-charge":{"get":{"tags":["Subscriptions"],"summary":"Get upcoming charge","operationId":"getUpcomingCharge","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"priceId","in":"query","required":true,"schema":{"minimum":1,"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUpcomingChargeResponse"}}}}}}},"/platform/orgs/{orgId}/subscriptions/management-urls":{"get":{"tags":["Subscriptions"],"summary":"Get subscription management URLs","operationId":"getManagementUrls","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMapStringString"}}}}}}},"/platform/orgs/{orgId}/subscriptions/customer-auth-token":{"get":{"tags":["Subscriptions"],"summary":"Get customer auth token","operationId":"getCustomerAuthToken","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseString"}}}}}}},"/platform/orgs/{orgId}/entitlements":{"get":{"tags":["Entitlements"],"summary":"Get resolved entitlements and current usage for the organization","operationId":"getEntitlements","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseEntitlementResponse"}}}}}}},"/platform/orgs/search":{"get":{"tags":["Organizations"],"summary":"Search organizations","operationId":"searchOrganizations","parameters":[{"name":"query","in":"query","required":true,"schema":{"type":"string"}},{"name":"paginationParams","in":"query","required":true,"schema":{"$ref":"#/components/schemas/PaginationParams"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultIdValuePair"}}}}}}},"/platform/me/orgs":{"get":{"tags":["Me"],"summary":"Get current user's organizations","operationId":"myOrgs","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultMyOrgItemDto"}}}}}}},"/platform/events/stream":{"get":{"tags":["Events"],"summary":"Subscribe to real-time platform events via SSE","operationId":"stream","responses":{"200":{"description":"OK","content":{"text/event-stream":{"schema":{"$ref":"#/components/schemas/SseEmitter"}}}}}}},"/platform/admin/users":{"get":{"tags":["admin-controller"],"operationId":"listUsers","parameters":[{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultUserDto"}}}}}}},"/platform/admin/stats":{"get":{"tags":["admin-controller"],"operationId":"getStats","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAdminStatsDto"}}}}}}},"/platform/admin/orgs":{"get":{"tags":["admin-controller"],"operationId":"listOrgs","parameters":[{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultOrganizationDto"}}}}}}},"/platform/admin/adapters/health":{"get":{"tags":["admin-adapter-health-controller"],"operationId":"getAdapterHealth","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultAdapterHealthDto"}}}}}}},"/api/v1/webhooks/{id}/deliveries":{"get":{"tags":["Webhooks"],"summary":"List recent deliveries for a webhook endpoint","operationId":"listDeliveries","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultWebhookDeliveryDto"}}}}}}},"/api/v1/webhooks/signing-secret":{"get":{"tags":["Webhooks"],"summary":"Get signing secret metadata for the authenticated org","operationId":"getSigningSecretInfo","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseWebhookSigningSecretDto"}}}}}}},"/api/v1/webhooks/events":{"get":{"tags":["Webhooks"],"summary":"List all available webhook event types","description":"Returns the full catalog of supported outbound webhook event types with their surface grouping and human-readable descriptions. Use this to populate subscription checkboxes when creating or updating a webhook endpoint.","operationId":"listEvents","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/WebhookEventCatalogResponse"}}}}}}},"/api/v1/services":{"get":{"tags":["Status Data"],"summary":"List all enabled services (cursor-paginated)","operationId":"listServices","parameters":[{"name":"category","in":"query","description":"Filter by category (exact match)","required":false,"schema":{"type":"string"}},{"name":"status","in":"query","description":"Filter by current overall_status (exact match)","required":false,"schema":{"type":"string"}},{"name":"cursor","in":"query","description":"Opaque cursor from a previous response","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Page size (1–100, default 20)","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CursorPageServiceCatalogDto"}}}}}}},"/api/v1/services/{slugOrId}":{"get":{"tags":["Status Data"],"summary":"Get a single service by slug or UUID with current status, components, and recent incidents","operationId":"getService","parameters":[{"name":"slugOrId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseServiceDetailDto"}}}}}}},"/api/v1/services/{slugOrId}/uptime":{"get":{"tags":["Status Data"],"summary":"Get uptime statistics for a service","description":"Uptime data aggregated across active non-group components.","operationId":"getServiceUptime","parameters":[{"name":"slugOrId","in":"path","required":true,"schema":{"type":"string"}},{"name":"period","in":"query","description":"Time window","required":false,"schema":{"type":"string","enum":["24h","7d","30d","90d","1y","2y","all"]}},{"name":"granularity","in":"query","description":"Bucket granularity","required":false,"schema":{"type":"string","enum":["hourly","daily","monthly"]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseServiceUptimeResponse"}}}}},"security":[{"BearerAuth":[]}]}},"/api/v1/services/{slugOrId}/maintenances":{"get":{"tags":["Status Data"],"summary":"List scheduled maintenances for a service","operationId":"getScheduledMaintenances","parameters":[{"name":"slugOrId","in":"path","required":true,"schema":{"type":"string"}},{"name":"status","in":"query","description":"Filter by status (e.g. scheduled, in_progress, verifying, completed)","required":false,"schema":{"type":"array","items":{"type":"string"}}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultScheduledMaintenanceDto"}}}}}}},"/api/v1/services/{slugOrId}/incidents":{"get":{"tags":["Status Data"],"summary":"List incident history for a service (paginated)","operationId":"listIncidents","parameters":[{"name":"slugOrId","in":"path","required":true,"schema":{"type":"string"}},{"name":"from","in":"query","description":"Earliest start date (ISO 8601 date)","required":false,"schema":{"type":"string","format":"date"}},{"name":"status","in":"query","description":"Filter: active (unresolved), resolved, or omit for all","required":false,"schema":{"type":"string","enum":["active","resolved"]}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultServiceIncidentDto"}}}}}}},"/api/v1/services/{slugOrId}/incidents/{incidentId}":{"get":{"tags":["Status Data"],"summary":"Get incident detail with full update timeline","operationId":"getIncident","parameters":[{"name":"slugOrId","in":"path","required":true,"schema":{"type":"string"}},{"name":"incidentId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseServiceIncidentDetailDto"}}}}}}},"/api/v1/services/{slugOrId}/components":{"get":{"tags":["Status Data"],"summary":"List active components for a service with current status and inline uptime","operationId":"getComponents","parameters":[{"name":"slugOrId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultServiceComponentDto"}}}}}}},"/api/v1/services/{slugOrId}/components/{componentId}/uptime":{"get":{"tags":["Status Data"],"summary":"Get daily uptime data for a component","operationId":"getComponentUptime","parameters":[{"name":"slugOrId","in":"path","required":true,"schema":{"type":"string"}},{"name":"componentId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"period","in":"query","description":"Time window","required":false,"schema":{"type":"string","enum":["7d","30d","90d","1y"]}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultComponentUptimeDayDto"}}}}}}},"/api/v1/services/summary":{"get":{"tags":["Status Data"],"summary":"Global status summary across all services","description":"Returns aggregate counts of services by status and a list of services currently experiencing issues.","operationId":"getGlobalStatusSummary","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseGlobalStatusSummaryDto"}}}}}}},"/api/v1/services/incidents":{"get":{"tags":["Status Data"],"summary":"List vendor incidents across all services (paginated)","description":"Cross-service vendor incident feed ordered by start date descending.","operationId":"listCrossServiceIncidents","parameters":[{"name":"from","in":"query","description":"Earliest start date (ISO 8601 date)","required":false,"schema":{"type":"string","format":"date"}},{"name":"status","in":"query","description":"Filter: active (unresolved), resolved, or omit for all","required":false,"schema":{"type":"string","enum":["active","resolved"]}},{"name":"category","in":"query","description":"Filter by service category","required":false,"schema":{"type":"string"}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultServiceIncidentDto"}}}}}}},"/api/v1/service-subscriptions":{"get":{"tags":["Service Subscriptions"],"summary":"List all service subscriptions for the organization","operationId":"list_14","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultServiceSubscriptionDto"}}}}}}},"/api/v1/service-subscriptions/{id}":{"get":{"tags":["Service Subscriptions"],"summary":"Get a subscription by its ID","operationId":"get_8","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseServiceSubscriptionDto"}}}}}},"delete":{"tags":["Service Subscriptions"],"summary":"Remove a subscription by its ID","description":"Removes a specific subscription (whole-service or component-level). No-op if not found.","operationId":"unsubscribe","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/resource-groups/{id}/health":{"get":{"tags":["Resource Groups"],"summary":"Get the detailed health breakdown for a resource group","description":"Returns member counts, worst-of status, and threshold-based health evaluation. The thresholdStatus field is populated only when a health threshold is configured.","operationId":"getHealth","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseResourceGroupHealthDto"}}}}}}},"/api/v1/notifications":{"get":{"tags":["Notifications"],"summary":"List notifications for the current user","operationId":"list_15","parameters":[{"name":"unreadOnly","in":"query","required":false,"schema":{"type":"boolean","default":false}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"size","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":20}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultNotificationDto"}}}}}}},"/api/v1/notifications/unread-count":{"get":{"tags":["Notifications"],"summary":"Get unread notification count","operationId":"unreadCount","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseLong"}}}}}}},"/api/v1/notification-policies/{id}/dispatches":{"get":{"tags":["Notification Policies"],"summary":"List all dispatches (firing history) for a notification policy","operationId":"listDispatches","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultNotificationDispatchDto"}}}}}}},"/api/v1/notification-dispatches":{"get":{"tags":["Notification Dispatches"],"summary":"List all dispatches for an incident","description":"Returns all notification dispatches for the given incident that belong to the authenticated org's policies. Each dispatch includes delivery records for all associated channels.","operationId":"listByIncident","parameters":[{"name":"incident_id","in":"query","description":"UUID of the incident to inspect","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultNotificationDispatchDto"}}}}}}},"/api/v1/notification-dispatches/{id}":{"get":{"tags":["Notification Dispatches"],"summary":"Get a single dispatch with full escalation and delivery history","description":"Returns the dispatch state including current escalation step, acknowledgment info, and all delivery attempts made across every step.","operationId":"getById_3","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseNotificationDispatchDto"}}}}}}},"/api/v1/monitors/{id}/versions":{"get":{"tags":["Monitors"],"summary":"List version history for a monitor","description":"Returns a paginated list of mutation snapshots for the monitor, newest first. Each version captures the full monitor config at the time of a PUT /monitors/{id} call.","operationId":"listVersions","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultMonitorVersionDto"}}}}}}},"/api/v1/monitors/{id}/versions/{version}":{"get":{"tags":["Monitors"],"summary":"Get a specific version snapshot for a monitor","description":"Returns the full monitor config snapshot captured at the given version number.","operationId":"getVersion","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"version","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseMonitorVersionDto"}}}}}}},"/api/v1/monitors/{id}/uptime":{"get":{"tags":["Check Results"],"summary":"Get uptime statistics","description":"Returns uptime percentage and latency statistics for the requested time window, computed from continuous aggregates. Uses hourly aggregates for 24h/7d windows and daily aggregates for 30d/90d windows.","operationId":"getUptime","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"window","in":"query","description":"Time window for uptime calculation","required":false,"schema":{"type":"string","enum":["24h","7d","30d","90d"]}}],"responses":{"200":{"description":"Uptime statistics","content":{"*/*":{"schema":{"$ref":"#/components/schemas/UptimeDto"}}}},"400":{"description":"Invalid window parameter","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUptimeDto"}}}},"403":{"description":"Monitor does not belong to the caller's org","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUptimeDto"}}}},"404":{"description":"Monitor not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseUptimeDto"}}}}}}},"/api/v1/monitors/{id}/results":{"get":{"tags":["Check Results"],"summary":"List raw check results","description":"Returns check results for the given monitor with optional time-range, region, and pass/fail filtering. Uses cursor-based pagination — pass the returned `cursor` value on subsequent requests to retrieve the next page. The cursor encodes the original time bounds, so `from`/`to` are ignored when a cursor is present.","operationId":"getResults","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"from","in":"query","description":"Start of time range (ISO 8601, inclusive); defaults to 24 hours ago","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","description":"End of time range (ISO 8601, inclusive); defaults to now","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"cursor","in":"query","description":"Opaque cursor from a previous response for pagination","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Maximum results per page (1–200)","required":false,"schema":{"type":"integer","format":"int32","default":50},"example":50},{"name":"region","in":"query","description":"Filter by region (e.g. us-east)","required":false,"schema":{"type":"string"}},{"name":"passed","in":"query","description":"Filter by pass/fail status","required":false,"schema":{"type":"boolean"}}],"responses":{"200":{"description":"Paginated check results","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CursorPage"}}}},"400":{"description":"Invalid query parameters","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CursorPageCheckResultDto"}}}},"403":{"description":"Monitor does not belong to the caller's org","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CursorPageCheckResultDto"}}}},"404":{"description":"Monitor not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/CursorPageCheckResultDto"}}}}}}},"/api/v1/monitors/{id}/results/summary":{"get":{"tags":["Check Results"],"summary":"Get results summary","description":"Returns a dashboard summary for the monitor: current status derived from the latest result per region, time-bucketed chart data, the 24-hour uptime percentage, and the selected window's uptime percentage.","operationId":"getSummary","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"chartWindow","in":"query","description":"Chart window: 24h returns hourly buckets, 7d/30d/90d return daily buckets","required":false,"schema":{"type":"string","enum":["24h","7d","30d","90d"]}}],"responses":{"200":{"description":"Results summary","content":{"*/*":{"schema":{"$ref":"#/components/schemas/ResultSummaryDto"}}}},"400":{"description":"Invalid chartWindow parameter","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseResultSummaryDto"}}}},"403":{"description":"Monitor does not belong to the caller's org","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseResultSummaryDto"}}}},"404":{"description":"Monitor not found","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseResultSummaryDto"}}}}}}},"/api/v1/members":{"get":{"tags":["Members"],"summary":"List organization members","operationId":"list_16","parameters":[{"name":"pageable","in":"query","required":true,"schema":{"$ref":"#/components/schemas/Pageable"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultMemberDto"}}}}}}},"/api/v1/integrations":{"get":{"tags":["Integrations"],"summary":"List all supported integration types","description":"Returns the full static catalog of supported alert channel integration types with their metadata and config field schemas. Used by the frontend to dynamically render the 'Add Alert Channel' form.","operationId":"list_17","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/IntegrationCatalogResponse"}}}}}}},"/api/v1/incidents/{id}":{"get":{"tags":["Incidents"],"summary":"Get incident details including update timeline","operationId":"get_9","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseIncidentDetailDto"}}}}}}},"/api/v1/dashboard/overview":{"get":{"tags":["Dashboard"],"summary":"Dashboard overview","description":"Returns monitor status counts, average uptime windows, and incident aggregates for the authenticated org. Results are cached for 1 minute.","operationId":"overview","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseDashboardOverviewDto"}}}}}}},"/api/v1/categories":{"get":{"tags":["Status Data"],"summary":"List categories with service counts","operationId":"listCategories","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultCategoryDto"}}}}}}},"/api/v1/auth/me":{"get":{"tags":["API Auth"],"summary":"Get current API key identity","description":"Returns the authenticated API key's metadata, organization, billing plan, entitlements with usage, and current rate-limit quota. Only available for API key authentication (Bearer dh_live_...).","operationId":"me_1","responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/SingleValueResponseAuthMeResponse"}}}}}}},"/api/v1/audit-log":{"get":{"tags":["Audit Log"],"summary":"List audit events for the current organization","operationId":"list_18","parameters":[{"name":"action","in":"query","required":false,"schema":{"type":"string"}},{"name":"actorId","in":"query","required":false,"schema":{"type":"integer","format":"int32"}},{"name":"resourceType","in":"query","required":false,"schema":{"type":"string"}},{"name":"from","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"to","in":"query","required":false,"schema":{"type":"string","format":"date-time"}},{"name":"page","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":0}},{"name":"size","in":"query","required":false,"schema":{"type":"integer","format":"int32","default":50}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/PageResultAuditEventDto"}}}}}}},"/api/v1/alert-deliveries/{id}/attempts":{"get":{"tags":["Alert Deliveries"],"summary":"List delivery attempts for a specific alert delivery","description":"Returns the ordered list of delivery attempts (request/response audit data) for the given delivery ID.","operationId":"listAttempts","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultDeliveryAttemptDto"}}}}}}},"/api/v1/alert-channels/{id}/deliveries":{"get":{"tags":["Alert Channels"],"summary":"List delivery history for an alert channel","operationId":"listDeliveries_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"*/*":{"schema":{"$ref":"#/components/schemas/TableValueResultAlertDeliveryDto"}}}}}}},"/platform/orgs/{orgId}":{"delete":{"tags":["Organizations"],"summary":"Delete organization","operationId":"delete_11","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}}},"/platform/admin/orgs/{orgId}/members/{userId}":{"delete":{"tags":["admin-member-controller"],"operationId":"removeMember","parameters":[{"name":"orgId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}},{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/resource-groups/{id}/members/{memberId}":{"delete":{"tags":["Resource Groups"],"summary":"Remove a member from a resource group","operationId":"removeMember_1","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"memberId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/members/{userId}":{"delete":{"tags":["Members"],"summary":"Remove member from organization","operationId":"remove_2","parameters":[{"name":"userId","in":"path","required":true,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/deploy/lock/{lockId}":{"delete":{"tags":["Deploy Lock"],"summary":"Release deploy lock","description":"Releases a deploy lock by ID. Only the lock holder should call this.","operationId":"release","parameters":[{"name":"lockId","in":"path","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"x-phelm-workspace-id","in":"header","required":false,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}}},"/api/v1/deploy/lock/force":{"delete":{"tags":["Deploy Lock"],"summary":"Force-release deploy lock","description":"Forcibly removes any deploy lock on the current workspace. Use to break stale locks.","operationId":"forceRelease","parameters":[{"name":"x-phelm-workspace-id","in":"header","required":false,"schema":{"type":"integer","format":"int32"}}],"responses":{"204":{"description":"No Content"}}}}},"components":{"schemas":{"CreateSubscriptionRequest":{"type":"object","properties":{"priceId":{"minimum":1,"type":"integer","format":"int32"}}},"BillingPlanDto":{"type":"object","properties":{"id":{"type":"integer","description":"Unique billing plan identifier","format":"int32"},"paddleId":{"type":"string","description":"Paddle product identifier"},"name":{"type":"string","description":"Billing plan display name"},"description":{"type":"string","description":"Plan description","nullable":true},"prices":{"type":"array","description":"Available prices for this plan; null when not requested","nullable":true,"items":{"$ref":"#/components/schemas/BillingPriceDto"}}},"description":"Associated billing plan; null when not requested","nullable":true},"BillingPriceDto":{"type":"object","properties":{"id":{"type":"integer","description":"Unique billing price identifier","format":"int32"},"paddleId":{"type":"string","description":"Paddle price identifier"},"amount":{"type":"integer","description":"Price amount in smallest currency unit (e.g. cents)","format":"int32"},"interval":{"type":"string","description":"Billing interval (MONTH or YEAR)","enum":["DAY","WEEK","MONTH","YEAR"]},"intervalCount":{"type":"integer","description":"Number of intervals between billing cycles","format":"int32"},"description":{"type":"string","description":"Price description","nullable":true},"billingPlan":{"$ref":"#/components/schemas/BillingPlanDto"}},"description":"Price details for this line item"},"ItemDto":{"type":"object","properties":{"billingPrice":{"$ref":"#/components/schemas/BillingPriceDto"},"quantity":{"type":"integer","description":"Quantity of this price","format":"int32"},"amount":{"type":"integer","description":"Line item total in smallest currency unit","format":"int32"}},"description":"Line items included in this subscription"},"SingleValueResponseSubscriptionDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/SubscriptionDto"}}},"SubscriptionDto":{"type":"object","properties":{"id":{"type":"integer","description":"Internal subscription identifier","format":"int32"},"paddleId":{"type":"string","description":"Paddle subscription identifier"},"createdAt":{"type":"string","description":"Timestamp when the subscription was created","format":"date-time"},"updatedAt":{"type":"string","description":"Timestamp when the subscription was last updated","format":"date-time"},"organizationId":{"type":"integer","description":"Organization this subscription belongs to","format":"int32"},"status":{"type":"string","description":"Current subscription status","enum":["ACTIVE","CANCELED","PAST_DUE","PAUSED","TRIALING"]},"nextBilledAt":{"type":"string","description":"Next billing date; null when cancelled or expired","format":"date-time","nullable":true},"willCancelAt":{"type":"string","description":"Scheduled cancellation date; null if no cancellation pending","format":"date-time","nullable":true},"items":{"type":"array","description":"Line items included in this subscription","items":{"$ref":"#/components/schemas/ItemDto"}}},"description":"Current billing subscription details"},"UpdateOrgDetailsRequest":{"required":["email","name"],"type":"object","properties":{"name":{"maxLength":200,"minLength":0,"type":"string","description":"New organization name (max 200 chars)"},"email":{"minLength":1,"type":"string","description":"New billing and contact email address","format":"email"},"size":{"maxLength":50,"minLength":0,"type":"string","description":"Team size range (e.g. 1-10, 11-50)"},"industry":{"maxLength":100,"minLength":0,"type":"string","description":"Industry vertical (e.g. SaaS, Fintech)"},"websiteUrl":{"maxLength":255,"minLength":0,"type":"string","description":"Organization website URL (max 255 chars)"}}},"OrganizationDto":{"type":"object","properties":{"id":{"type":"integer","description":"Unique organization identifier","format":"int32"},"name":{"type":"string","description":"Organization name"},"email":{"type":"string","description":"Billing and contact email","nullable":true},"size":{"type":"string","description":"Team size range (e.g. 1-10, 11-50)","nullable":true},"industry":{"type":"string","description":"Industry vertical (e.g. SaaS, Fintech)","nullable":true},"websiteUrl":{"type":"string","description":"Organization website URL","nullable":true}},"description":"Organization account details"},"SingleValueResponseOrganizationDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/OrganizationDto"}}},"UpdateOnboardingStageRequest":{"required":["stage"],"type":"object","properties":{"stage":{"type":"string","description":"New onboarding stage","enum":["WELCOME","FIRST_MONITOR","SETUP_COMPLETE","COMPLETED"]}},"description":"Advance the user's onboarding stage"},"SingleValueResponseUserDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/UserDto"}}},"UserDto":{"type":"object","properties":{"id":{"type":"integer","description":"Unique user identifier","format":"int32"},"email":{"type":"string","description":"User email address"},"emailVerified":{"type":"boolean","description":"Whether the email address has been verified"},"name":{"type":"string","description":"Display name; null if not set","nullable":true},"userRole":{"type":"string","description":"Platform role: USER or SUPERADMIN","enum":["SUPERADMIN","ADMIN","USER"]},"onboardingStage":{"type":"string","description":"Current onboarding progress stage; null when completed","nullable":true,"enum":["WELCOME","FIRST_MONITOR","SETUP_COMPLETE","COMPLETED"]},"imageUrl":{"type":"string","description":"Profile image URL; null if not set","nullable":true},"createdAt":{"type":"string","description":"Timestamp when the account was created","format":"date-time"},"updatedAt":{"type":"string","description":"Timestamp when the account was last updated","format":"date-time"}},"description":"User account details"},"UpdateProfileRequest":{"type":"object","properties":{"name":{"maxLength":200,"minLength":0,"type":"string","description":"New display name (max 200 chars)"}}},"UpdateNotificationPreferencesRequest":{"required":["preferences"],"type":"object","properties":{"preferences":{"type":"object","additionalProperties":{"type":"boolean","description":"Map of category keys to enabled/disabled flags"},"description":"Map of category keys to enabled/disabled flags"}},"description":"Replace notification preferences for the current user"},"NotificationPreferencesDto":{"type":"object","properties":{"preferences":{"type":"object","additionalProperties":{"type":"boolean","description":"Map of category keys to enabled/disabled flags"},"description":"Map of category keys to enabled/disabled flags"},"updatedAt":{"type":"string","description":"Timestamp when preferences were last updated","format":"date-time"}},"description":"User notification preferences keyed by notification category"},"SingleValueResponseNotificationPreferencesDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/NotificationPreferencesDto"}}},"UpdateWorkspaceRequest":{"required":["name"],"type":"object","properties":{"name":{"maxLength":200,"minLength":0,"type":"string","description":"New workspace name"}},"description":"Update workspace details"},"SingleValueResponseWorkspaceDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WorkspaceDto"}}},"WorkspaceDto":{"type":"object","properties":{"id":{"type":"integer","description":"Unique workspace identifier","format":"int32"},"createdAt":{"type":"string","description":"Timestamp when the workspace was created","format":"date-time"},"updatedAt":{"type":"string","description":"Timestamp when the workspace was last updated","format":"date-time"},"name":{"type":"string","description":"Workspace name"},"orgId":{"type":"integer","description":"Organization this workspace belongs to","format":"int32"}},"description":"Workspace within an organization"},"UpdateUserRequest":{"type":"object","properties":{"name":{"maxLength":200,"minLength":0,"type":"string","description":"New display name (max 200 chars)"},"email":{"type":"string","description":"New email address","format":"email"},"userRole":{"type":"string","description":"New platform role","enum":["SUPERADMIN","ADMIN","USER"]},"onboardingStage":{"type":"string","description":"New onboarding stage","enum":["WELCOME","FIRST_MONITOR","SETUP_COMPLETE","COMPLETED"]},"imageUrl":{"maxLength":500,"minLength":0,"type":"string","description":"New profile image URL (max 500 chars)"}}},"ChangeRoleRequest":{"required":["orgRole"],"type":"object","properties":{"orgRole":{"type":"string","description":"New role to assign","enum":["OWNER","ADMIN","MEMBER"]}},"description":"Update an organization member's role"},"UpdateWebhookEndpointRequest":{"type":"object","properties":{"url":{"maxLength":2048,"minLength":0,"type":"string","description":"New webhook URL; null preserves current","nullable":true},"description":{"maxLength":255,"minLength":0,"type":"string","description":"New description; null preserves current","nullable":true},"subscribedEvents":{"type":"array","description":"Replace subscribed events; null preserves current","nullable":true,"items":{"type":"string","description":"Replace subscribed events; null preserves current","nullable":true}},"enabled":{"type":"boolean","description":"Enable or disable delivery; null preserves current","nullable":true}}},"SingleValueResponseWebhookEndpointDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WebhookEndpointDto"}}},"WebhookEndpointDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique webhook endpoint identifier","format":"uuid"},"url":{"type":"string","description":"HTTPS endpoint URL that receives event payloads"},"description":{"type":"string","description":"Human-readable description of this endpoint","nullable":true},"subscribedEvents":{"type":"array","description":"Event types this endpoint is subscribed to","items":{"type":"string","description":"Event types this endpoint is subscribed to"}},"enabled":{"type":"boolean","description":"Whether delivery is enabled for this endpoint"},"consecutiveFailures":{"type":"integer","description":"Number of consecutive delivery failures","format":"int32"},"disabledReason":{"type":"string","description":"Reason the endpoint was auto-disabled","nullable":true},"disabledAt":{"type":"string","description":"Timestamp when the endpoint was auto-disabled","format":"date-time","nullable":true},"createdAt":{"type":"string","description":"Timestamp when the endpoint was created","format":"date-time"},"updatedAt":{"type":"string","description":"Timestamp when the endpoint was last updated","format":"date-time"}},"description":"Webhook endpoint that receives event delivery payloads"},"UpdateTagRequest":{"type":"object","properties":{"name":{"maxLength":100,"minLength":0,"type":"string","description":"New tag name","nullable":true},"color":{"pattern":"^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$","type":"string","description":"New hex color code","nullable":true}},"description":"Request body for updating a tag; null fields are left unchanged"},"SingleValueResponseTagDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TagDto"}}},"TagDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique tag identifier","format":"uuid"},"organizationId":{"type":"integer","description":"Organization this tag belongs to","format":"int32"},"name":{"type":"string","description":"Tag name, unique within the org"},"color":{"type":"string","description":"Hex color code for display (e.g. #6B7280)"},"createdAt":{"type":"string","description":"Timestamp when the tag was created","format":"date-time"},"updatedAt":{"type":"string","description":"Timestamp when the tag was last updated","format":"date-time"}},"description":"Tag for organizing and filtering monitors"},"UpdateSecretRequest":{"required":["value"],"type":"object","properties":{"value":{"maxLength":32768,"minLength":0,"type":"string","description":"New secret value, stored encrypted (max 32KB)"}}},"MonitorReference":{"type":"object","properties":{"id":{"type":"string","description":"Monitor identifier","format":"uuid"},"name":{"type":"string","description":"Monitor name"}},"description":"Monitors that reference this secret; null on create/update responses"},"SecretDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique secret identifier","format":"uuid"},"key":{"type":"string","description":"Secret key name, unique within the workspace"},"dekVersion":{"type":"integer","description":"DEK version at the time of last encryption","format":"int32"},"valueHash":{"type":"string","description":"SHA-256 hex digest of the current plaintext; use for change detection"},"createdAt":{"type":"string","description":"Timestamp when the secret was created","format":"date-time"},"updatedAt":{"type":"string","description":"Timestamp when the secret was last updated","format":"date-time"},"usedByMonitors":{"type":"array","description":"Monitors that reference this secret; null on create/update responses","nullable":true,"items":{"$ref":"#/components/schemas/MonitorReference"}}},"description":"Secret with change-detection hash; plaintext value is never returned"},"SingleValueResponseSecretDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/SecretDto"}}},"RetryStrategy":{"required":["type"],"type":"object","properties":{"type":{"type":"string","description":"Retry strategy kind, e.g. fixed interval between attempts"},"maxRetries":{"type":"integer","description":"Maximum number of retries after a failed check","format":"int32"},"interval":{"type":"integer","description":"Delay between retry attempts in seconds","format":"int32"}},"description":"Default retry strategy for member monitors; null clears"},"UpdateResourceGroupRequest":{"required":["name"],"type":"object","properties":{"name":{"maxLength":255,"minLength":0,"type":"string","description":"Human-readable name for this group"},"description":{"type":"string","description":"Optional description; null clears the existing value","nullable":true},"alertPolicyId":{"type":"string","description":"Optional notification policy to apply for this group; null clears the existing value","format":"uuid","nullable":true},"defaultFrequency":{"maximum":86400,"minimum":30,"type":"integer","description":"Default check frequency in seconds for members (30–86400); null clears","format":"int32","nullable":true},"defaultRegions":{"type":"array","description":"Default regions for member monitors; null clears","nullable":true,"items":{"type":"string","description":"Default regions for member monitors; null clears","nullable":true}},"defaultRetryStrategy":{"$ref":"#/components/schemas/RetryStrategy"},"defaultAlertChannels":{"type":"array","description":"Default alert channel IDs for member monitors; null clears","nullable":true,"items":{"type":"string","description":"Default alert channel IDs for member monitors; null clears","format":"uuid","nullable":true}},"defaultEnvironmentId":{"type":"string","description":"Default environment ID for member monitors; null clears","format":"uuid","nullable":true},"healthThresholdType":{"type":"string","description":"Health threshold type: COUNT or PERCENTAGE; null disables threshold","nullable":true,"enum":["COUNT","PERCENTAGE"]},"healthThresholdValue":{"maximum":100,"exclusiveMaximum":false,"minimum":0,"exclusiveMinimum":false,"type":"number","description":"Health threshold value; null disables threshold","nullable":true},"suppressMemberAlerts":{"type":"boolean","description":"Suppress member-level alert notifications; null preserves current value","nullable":true},"confirmationDelaySeconds":{"maximum":600,"minimum":0,"type":"integer","description":"Confirmation delay in seconds; null clears","format":"int32","nullable":true},"recoveryCooldownMinutes":{"maximum":60,"minimum":0,"type":"integer","description":"Recovery cooldown in minutes; null clears","format":"int32","nullable":true}},"description":"Request body for updating a resource group"},"ResourceGroupDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique resource group identifier","format":"uuid"},"organizationId":{"type":"integer","description":"Organization this group belongs to","format":"int32"},"name":{"type":"string","description":"Human-readable group name"},"slug":{"type":"string","description":"URL-safe group identifier"},"description":{"type":"string","description":"Optional group description","nullable":true},"alertPolicyId":{"type":"string","description":"Notification policy applied to this group","format":"uuid","nullable":true},"defaultFrequency":{"type":"integer","description":"Default check frequency in seconds for member monitors","format":"int32","nullable":true},"defaultRegions":{"type":"array","description":"Default regions for member monitors","nullable":true,"items":{"type":"string","description":"Default regions for member monitors","nullable":true}},"defaultRetryStrategy":{"$ref":"#/components/schemas/RetryStrategy"},"defaultAlertChannels":{"type":"array","description":"Default alert channel IDs for member monitors","nullable":true,"items":{"type":"string","description":"Default alert channel IDs for member monitors","format":"uuid","nullable":true}},"defaultEnvironmentId":{"type":"string","description":"Default environment ID for member monitors","format":"uuid","nullable":true},"healthThresholdType":{"type":"string","description":"Health threshold type: COUNT or PERCENTAGE","nullable":true,"enum":["COUNT","PERCENTAGE"]},"healthThresholdValue":{"type":"number","description":"Health threshold value","nullable":true},"suppressMemberAlerts":{"type":"boolean","description":"When true, member-level incidents skip notification dispatch; only group alerts fire"},"confirmationDelaySeconds":{"type":"integer","description":"Seconds to wait after health threshold breach before creating group incident","format":"int32","nullable":true},"recoveryCooldownMinutes":{"type":"integer","description":"Cooldown minutes after group incident resolves before a new one can open","format":"int32","nullable":true},"health":{"$ref":"#/components/schemas/ResourceGroupHealthDto"},"members":{"type":"array","description":"Member list with individual statuses; populated on detail GET only","nullable":true,"items":{"$ref":"#/components/schemas/ResourceGroupMemberDto"}},"createdAt":{"type":"string","description":"Timestamp when the group was created","format":"date-time"},"updatedAt":{"type":"string","description":"Timestamp when the group was last updated","format":"date-time"}},"description":"Resource group with health summary and optional member details"},"ResourceGroupHealthDto":{"type":"object","properties":{"status":{"type":"string","description":"Worst-of health status across all members","enum":["operational","maintenance","degraded","down"]},"totalMembers":{"type":"integer","description":"Total number of members in the group","format":"int32"},"operationalCount":{"type":"integer","description":"Number of members currently in operational status","format":"int32"},"activeIncidents":{"type":"integer","description":"Number of members with an active incident or non-operational status","format":"int32"},"thresholdStatus":{"type":"string","description":"Computed group health status based on threshold: 'healthy', 'degraded', or 'down'. Null when no health threshold is configured.","nullable":true,"enum":["healthy","degraded","down"]},"failingCount":{"type":"integer","description":"Number of failing members at time of last evaluation","format":"int32","nullable":true}},"description":"Aggregated health summary for a resource group"},"ResourceGroupMemberDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique group member record identifier","format":"uuid"},"groupId":{"type":"string","description":"Resource group this member belongs to","format":"uuid"},"memberType":{"type":"string","description":"Type of member: 'monitor' or 'service'"},"monitorId":{"type":"string","description":"Monitor ID; set when memberType is 'monitor'","format":"uuid","nullable":true},"serviceId":{"type":"string","description":"Service ID; set when memberType is 'service'","format":"uuid","nullable":true},"name":{"type":"string","description":"Display name of the referenced monitor or service","nullable":true},"slug":{"type":"string","description":"Slug identifier for the service (services only); used for icons and uptime API calls","nullable":true},"subscriptionId":{"type":"string","description":"Subscription ID for the service (services only); used to link to the dependency detail page","format":"uuid","nullable":true},"status":{"type":"string","description":"Computed health status for this member","enum":["operational","maintenance","degraded","down"]},"effectiveFrequency":{"type":"string","description":"Effective check frequency label showing the group default when the monitor inherits it; null for services or when no group default is configured","nullable":true},"createdAt":{"type":"string","description":"Timestamp when the member was added to the group","format":"date-time"},"uptime24h":{"type":"number","description":"24h uptime percentage; populated when includeMetrics=true","format":"double","nullable":true},"chartData":{"type":"array","description":"Uptime tick values (0-100) for last-24h mini chart; populated when includeMetrics=true","nullable":true,"items":{"type":"number","description":"Uptime tick values (0-100) for last-24h mini chart; populated when includeMetrics=true","format":"double","nullable":true}},"avgLatencyMs":{"type":"number","description":"Average latency in ms (monitors only); populated when includeMetrics=true","format":"double","nullable":true},"p95LatencyMs":{"type":"number","description":"P95 latency in ms (monitors only); populated when includeMetrics=true","format":"double","nullable":true},"lastCheckedAt":{"type":"string","description":"Timestamp of the most recent health check; populated when includeMetrics=true","format":"date-time","nullable":true},"monitorType":{"type":"string","description":"Monitor type (HTTP, DNS, TCP, ICMP, HEARTBEAT, MCP); monitors only","nullable":true},"environmentName":{"type":"string","description":"Environment name; monitors only","nullable":true}},"description":"A single member of a resource group with its computed health status"},"SingleValueResponseResourceGroupDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ResourceGroupDto"}}},"EscalationChain":{"required":["steps"],"type":"object","properties":{"steps":{"minItems":1,"type":"array","description":"Ordered escalation steps, evaluated in sequence","items":{"$ref":"#/components/schemas/EscalationStep"}},"onResolve":{"type":"string","description":"Action when the incident resolves","nullable":true},"onReopen":{"type":"string","description":"Action when a resolved incident reopens","nullable":true}},"description":"Escalation chain defining which channels to notify; null preserves current"},"EscalationStep":{"required":["channelIds"],"type":"object","properties":{"delayMinutes":{"minimum":0,"type":"integer","description":"Minutes to wait before executing this step (0 = immediate)","format":"int32"},"channelIds":{"minItems":1,"type":"array","description":"Alert channel IDs to notify in this step","items":{"type":"string","description":"Alert channel IDs to notify in this step","format":"uuid"}},"requireAck":{"type":"boolean","description":"Whether an acknowledgment is required before escalating","nullable":true},"repeatIntervalSeconds":{"minimum":1,"type":"integer","description":"Repeat notification interval in seconds until acknowledged","format":"int32","nullable":true}},"description":"Ordered escalation steps, evaluated in sequence"},"MatchRule":{"required":["type"],"type":"object","properties":{"type":{"type":"string","description":"Rule type, e.g. severity_gte, monitor_id_in, region_in"},"value":{"type":"string","description":"Comparison value for single-value rules like severity_gte","nullable":true},"monitorIds":{"type":"array","description":"Monitor UUIDs to match for monitor_id_in rules","nullable":true,"items":{"type":"string","description":"Monitor UUIDs to match for monitor_id_in rules","format":"uuid","nullable":true}},"regions":{"type":"array","description":"Region codes to match for region_in rules","nullable":true,"items":{"type":"string","description":"Region codes to match for region_in rules","nullable":true}},"values":{"type":"array","description":"Values list for multi-value rules like monitor_type_in","nullable":true,"items":{"type":"string","description":"Values list for multi-value rules like monitor_type_in","nullable":true}}},"description":"Match rules to evaluate (all must pass; omit or empty for catch-all)"},"UpdateNotificationPolicyRequest":{"type":"object","properties":{"name":{"maxLength":255,"minLength":0,"type":"string","description":"Human-readable name for this policy; null preserves current"},"matchRules":{"type":"array","description":"Match rules to evaluate (all must pass; omit or empty for catch-all)","items":{"$ref":"#/components/schemas/MatchRule"}},"escalation":{"$ref":"#/components/schemas/EscalationChain"},"enabled":{"type":"boolean","description":"Whether this policy is enabled; null preserves current"},"priority":{"type":"integer","description":"Evaluation priority; higher value = evaluated first; null preserves current","format":"int32"}},"description":"Request body for updating a notification policy (null fields are preserved)"},"NotificationPolicyDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique notification policy identifier","format":"uuid"},"organizationId":{"type":"integer","description":"Organization this policy belongs to","format":"int32"},"name":{"type":"string","description":"Human-readable name for this policy"},"matchRules":{"type":"array","description":"Match rules (all must pass; empty = catch-all)","items":{"$ref":"#/components/schemas/MatchRule"}},"escalation":{"$ref":"#/components/schemas/EscalationChain"},"enabled":{"type":"boolean","description":"Whether this policy is active"},"priority":{"type":"integer","description":"Evaluation order; higher value = evaluated first","format":"int32"},"createdAt":{"type":"string","description":"Timestamp when the policy was created","format":"date-time"},"updatedAt":{"type":"string","description":"Timestamp when the policy was last updated","format":"date-time"}},"description":"Org-level notification policy with match rules and escalation chain"},"SingleValueResponseNotificationPolicyDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/NotificationPolicyDto"}}},"ConfirmationPolicy":{"required":["type"],"type":"object","properties":{"type":{"type":"string","description":"How incident confirmation is coordinated across regions","enum":["multi_region"]},"minRegionsFailing":{"type":"integer","description":"Minimum failing regions required to confirm an incident","format":"int32"},"maxWaitSeconds":{"type":"integer","description":"Maximum seconds to wait for enough regions to fail after first trigger","format":"int32"}},"description":"Multi-region confirmation settings"},"IncidentPolicyDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique incident policy identifier","format":"uuid"},"monitorId":{"type":"string","description":"Monitor this policy is attached to","format":"uuid"},"triggerRules":{"type":"array","description":"Array of trigger rules defining when an incident should be raised","items":{"$ref":"#/components/schemas/TriggerRule"}},"confirmation":{"$ref":"#/components/schemas/ConfirmationPolicy"},"recovery":{"$ref":"#/components/schemas/RecoveryPolicy"},"createdAt":{"type":"string","description":"Timestamp when the policy was created","format":"date-time"},"updatedAt":{"type":"string","description":"Timestamp when the policy was last updated","format":"date-time"},"monitorRegionCount":{"type":"integer","description":"Number of regions configured on the monitor (only set in internal API responses)","format":"int32","nullable":true},"checkFrequencySeconds":{"type":"integer","description":"Monitor check frequency in seconds (only set in internal API responses)","format":"int32","nullable":true}},"description":"Incident detection, confirmation, and recovery policy for a monitor"},"RecoveryPolicy":{"type":"object","properties":{"consecutiveSuccesses":{"type":"integer","description":"Consecutive passing checks required to auto-resolve the incident","format":"int32"},"minRegionsPassing":{"type":"integer","description":"Minimum regions that must be passing before recovery can complete","format":"int32"},"cooldownMinutes":{"type":"integer","description":"Minutes after resolve before a new incident may open on the same monitor","format":"int32"}},"description":"Auto-recovery settings"},"TriggerRule":{"required":["scope","severity","type"],"type":"object","properties":{"type":{"type":"string","description":"Condition that opens or escalates an incident from check results","enum":["consecutive_failures","failures_in_window","response_time"]},"count":{"type":"integer","description":"Failure count for consecutive or windowed failure rules","format":"int32","nullable":true},"windowMinutes":{"type":"integer","description":"Window length in minutes for failures-in-window rules","format":"int32","nullable":true},"scope":{"type":"string","description":"Whether the rule applies per region or across regions","nullable":true,"enum":["per_region","any_region"]},"thresholdMs":{"type":"integer","description":"Response time threshold in milliseconds for response-time rules","format":"int32","nullable":true},"severity":{"type":"string","description":"Incident severity when this rule fires","enum":["down","degraded"]},"aggregationType":{"type":"string","description":"How response times are aggregated for response-time rules","nullable":true,"enum":["all_exceed","average","p95","max"]}},"description":"Array of trigger rules defining when an incident should be raised"},"UpdateIncidentPolicyRequest":{"required":["confirmation","recovery","triggerRules"],"type":"object","properties":{"triggerRules":{"minItems":1,"type":"array","description":"Array of trigger rules; at least one required","items":{"$ref":"#/components/schemas/TriggerRule"}},"confirmation":{"$ref":"#/components/schemas/ConfirmationPolicy"},"recovery":{"$ref":"#/components/schemas/RecoveryPolicy"}},"description":"Request body for updating an incident policy"},"SingleValueResponseIncidentPolicyDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IncidentPolicyDto"}}},"ApiKeyAuthConfig":{"required":["headerName"],"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorAuthConfig"},{"type":"object","properties":{"headerName":{"minLength":1,"pattern":"^[A-Za-z0-9\\-_]+$","type":"string","description":"HTTP header name that carries the API key"},"vaultSecretId":{"type":"string","description":"Vault secret ID for the API key value","format":"uuid","nullable":true}}}]},"BasicAuthConfig":{"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorAuthConfig"},{"type":"object","properties":{"vaultSecretId":{"type":"string","description":"Vault secret ID holding Basic auth username and password","format":"uuid","nullable":true}}}]},"BearerAuthConfig":{"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorAuthConfig"},{"type":"object","properties":{"vaultSecretId":{"type":"string","description":"Vault secret ID holding the bearer token value","format":"uuid","nullable":true}}}]},"HeaderAuthConfig":{"required":["headerName"],"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorAuthConfig"},{"type":"object","properties":{"headerName":{"minLength":1,"pattern":"^[A-Za-z0-9\\-_]+$","type":"string","description":"Custom HTTP header name for the secret value"},"vaultSecretId":{"type":"string","description":"Vault secret ID for the header value","format":"uuid","nullable":true}}}]},"MonitorAuthConfig":{"required":["type"],"type":"object","properties":{"type":{"type":"string"}},"description":"New authentication configuration (full replacement)","discriminator":{"propertyName":"type"}},"UpdateMonitorAuthRequest":{"required":["config"],"type":"object","properties":{"config":{"oneOf":[{"$ref":"#/components/schemas/ApiKeyAuthConfig"},{"$ref":"#/components/schemas/BasicAuthConfig"},{"$ref":"#/components/schemas/BearerAuthConfig"},{"$ref":"#/components/schemas/HeaderAuthConfig"}]}}},"MonitorAuthDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"monitorId":{"type":"string","format":"uuid"},"authType":{"type":"string","enum":["bearer","basic","header","api_key"]},"config":{"oneOf":[{"$ref":"#/components/schemas/ApiKeyAuthConfig"},{"$ref":"#/components/schemas/BasicAuthConfig"},{"$ref":"#/components/schemas/BearerAuthConfig"},{"$ref":"#/components/schemas/HeaderAuthConfig"}]}}},"SingleValueResponseMonitorAuthDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MonitorAuthDto"}}},"AssertionConfig":{"required":["type"],"type":"object","properties":{"type":{"type":"string"}},"description":"New assertion configuration (full replacement)","discriminator":{"propertyName":"type"}},"BodyContainsAssertion":{"required":["substring"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"substring":{"minLength":1,"type":"string","description":"Substring that must appear in the response body"}}}]},"DnsExpectedCnameAssertion":{"required":["value"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"value":{"minLength":1,"type":"string","description":"Expected CNAME target the resolution must include"}}}]},"DnsExpectedIpsAssertion":{"required":["ips"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"ips":{"minItems":1,"type":"array","description":"Allowed IP addresses; at least one resolved address must match","items":{"type":"string","description":"Allowed IP addresses; at least one resolved address must match"}}}}]},"DnsMaxAnswersAssertion":{"required":["recordType"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"recordType":{"minLength":1,"type":"string","description":"DNS record type whose answer count is checked"},"max":{"type":"integer","description":"Maximum number of answers allowed for that record type","format":"int32"}}}]},"DnsMinAnswersAssertion":{"required":["recordType"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"recordType":{"minLength":1,"type":"string","description":"DNS record type whose answer count is checked"},"min":{"type":"integer","description":"Minimum number of answers required for that record type","format":"int32"}}}]},"DnsRecordContainsAssertion":{"required":["recordType","substring"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"recordType":{"minLength":1,"type":"string","description":"DNS record type to assert on (A, AAAA, CNAME, MX, TXT)"},"substring":{"minLength":1,"type":"string","description":"Substring that must appear in a matching record value"}}}]},"DnsRecordEqualsAssertion":{"required":["recordType","value"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"recordType":{"minLength":1,"type":"string","description":"DNS record type to assert on (A, AAAA, CNAME, MX, TXT)"},"value":{"minLength":1,"type":"string","description":"Expected DNS record value for an exact match"}}}]},"DnsResolvesAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"}]},"DnsResponseTimeAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxMs":{"type":"integer","description":"Maximum allowed DNS resolution time in milliseconds","format":"int32"}}}]},"DnsResponseTimeWarnAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"warnMs":{"type":"integer","description":"DNS resolution time in milliseconds that triggers a warning only","format":"int32"}}}]},"DnsTtlHighAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxTtl":{"type":"integer","description":"Maximum TTL in seconds before a high-TTL warning is raised","format":"int32"}}}]},"DnsTtlLowAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"minTtl":{"type":"integer","description":"Minimum acceptable TTL in seconds before a warning is raised","format":"int32"}}}]},"DnsTxtContainsAssertion":{"required":["substring"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"substring":{"minLength":1,"type":"string","description":"Substring that must appear in at least one TXT record"}}}]},"HeaderValueAssertion":{"required":["expected","headerName","operator"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"headerName":{"minLength":1,"type":"string","description":"HTTP header name to assert on"},"expected":{"minLength":1,"type":"string","description":"Expected value to compare against"},"operator":{"type":"string","description":"Comparison operator (equals, contains, less_than, greater_than, etc.)","enum":["equals","contains","less_than","greater_than","matches","range"]}}}]},"HeartbeatIntervalDriftAssertion":{"required":["maxDeviationPercent"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxDeviationPercent":{"maximum":100,"minimum":1,"type":"integer","description":"Max percent drift from expected ping interval before warning (non-fatal)","format":"int32"}}}]},"HeartbeatMaxIntervalAssertion":{"required":["maxSeconds"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxSeconds":{"minimum":1,"type":"integer","description":"Maximum allowed gap in seconds between consecutive heartbeat pings","format":"int32"}}}]},"HeartbeatPayloadContainsAssertion":{"required":["path","value"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"path":{"minLength":1,"type":"string","description":"JSONPath expression into the heartbeat ping JSON payload"},"value":{"type":"string","description":"Expected value to compare against at that path"}}}]},"HeartbeatReceivedAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"}]},"IcmpPacketLossAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxPercent":{"maximum":100.0,"exclusiveMaximum":false,"minimum":0.0,"exclusiveMinimum":false,"type":"number","description":"Maximum allowed packet loss percentage before the check fails (0–100)","format":"double"}}}]},"IcmpReachableAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"}]},"IcmpResponseTimeAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxMs":{"type":"integer","description":"Maximum average ICMP round-trip time in milliseconds","format":"int32"}}}]},"IcmpResponseTimeWarnAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"warnMs":{"type":"integer","description":"ICMP round-trip time in milliseconds that triggers a warning only","format":"int32"}}}]},"JsonPathAssertion":{"required":["expected","operator","path"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"path":{"minLength":1,"type":"string","description":"JSONPath expression to extract a value from the response body"},"expected":{"minLength":1,"type":"string","description":"Expected value to compare against"},"operator":{"type":"string","description":"Comparison operator (equals, contains, less_than, greater_than, etc.)","enum":["equals","contains","less_than","greater_than","matches","range"]}}}]},"McpConnectsAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"}]},"McpHasCapabilityAssertion":{"required":["capability"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"capability":{"minLength":1,"type":"string","description":"Capability name the server must advertise, e.g. tools or resources"}}}]},"McpMinToolsAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"min":{"type":"integer","description":"Minimum number of tools the server must expose","format":"int32"}}}]},"McpProtocolVersionAssertion":{"required":["version"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"version":{"minLength":1,"type":"string","description":"Expected MCP protocol version string from the server handshake"}}}]},"McpResponseTimeAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxMs":{"type":"integer","description":"Maximum allowed MCP check duration in milliseconds","format":"int32"}}}]},"McpResponseTimeWarnAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"warnMs":{"type":"integer","description":"MCP check duration in milliseconds that triggers a warning only","format":"int32"}}}]},"McpToolAvailableAssertion":{"required":["toolName"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"toolName":{"minLength":1,"type":"string","description":"MCP tool name that must appear in the server's tool list"}}}]},"McpToolCountChangedAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"expectedCount":{"type":"integer","description":"Expected tool count; warns when the live count differs","format":"int32"}}}]},"RedirectCountAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxCount":{"type":"integer","description":"Maximum number of HTTP redirects allowed before the check fails","format":"int32"}}}]},"RedirectTargetAssertion":{"required":["expected","operator"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"expected":{"minLength":1,"type":"string","description":"Expected final URL after following redirects"},"operator":{"type":"string","description":"Comparison operator (equals, contains, less_than, greater_than, etc.)","enum":["equals","contains","less_than","greater_than","matches","range"]}}}]},"RegexBodyAssertion":{"required":["pattern"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"pattern":{"minLength":1,"type":"string","description":"Regular expression the response body must match"}}}]},"ResponseSizeAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxBytes":{"type":"integer","description":"Maximum response body size in bytes before the check fails","format":"int32"}}}]},"ResponseTimeAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"thresholdMs":{"type":"integer","description":"Maximum allowed response time in milliseconds before the check fails","format":"int32"}}}]},"ResponseTimeWarnAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"warnMs":{"type":"integer","description":"HTTP response time in milliseconds that triggers a warning only","format":"int32"}}}]},"SslExpiryAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"minDaysRemaining":{"type":"integer","description":"Minimum days before TLS certificate expiry; fails or warns below this threshold","format":"int32"}}}]},"StatusCodeAssertion":{"required":["expected","operator"],"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"expected":{"minLength":1,"type":"string","description":"Expected status code, range pattern, or wildcard such as 2xx"},"operator":{"type":"string","description":"Comparison operator (equals, contains, less_than, greater_than, etc.)","enum":["equals","contains","less_than","greater_than","matches","range"]}}}]},"TcpConnectsAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"}]},"TcpResponseTimeAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"maxMs":{"type":"integer","description":"Maximum TCP connect time in milliseconds before the check fails","format":"int32"}}}]},"TcpResponseTimeWarnAssertion":{"type":"object","allOf":[{"$ref":"#/components/schemas/AssertionConfig"},{"type":"object","properties":{"warnMs":{"type":"integer","description":"TCP connect time in milliseconds that triggers a warning only","format":"int32"}}}]},"UpdateAssertionRequest":{"required":["config"],"type":"object","properties":{"config":{"oneOf":[{"$ref":"#/components/schemas/BodyContainsAssertion"},{"$ref":"#/components/schemas/DnsExpectedCnameAssertion"},{"$ref":"#/components/schemas/DnsExpectedIpsAssertion"},{"$ref":"#/components/schemas/DnsMaxAnswersAssertion"},{"$ref":"#/components/schemas/DnsMinAnswersAssertion"},{"$ref":"#/components/schemas/DnsRecordContainsAssertion"},{"$ref":"#/components/schemas/DnsRecordEqualsAssertion"},{"$ref":"#/components/schemas/DnsResolvesAssertion"},{"$ref":"#/components/schemas/DnsResponseTimeAssertion"},{"$ref":"#/components/schemas/DnsResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/DnsTtlHighAssertion"},{"$ref":"#/components/schemas/DnsTtlLowAssertion"},{"$ref":"#/components/schemas/DnsTxtContainsAssertion"},{"$ref":"#/components/schemas/HeaderValueAssertion"},{"$ref":"#/components/schemas/HeartbeatIntervalDriftAssertion"},{"$ref":"#/components/schemas/HeartbeatMaxIntervalAssertion"},{"$ref":"#/components/schemas/HeartbeatPayloadContainsAssertion"},{"$ref":"#/components/schemas/HeartbeatReceivedAssertion"},{"$ref":"#/components/schemas/IcmpPacketLossAssertion"},{"$ref":"#/components/schemas/IcmpReachableAssertion"},{"$ref":"#/components/schemas/IcmpResponseTimeAssertion"},{"$ref":"#/components/schemas/IcmpResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/JsonPathAssertion"},{"$ref":"#/components/schemas/McpConnectsAssertion"},{"$ref":"#/components/schemas/McpHasCapabilityAssertion"},{"$ref":"#/components/schemas/McpMinToolsAssertion"},{"$ref":"#/components/schemas/McpProtocolVersionAssertion"},{"$ref":"#/components/schemas/McpResponseTimeAssertion"},{"$ref":"#/components/schemas/McpResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/McpToolAvailableAssertion"},{"$ref":"#/components/schemas/McpToolCountChangedAssertion"},{"$ref":"#/components/schemas/RedirectCountAssertion"},{"$ref":"#/components/schemas/RedirectTargetAssertion"},{"$ref":"#/components/schemas/RegexBodyAssertion"},{"$ref":"#/components/schemas/ResponseSizeAssertion"},{"$ref":"#/components/schemas/ResponseTimeAssertion"},{"$ref":"#/components/schemas/ResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/SslExpiryAssertion"},{"$ref":"#/components/schemas/StatusCodeAssertion"},{"$ref":"#/components/schemas/TcpConnectsAssertion"},{"$ref":"#/components/schemas/TcpResponseTimeAssertion"},{"$ref":"#/components/schemas/TcpResponseTimeWarnAssertion"}]},"severity":{"type":"string","description":"New outcome severity: FAIL or WARN","enum":["fail","warn"]}}},"MonitorAssertionDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"monitorId":{"type":"string","format":"uuid"},"assertionType":{"type":"string","enum":["status_code","response_time","body_contains","json_path","header","regex","dns_resolves","dns_response_time","dns_expected_ips","dns_expected_cname","dns_record_contains","dns_record_equals","dns_txt_contains","dns_min_answers","dns_max_answers","dns_response_time_warn","dns_ttl_low","dns_ttl_high","mcp_connects","mcp_response_time","mcp_has_capability","mcp_tool_available","mcp_min_tools","mcp_protocol_version","mcp_response_time_warn","mcp_tool_count_changed","ssl_expiry","response_size","redirect_count","redirect_target","response_time_warn","tcp_connects","tcp_response_time","tcp_response_time_warn","icmp_reachable","icmp_response_time","icmp_response_time_warn","icmp_packet_loss","heartbeat_received","heartbeat_max_interval","heartbeat_interval_drift","heartbeat_payload_contains"]},"config":{"oneOf":[{"$ref":"#/components/schemas/BodyContainsAssertion"},{"$ref":"#/components/schemas/DnsExpectedCnameAssertion"},{"$ref":"#/components/schemas/DnsExpectedIpsAssertion"},{"$ref":"#/components/schemas/DnsMaxAnswersAssertion"},{"$ref":"#/components/schemas/DnsMinAnswersAssertion"},{"$ref":"#/components/schemas/DnsRecordContainsAssertion"},{"$ref":"#/components/schemas/DnsRecordEqualsAssertion"},{"$ref":"#/components/schemas/DnsResolvesAssertion"},{"$ref":"#/components/schemas/DnsResponseTimeAssertion"},{"$ref":"#/components/schemas/DnsResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/DnsTtlHighAssertion"},{"$ref":"#/components/schemas/DnsTtlLowAssertion"},{"$ref":"#/components/schemas/DnsTxtContainsAssertion"},{"$ref":"#/components/schemas/HeaderValueAssertion"},{"$ref":"#/components/schemas/HeartbeatIntervalDriftAssertion"},{"$ref":"#/components/schemas/HeartbeatMaxIntervalAssertion"},{"$ref":"#/components/schemas/HeartbeatPayloadContainsAssertion"},{"$ref":"#/components/schemas/HeartbeatReceivedAssertion"},{"$ref":"#/components/schemas/IcmpPacketLossAssertion"},{"$ref":"#/components/schemas/IcmpReachableAssertion"},{"$ref":"#/components/schemas/IcmpResponseTimeAssertion"},{"$ref":"#/components/schemas/IcmpResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/JsonPathAssertion"},{"$ref":"#/components/schemas/McpConnectsAssertion"},{"$ref":"#/components/schemas/McpHasCapabilityAssertion"},{"$ref":"#/components/schemas/McpMinToolsAssertion"},{"$ref":"#/components/schemas/McpProtocolVersionAssertion"},{"$ref":"#/components/schemas/McpResponseTimeAssertion"},{"$ref":"#/components/schemas/McpResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/McpToolAvailableAssertion"},{"$ref":"#/components/schemas/McpToolCountChangedAssertion"},{"$ref":"#/components/schemas/RedirectCountAssertion"},{"$ref":"#/components/schemas/RedirectTargetAssertion"},{"$ref":"#/components/schemas/RegexBodyAssertion"},{"$ref":"#/components/schemas/ResponseSizeAssertion"},{"$ref":"#/components/schemas/ResponseTimeAssertion"},{"$ref":"#/components/schemas/ResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/SslExpiryAssertion"},{"$ref":"#/components/schemas/StatusCodeAssertion"},{"$ref":"#/components/schemas/TcpConnectsAssertion"},{"$ref":"#/components/schemas/TcpResponseTimeAssertion"},{"$ref":"#/components/schemas/TcpResponseTimeWarnAssertion"}]},"severity":{"type":"string","enum":["fail","warn"]}}},"SingleValueResponseMonitorAssertionDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MonitorAssertionDto"}}},"SetAlertChannelsRequest":{"required":["channelIds"],"type":"object","properties":{"channelIds":{"type":"array","description":"IDs of alert channels to link (replaces current list)","items":{"type":"string","description":"IDs of alert channels to link (replaces current list)","format":"uuid"}}},"description":"Replace the alert channels linked to a monitor"},"SingleValueResponseListUUID":{"type":"object","properties":{"data":{"type":"array","nullable":true,"items":{"type":"string","format":"uuid","nullable":true}}}},"AddMonitorTagsRequest":{"type":"object","properties":{"tagIds":{"type":"array","description":"IDs of existing org tags to attach","nullable":true,"items":{"type":"string","description":"IDs of existing org tags to attach","format":"uuid","nullable":true}},"newTags":{"type":"array","description":"New tags to create (if not already present) and attach","nullable":true,"items":{"$ref":"#/components/schemas/NewTagRequest"}}},"description":"Request body for adding tags to a monitor. Provide existing tag IDs, inline new tags, or both."},"CreateAssertionRequest":{"required":["config"],"type":"object","properties":{"config":{"oneOf":[{"$ref":"#/components/schemas/BodyContainsAssertion"},{"$ref":"#/components/schemas/DnsExpectedCnameAssertion"},{"$ref":"#/components/schemas/DnsExpectedIpsAssertion"},{"$ref":"#/components/schemas/DnsMaxAnswersAssertion"},{"$ref":"#/components/schemas/DnsMinAnswersAssertion"},{"$ref":"#/components/schemas/DnsRecordContainsAssertion"},{"$ref":"#/components/schemas/DnsRecordEqualsAssertion"},{"$ref":"#/components/schemas/DnsResolvesAssertion"},{"$ref":"#/components/schemas/DnsResponseTimeAssertion"},{"$ref":"#/components/schemas/DnsResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/DnsTtlHighAssertion"},{"$ref":"#/components/schemas/DnsTtlLowAssertion"},{"$ref":"#/components/schemas/DnsTxtContainsAssertion"},{"$ref":"#/components/schemas/HeaderValueAssertion"},{"$ref":"#/components/schemas/HeartbeatIntervalDriftAssertion"},{"$ref":"#/components/schemas/HeartbeatMaxIntervalAssertion"},{"$ref":"#/components/schemas/HeartbeatPayloadContainsAssertion"},{"$ref":"#/components/schemas/HeartbeatReceivedAssertion"},{"$ref":"#/components/schemas/IcmpPacketLossAssertion"},{"$ref":"#/components/schemas/IcmpReachableAssertion"},{"$ref":"#/components/schemas/IcmpResponseTimeAssertion"},{"$ref":"#/components/schemas/IcmpResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/JsonPathAssertion"},{"$ref":"#/components/schemas/McpConnectsAssertion"},{"$ref":"#/components/schemas/McpHasCapabilityAssertion"},{"$ref":"#/components/schemas/McpMinToolsAssertion"},{"$ref":"#/components/schemas/McpProtocolVersionAssertion"},{"$ref":"#/components/schemas/McpResponseTimeAssertion"},{"$ref":"#/components/schemas/McpResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/McpToolAvailableAssertion"},{"$ref":"#/components/schemas/McpToolCountChangedAssertion"},{"$ref":"#/components/schemas/RedirectCountAssertion"},{"$ref":"#/components/schemas/RedirectTargetAssertion"},{"$ref":"#/components/schemas/RegexBodyAssertion"},{"$ref":"#/components/schemas/ResponseSizeAssertion"},{"$ref":"#/components/schemas/ResponseTimeAssertion"},{"$ref":"#/components/schemas/ResponseTimeWarnAssertion"},{"$ref":"#/components/schemas/SslExpiryAssertion"},{"$ref":"#/components/schemas/StatusCodeAssertion"},{"$ref":"#/components/schemas/TcpConnectsAssertion"},{"$ref":"#/components/schemas/TcpResponseTimeAssertion"},{"$ref":"#/components/schemas/TcpResponseTimeWarnAssertion"}]},"severity":{"type":"string","description":"Outcome severity: FAIL (fails the check) or WARN (warns without failing)","enum":["fail","warn"]}},"description":"Replace all assertions; null preserves current"},"DnsMonitorConfig":{"required":["hostname"],"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorConfig"},{"type":"object","properties":{"hostname":{"minLength":1,"type":"string","description":"Domain name to resolve"},"recordTypes":{"type":"array","description":"DNS record types to query: A, AAAA, CNAME, MX, NS, TXT, SRV, SOA, CAA, PTR","nullable":true,"items":{"type":"string","description":"DNS record types to query: A, AAAA, CNAME, MX, NS, TXT, SRV, SOA, CAA, PTR","nullable":true,"enum":["A","AAAA","CNAME","MX","NS","TXT","SRV","SOA","CAA","PTR"]}},"nameservers":{"type":"array","description":"Custom nameservers to query (uses system defaults if omitted)","nullable":true,"items":{"type":"string","description":"Custom nameservers to query (uses system defaults if omitted)","nullable":true}},"timeoutMs":{"type":"integer","description":"Per-query timeout in milliseconds","format":"int32","nullable":true},"totalTimeoutMs":{"type":"integer","description":"Total timeout for all queries in milliseconds","format":"int32","nullable":true}}}]},"HeartbeatMonitorConfig":{"required":["expectedInterval","gracePeriod"],"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorConfig"},{"type":"object","properties":{"expectedInterval":{"maximum":86400,"minimum":1,"type":"integer","description":"Expected heartbeat interval in seconds","format":"int32"},"gracePeriod":{"minimum":1,"type":"integer","description":"Grace period in seconds before marking as down","format":"int32"}}}]},"HttpMonitorConfig":{"required":["method","url"],"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorConfig"},{"type":"object","properties":{"url":{"minLength":1,"type":"string","description":"Target URL to send requests to"},"method":{"type":"string","description":"HTTP method: GET, POST, PUT, PATCH, DELETE, or HEAD","enum":["GET","POST","PUT","PATCH","DELETE","HEAD"]},"customHeaders":{"type":"object","additionalProperties":{"type":"string","description":"Additional HTTP headers to include in requests","nullable":true},"description":"Additional HTTP headers to include in requests","nullable":true},"requestBody":{"type":"string","description":"Request body content for POST/PUT/PATCH methods","nullable":true},"contentType":{"type":"string","description":"Content-Type header value for the request body","nullable":true},"verifyTls":{"type":"boolean","description":"Whether to verify TLS certificates (default: true)","nullable":true}}}]},"IcmpMonitorConfig":{"required":["host"],"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorConfig"},{"type":"object","properties":{"host":{"minLength":1,"type":"string","description":"Target hostname or IP address to ping"},"packetCount":{"maximum":20,"minimum":1,"type":"integer","description":"Number of ICMP packets to send","format":"int32","nullable":true},"timeoutMs":{"type":"integer","description":"Ping timeout in milliseconds","format":"int32","nullable":true}}}]},"McpServerMonitorConfig":{"required":["command"],"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorConfig"},{"type":"object","properties":{"command":{"minLength":1,"type":"string","description":"Command to execute to start the MCP server"},"args":{"type":"array","description":"Command-line arguments for the MCP server process","nullable":true,"items":{"type":"string","description":"Command-line arguments for the MCP server process","nullable":true}},"env":{"type":"object","additionalProperties":{"type":"string","description":"Environment variables to pass to the MCP server process","nullable":true},"description":"Environment variables to pass to the MCP server process","nullable":true}}}]},"MonitorConfig":{"type":"object","description":"Updated protocol-specific configuration; null preserves current"},"NewTagRequest":{"required":["name"],"type":"object","properties":{"name":{"maxLength":100,"minLength":0,"type":"string","description":"Tag name"},"color":{"pattern":"^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$","type":"string","description":"Hex color code (defaults to #6B7280 if omitted)","nullable":true}},"description":"Inline tag creation — creates the tag if it does not already exist"},"TcpMonitorConfig":{"required":["host"],"type":"object","allOf":[{"$ref":"#/components/schemas/MonitorConfig"},{"type":"object","properties":{"host":{"minLength":1,"type":"string","description":"Target hostname or IP address"},"port":{"maximum":65535,"minimum":1,"type":"integer","description":"TCP port to connect to","format":"int32"},"timeoutMs":{"type":"integer","description":"Connection timeout in milliseconds","format":"int32","nullable":true}}}]},"UpdateMonitorRequest":{"type":"object","properties":{"name":{"maxLength":255,"minLength":0,"type":"string","description":"New monitor name; null preserves current","nullable":true},"config":{"oneOf":[{"$ref":"#/components/schemas/DnsMonitorConfig"},{"$ref":"#/components/schemas/HeartbeatMonitorConfig"},{"$ref":"#/components/schemas/HttpMonitorConfig"},{"$ref":"#/components/schemas/IcmpMonitorConfig"},{"$ref":"#/components/schemas/McpServerMonitorConfig"},{"$ref":"#/components/schemas/TcpMonitorConfig"}]},"frequencySeconds":{"type":"integer","description":"New check frequency in seconds (30–86400); null preserves current","format":"int32","nullable":true},"enabled":{"type":"boolean","description":"Enable or disable the monitor; null preserves current","nullable":true},"regions":{"type":"array","description":"New probe regions; null preserves current","nullable":true,"items":{"type":"string","description":"New probe regions; null preserves current","nullable":true}},"managedBy":{"type":"string","description":"New management source; null preserves current","nullable":true,"enum":["DASHBOARD","CLI"]},"environmentId":{"type":"string","description":"New environment ID; null preserves current (use clearEnvironmentId to unset)","format":"uuid","nullable":true},"clearEnvironmentId":{"type":"boolean","description":"Set to true to remove the environment association","nullable":true},"assertions":{"type":"array","description":"Replace all assertions; null preserves current","nullable":true,"items":{"$ref":"#/components/schemas/CreateAssertionRequest"}},"auth":{"oneOf":[{"$ref":"#/components/schemas/ApiKeyAuthConfig"},{"$ref":"#/components/schemas/BasicAuthConfig"},{"$ref":"#/components/schemas/BearerAuthConfig"},{"$ref":"#/components/schemas/HeaderAuthConfig"}]},"clearAuth":{"type":"boolean","description":"Set to true to remove authentication","nullable":true},"incidentPolicy":{"$ref":"#/components/schemas/UpdateIncidentPolicyRequest"},"alertChannelIds":{"type":"array","description":"Replace alert channel list; null preserves current","nullable":true,"items":{"type":"string","description":"Replace alert channel list; null preserves current","format":"uuid","nullable":true}},"tags":{"$ref":"#/components/schemas/AddMonitorTagsRequest"}}},"MonitorDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique monitor identifier","format":"uuid"},"organizationId":{"type":"integer","description":"Organization this monitor belongs to","format":"int32"},"name":{"type":"string","description":"Human-readable name for this monitor"},"type":{"type":"string","enum":["HTTP","DNS","MCP_SERVER","TCP","ICMP","HEARTBEAT"]},"config":{"oneOf":[{"$ref":"#/components/schemas/DnsMonitorConfig"},{"$ref":"#/components/schemas/HeartbeatMonitorConfig"},{"$ref":"#/components/schemas/HttpMonitorConfig"},{"$ref":"#/components/schemas/IcmpMonitorConfig"},{"$ref":"#/components/schemas/McpServerMonitorConfig"},{"$ref":"#/components/schemas/TcpMonitorConfig"}]},"frequencySeconds":{"type":"integer","description":"Check frequency in seconds (30–86400)","format":"int32"},"enabled":{"type":"boolean","description":"Whether the monitor is active"},"regions":{"type":"array","description":"Probe regions where checks are executed","items":{"type":"string","description":"Probe regions where checks are executed"}},"managedBy":{"type":"string","description":"Management source: DASHBOARD or CLI","enum":["DASHBOARD","CLI"]},"createdAt":{"type":"string","description":"Timestamp when the monitor was created","format":"date-time"},"updatedAt":{"type":"string","description":"Timestamp when the monitor was last updated","format":"date-time"},"assertions":{"type":"array","description":"Assertions evaluated against each check result; null on list responses","nullable":true,"items":{"$ref":"#/components/schemas/MonitorAssertionDto"}},"tags":{"type":"array","description":"Tags applied to this monitor","nullable":true,"items":{"$ref":"#/components/schemas/TagDto"}},"pingUrl":{"type":"string","description":"Heartbeat ping URL; populated for HEARTBEAT monitors only","nullable":true},"environment":{"$ref":"#/components/schemas/Summary"},"auth":{"oneOf":[{"$ref":"#/components/schemas/ApiKeyAuthConfig"},{"$ref":"#/components/schemas/BasicAuthConfig"},{"$ref":"#/components/schemas/BearerAuthConfig"},{"$ref":"#/components/schemas/HeaderAuthConfig"}]},"incidentPolicy":{"$ref":"#/components/schemas/IncidentPolicyDto"},"alertChannelIds":{"type":"array","description":"Alert channel IDs linked to this monitor; populated on single-monitor responses","nullable":true,"items":{"type":"string","description":"Alert channel IDs linked to this monitor; populated on single-monitor responses","format":"uuid","nullable":true}}},"description":"Full monitor representation"},"SingleValueResponseMonitorDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MonitorDto"}}},"Summary":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"slug":{"type":"string"}},"description":"Environment associated with this monitor; null when unassigned"},"ChangeStatusRequest":{"required":["status"],"type":"object","properties":{"status":{"type":"string","description":"New membership status (ACTIVE or SUSPENDED)","enum":["INVITED","ACTIVE","SUSPENDED","LEFT","REMOVED","DECLINED"]}},"description":"Update an organization member's status"},"UpdateMaintenanceWindowRequest":{"required":["endsAt","startsAt"],"type":"object","properties":{"monitorId":{"type":"string","description":"Monitor to attach this maintenance window to; null preserves current","format":"uuid"},"startsAt":{"type":"string","description":"Updated start time (ISO 8601)","format":"date-time"},"endsAt":{"type":"string","description":"Updated end time (ISO 8601)","format":"date-time"},"repeatRule":{"maxLength":100,"minLength":0,"type":"string","description":"Updated iCal RRULE; null clears the repeat rule"},"reason":{"type":"string","description":"Updated reason; null clears the existing reason"},"suppressAlerts":{"type":"boolean","description":"Whether to suppress alerts; null preserves current"}}},"MaintenanceWindowDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique maintenance window identifier","format":"uuid"},"monitorId":{"type":"string","description":"Monitor this window applies to; null for org-wide windows","format":"uuid","nullable":true},"organizationId":{"type":"integer","description":"Organization this maintenance window belongs to","format":"int32"},"startsAt":{"type":"string","description":"Scheduled start of the maintenance window","format":"date-time"},"endsAt":{"type":"string","description":"Scheduled end of the maintenance window","format":"date-time"},"repeatRule":{"type":"string","description":"iCal RRULE for recurring windows; null for one-time","nullable":true},"reason":{"type":"string","description":"Human-readable reason for the maintenance","nullable":true},"suppressAlerts":{"type":"boolean","description":"Whether alerts are suppressed during this window"},"createdAt":{"type":"string","description":"Timestamp when the window was created","format":"date-time"}},"description":"Scheduled maintenance window for a monitor"},"SingleValueResponseMaintenanceWindowDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MaintenanceWindowDto"}}},"UpdateEnvironmentRequest":{"type":"object","properties":{"name":{"maxLength":100,"minLength":0,"type":"string","description":"New environment name; null preserves current","nullable":true},"variables":{"type":"object","additionalProperties":{"type":"string","description":"Replace all variables; null preserves current","nullable":true},"description":"Replace all variables; null preserves current","nullable":true},"isDefault":{"type":"boolean","description":"Whether this is the default environment; null preserves current","nullable":true}}},"EnvironmentDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique environment identifier","format":"uuid"},"orgId":{"type":"integer","description":"Organization this environment belongs to","format":"int32"},"name":{"type":"string","description":"Human-readable environment name"},"slug":{"type":"string","description":"URL-safe identifier"},"variables":{"type":"object","additionalProperties":{"type":"string","description":"Key-value variable pairs available for interpolation"},"description":"Key-value variable pairs available for interpolation"},"createdAt":{"type":"string","description":"Timestamp when the environment was created","format":"date-time"},"updatedAt":{"type":"string","description":"Timestamp when the environment was last updated","format":"date-time"},"monitorCount":{"type":"integer","description":"Number of monitors using this environment","format":"int32"},"isDefault":{"type":"boolean","description":"Whether this is the default environment for new monitors"}},"description":"Environment with variable substitutions for monitor configs"},"SingleValueResponseEnvironmentDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EnvironmentDto"}}},"ChannelConfig":{"required":["channelType"],"type":"object","properties":{"channelType":{"type":"string"}},"description":"New channel configuration (full replacement, not partial update)","discriminator":{"propertyName":"channelType"}},"DiscordChannelConfig":{"required":["webhookUrl"],"type":"object","allOf":[{"$ref":"#/components/schemas/ChannelConfig"},{"type":"object","properties":{"webhookUrl":{"minLength":1,"type":"string","description":"Discord webhook URL"},"mentionRoleId":{"type":"string","description":"Optional Discord role ID to mention in notifications","nullable":true}}}]},"EmailChannelConfig":{"required":["recipients"],"type":"object","allOf":[{"$ref":"#/components/schemas/ChannelConfig"},{"type":"object","properties":{"recipients":{"minItems":1,"type":"array","description":"Email addresses to send notifications to","items":{"type":"string","description":"Email addresses to send notifications to","format":"email"}}}}]},"OpsGenieChannelConfig":{"required":["apiKey"],"type":"object","allOf":[{"$ref":"#/components/schemas/ChannelConfig"},{"type":"object","properties":{"apiKey":{"minLength":1,"type":"string","description":"OpsGenie API key for alert creation"},"region":{"type":"string","description":"OpsGenie API region: us or eu","nullable":true}}}]},"PagerDutyChannelConfig":{"required":["routingKey"],"type":"object","allOf":[{"$ref":"#/components/schemas/ChannelConfig"},{"type":"object","properties":{"routingKey":{"minLength":1,"type":"string","description":"PagerDuty Events API v2 routing (integration) key"},"severityOverride":{"type":"string","description":"Override PagerDuty severity mapping","nullable":true}}}]},"SlackChannelConfig":{"required":["webhookUrl"],"type":"object","allOf":[{"$ref":"#/components/schemas/ChannelConfig"},{"type":"object","properties":{"webhookUrl":{"minLength":1,"type":"string","description":"Slack incoming webhook URL"},"mentionText":{"type":"string","description":"Optional mention text included in notifications, e.g. @channel","nullable":true}}}]},"TeamsChannelConfig":{"required":["webhookUrl"],"type":"object","allOf":[{"$ref":"#/components/schemas/ChannelConfig"},{"type":"object","properties":{"webhookUrl":{"minLength":1,"type":"string","description":"Microsoft Teams incoming webhook URL"}}}]},"UpdateAlertChannelRequest":{"required":["config","name"],"type":"object","properties":{"name":{"maxLength":255,"minLength":0,"type":"string","description":"New channel name (full replacement, not partial update)"},"config":{"oneOf":[{"$ref":"#/components/schemas/DiscordChannelConfig"},{"$ref":"#/components/schemas/EmailChannelConfig"},{"$ref":"#/components/schemas/OpsGenieChannelConfig"},{"$ref":"#/components/schemas/PagerDutyChannelConfig"},{"$ref":"#/components/schemas/SlackChannelConfig"},{"$ref":"#/components/schemas/TeamsChannelConfig"},{"$ref":"#/components/schemas/WebhookChannelConfig"}]}}},"WebhookChannelConfig":{"required":["url"],"type":"object","allOf":[{"$ref":"#/components/schemas/ChannelConfig"},{"type":"object","properties":{"url":{"minLength":1,"type":"string","description":"Webhook endpoint URL that receives alert payloads"},"signingSecret":{"type":"string","description":"Optional HMAC signing secret for payload verification","nullable":true},"customHeaders":{"type":"object","additionalProperties":{"type":"string","description":"Additional HTTP headers to include in webhook requests","nullable":true},"description":"Additional HTTP headers to include in webhook requests","nullable":true}}}]},"AlertChannelDto":{"required":["channelType","createdAt","id","name","updatedAt"],"type":"object","properties":{"id":{"type":"string","description":"Unique alert channel identifier","format":"uuid"},"name":{"type":"string","description":"Human-readable channel name"},"channelType":{"type":"string","description":"Channel integration type (e.g. SLACK, PAGERDUTY, EMAIL)","enum":["email","webhook","slack","pagerduty","opsgenie","teams","discord"]},"displayConfig":{"type":"object","additionalProperties":{"type":"object","description":"Non-sensitive display metadata; null for older channels","nullable":true},"description":"Non-sensitive display metadata; null for older channels","nullable":true},"createdAt":{"type":"string","description":"Timestamp when the channel was created","format":"date-time"},"updatedAt":{"type":"string","description":"Timestamp when the channel was last updated","format":"date-time"},"configHash":{"type":"string","description":"SHA-256 hash of the channel config; use for change detection","nullable":true},"lastDeliveryAt":{"type":"string","description":"Timestamp of the most recent delivery attempt","format":"date-time","nullable":true},"lastDeliveryStatus":{"type":"string","description":"Outcome of the most recent delivery (SUCCESS, FAILED, etc.)","nullable":true}},"description":"Alert channel with non-sensitive configuration metadata"},"SingleValueResponseAlertChannelDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AlertChannelDto"}}},"WorkspaceCreateParams":{"required":["name"],"type":"object","properties":{"organizationId":{"type":"integer","format":"int32"},"name":{"minLength":1,"type":"string"}}},"ServiceIncidentRequest":{"required":["action","externalRef","serviceId","title"],"type":"object","properties":{"serviceId":{"type":"string","format":"uuid"},"externalRef":{"minLength":1,"type":"string"},"severity":{"type":"string","nullable":true},"title":{"minLength":1,"type":"string"},"shortlink":{"type":"string","nullable":true},"affectedComponents":{"type":"array","nullable":true,"items":{"type":"string","nullable":true}},"serviceIncidentId":{"type":"string","format":"uuid","nullable":true},"action":{"minLength":1,"type":"string"},"statusText":{"type":"string","nullable":true}}},"IncidentDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique incident identifier","format":"uuid"},"monitorId":{"type":"string","description":"Monitor that triggered the incident; null for service or manual incidents","format":"uuid","nullable":true},"organizationId":{"type":"integer","description":"Organization this incident belongs to","format":"int32"},"source":{"type":"string","description":"Incident origin: MONITOR, SERVICE, or MANUAL","enum":["AUTOMATIC","MANUAL","MONITORS","STATUS_DATA","RESOURCE_GROUP"]},"status":{"type":"string","description":"Current lifecycle status (OPEN, RESOLVED, etc.)","enum":["WATCHING","TRIGGERED","CONFIRMED","RESOLVED"]},"severity":{"type":"string","description":"Severity level: DOWN, DEGRADED, or MAINTENANCE","enum":["DOWN","DEGRADED","MAINTENANCE"]},"title":{"type":"string","description":"Short summary of the incident; null for auto-generated incidents","nullable":true},"triggeredByRule":{"type":"string","description":"Human-readable description of the trigger rule that fired","nullable":true},"affectedRegions":{"type":"array","description":"Probe regions that observed the failure","items":{"type":"string","description":"Probe regions that observed the failure"}},"reopenCount":{"type":"integer","description":"Number of times this incident has been reopened","format":"int32"},"createdByUserId":{"type":"integer","description":"User who created the incident (manual incidents only)","format":"int32","nullable":true},"statusPageVisible":{"type":"boolean","description":"Whether this incident is visible on the status page"},"serviceIncidentId":{"type":"string","description":"Linked vendor service incident ID; null for monitor incidents","format":"uuid","nullable":true},"serviceId":{"type":"string","description":"Linked service catalog ID; null for monitor incidents","format":"uuid","nullable":true},"externalRef":{"type":"string","description":"External reference ID (e.g. PagerDuty incident ID)","nullable":true},"affectedComponents":{"type":"array","description":"Service components affected by this incident","nullable":true,"items":{"type":"string","description":"Service components affected by this incident","nullable":true}},"shortlink":{"type":"string","description":"Short URL linking to the incident details","nullable":true},"resolutionReason":{"type":"string","description":"How the incident was resolved (AUTO_RECOVERED, MANUAL, etc.)","nullable":true,"enum":["MANUAL","AUTO_RECOVERED","AUTO_RESOLVED"]},"startedAt":{"type":"string","description":"Timestamp when the incident was detected or created","format":"date-time","nullable":true},"confirmedAt":{"type":"string","description":"Timestamp when the incident was confirmed (multi-region confirmation)","format":"date-time","nullable":true},"resolvedAt":{"type":"string","description":"Timestamp when the incident was resolved","format":"date-time","nullable":true},"cooldownUntil":{"type":"string","description":"Cooldown window end; new incidents suppressed until this time","format":"date-time","nullable":true},"createdAt":{"type":"string","description":"Timestamp when the incident record was created","format":"date-time"},"updatedAt":{"type":"string","description":"Timestamp when the incident was last updated","format":"date-time"},"monitorName":{"type":"string","description":"Name of the associated monitor; populated on list responses","nullable":true},"serviceName":{"type":"string","description":"Name of the associated service; populated on list responses","nullable":true},"serviceSlug":{"type":"string","description":"Slug of the associated service; populated on list responses","nullable":true},"monitorType":{"type":"string","description":"Type of the associated monitor; populated on list responses","nullable":true},"resourceGroupId":{"type":"string","description":"Resource group that owns this incident; null when not group-managed","format":"uuid","nullable":true},"resourceGroupName":{"type":"string","description":"Name of the resource group; populated on list responses","nullable":true}},"description":"Incident triggered by a monitor check failure or manual creation"},"TableValueResultIncidentDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/IncidentDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"SingleValueResponseInteger":{"type":"object","properties":{"data":{"type":"integer","format":"int32","nullable":true}}},"CreateAutoIncidentRequest":{"required":["monitorId"],"type":"object","properties":{"monitorId":{"type":"string","format":"uuid"},"severity":{"type":"string","nullable":true},"triggeredByRule":{"type":"string","nullable":true},"affectedRegions":{"type":"array","nullable":true,"items":{"type":"string","nullable":true}},"startedAt":{"type":"string","format":"date-time","nullable":true}}},"SingleValueResponseIncidentDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IncidentDto"}}},"ReopenAutoIncidentRequest":{"type":"object","properties":{"affectedRegions":{"type":"array","items":{"type":"string"}},"severity":{"type":"string","nullable":true}}},"AdapterHealthReportRequest":{"required":["serviceId","success"],"type":"object","properties":{"serviceId":{"type":"string","format":"uuid"},"success":{"type":"boolean"},"errorMessage":{"type":"string","nullable":true}}},"AdapterHealthDto":{"type":"object","properties":{"serviceId":{"type":"string","description":"Service this health record belongs to","format":"uuid"},"serviceSlug":{"type":"string","description":"URL-safe service identifier"},"serviceName":{"type":"string","description":"Service name"},"adapterType":{"type":"string","description":"Data source adapter type","nullable":true},"lastSuccessAt":{"type":"string","description":"Timestamp of the last successful poll","format":"date-time","nullable":true},"lastFailureAt":{"type":"string","description":"Timestamp of the last failed poll","format":"date-time","nullable":true},"consecutiveFailures":{"type":"integer","description":"Number of consecutive poll failures","format":"int32"},"lastErrorMessage":{"type":"string","description":"Error message from the most recent failure","nullable":true},"disabledByHealth":{"type":"boolean","description":"Whether the adapter is disabled due to repeated failures"},"updatedAt":{"type":"string","description":"Timestamp when this health record was last updated","format":"date-time"}}},"SingleValueResponseAdapterHealthDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AdapterHealthDto"}}},"CreateOrgRequest":{"required":["name"],"type":"object","properties":{"name":{"minLength":1,"type":"string","description":"Organization name"},"email":{"type":"string","description":"Billing and contact email address","format":"email","nullable":true}},"description":"Create a new organization"},"SingleValueResponseTransactionDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TransactionDto"}}},"TransactionDto":{"type":"object","properties":{"id":{"type":"string","description":"Paddle transaction identifier"},"status":{"type":"string","description":"Transaction status (e.g. completed, pending)","nullable":true},"currencyCode":{"type":"string","description":"ISO 4217 currency code","nullable":true},"invoiceNumber":{"type":"string","description":"Invoice number; null if not invoiced","nullable":true},"billedAt":{"type":"string","description":"Timestamp when the transaction was billed","format":"date-time","nullable":true},"createdAt":{"type":"string","description":"Timestamp when the transaction was created","format":"date-time"},"updatedAt":{"type":"string","description":"Timestamp when the transaction was last updated","format":"date-time"},"total":{"type":"string","description":"Total amount as a decimal string (including tax)","nullable":true},"subtotal":{"type":"string","description":"Subtotal before tax as a decimal string","nullable":true},"tax":{"type":"string","description":"Tax amount as a decimal string","nullable":true}},"description":"A billing transaction from Paddle"},"QuickMonitorRequest":{"required":["url"],"type":"object","properties":{"url":{"minLength":1,"type":"string","description":"Target URL to monitor"},"name":{"type":"string","description":"Human-readable monitor name; defaults to the hostname if omitted","nullable":true},"frequencySeconds":{"type":"integer","description":"Check frequency in seconds (30–86400); defaults to 60","format":"int32","nullable":true}},"description":"Minimal request for creating an HTTP monitor quickly"},"OnboardingSetupRequest":{"required":["name"],"type":"object","properties":{"name":{"maxLength":200,"minLength":0,"type":"string","description":"Organization or team name (max 200 chars)"},"role":{"maxLength":50,"minLength":0,"type":"string","description":"User's role or job title","nullable":true},"teamSize":{"maxLength":50,"minLength":0,"type":"string","description":"Team size range (e.g. 1-10, 11-50)","nullable":true}}},"AnalyzeUrlRequest":{"required":["url"],"type":"object","properties":{"url":{"minLength":1,"type":"string","description":"Target URL to analyze (must be a valid HTTP/HTTPS URL)"}},"description":"URL to analyze for monitor setup suggestions"},"AnalyzeUrlResponse":{"type":"object","properties":{"reachable":{"type":"boolean","description":"Whether the URL responded during analysis"},"responseTimeMs":{"type":"integer","description":"Response time observed during analysis in milliseconds","format":"int64"},"statusCode":{"type":"integer","description":"HTTP status code from the analysis request","format":"int32"},"tlsExpiry":{"type":"string","description":"TLS certificate expiry date; null for non-HTTPS or unavailable","format":"date-time","nullable":true},"tlsDaysRemaining":{"type":"integer","description":"Days until TLS certificate expires; null if not applicable","format":"int32","nullable":true},"contentType":{"type":"string","description":"Response Content-Type header value","nullable":true},"suggestedName":{"type":"string","description":"Suggested monitor name derived from the URL hostname"},"suggestedAssertions":{"type":"array","description":"Recommended assertions based on the URL response","items":{"$ref":"#/components/schemas/SuggestedAssertion"}},"suggestedFrequencySeconds":{"type":"integer","description":"Suggested check frequency in seconds based on the URL","format":"int32"}},"description":"Analysis of a URL with monitor setup suggestions"},"SingleValueResponseAnalyzeUrlResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AnalyzeUrlResponse"}}},"SuggestedAssertion":{"type":"object","properties":{"type":{"type":"string","description":"Assertion type (e.g. status_code, response_time)"},"operator":{"type":"string","description":"Comparison operator (e.g. equals, less_than)"},"value":{"type":"string","description":"Expected value to compare against"}},"description":"Recommended assertions based on the URL response"},"AcceptInviteRequest":{"required":["token"],"type":"object","properties":{"token":{"minLength":1,"type":"string","description":"Invite token from the invitation email"}},"description":"Accept an organization invite using the invite token"},"AcceptInviteDto":{"type":"object","properties":{"orgId":{"type":"integer","description":"Organization the user joined","format":"int32"},"userId":{"type":"integer","description":"User who accepted the invite","format":"int32"},"orgRole":{"type":"string","description":"Role assigned to the new member","enum":["OWNER","ADMIN","MEMBER"]},"status":{"type":"string","description":"Initial membership status after joining","enum":["INVITED","ACTIVE","SUSPENDED","LEFT","REMOVED","DECLINED"]}},"description":"Result of accepting an organization invite"},"SingleValueResponseAcceptInviteDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AcceptInviteDto"}}},"RegisterUserRequest":{"type":"object","properties":{"nickname":{"type":"string","description":"User nickname from the identity provider","nullable":true},"name":{"type":"string","description":"User display name from the identity provider","nullable":true},"picture":{"type":"string","description":"Profile picture URL from the identity provider","nullable":true}}},"CreateWorkspaceRequest":{"required":["name"],"type":"object","properties":{"name":{"minLength":1,"type":"string","description":"Workspace name"}},"description":"Create a new workspace within the organization"},"AddMemberRequest":{"required":["orgRole","userId"],"type":"object","properties":{"userId":{"type":"integer","description":"ID of the user to add","format":"int32"},"orgRole":{"type":"string","description":"Role to assign to the new member","enum":["OWNER","ADMIN","MEMBER"]}},"description":"Add an existing user as a member of the organization"},"MemberDto":{"type":"object","properties":{"userId":{"type":"integer","description":"User identifier of the member","format":"int32"},"email":{"type":"string","description":"Member email address"},"name":{"type":"string","description":"Member display name; null if not set","nullable":true},"orgRole":{"type":"string","description":"Member role within this organization (OWNER, ADMIN, MEMBER)","enum":["OWNER","ADMIN","MEMBER"]},"status":{"type":"string","description":"Membership status (ACTIVE, PENDING, SUSPENDED)","enum":["INVITED","ACTIVE","SUSPENDED","LEFT","REMOVED","DECLINED"]},"createdAt":{"type":"string","description":"Timestamp when the member was added to the organization","format":"date-time"}},"description":"Organization member with role and status"},"SingleValueResponseMemberDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MemberDto"}}},"CreateWebhookEndpointRequest":{"required":["subscribedEvents","url"],"type":"object","properties":{"url":{"maxLength":2048,"minLength":0,"type":"string","description":"HTTPS endpoint that receives webhook event payloads"},"description":{"maxLength":255,"minLength":0,"type":"string","description":"Optional human-readable description"},"subscribedEvents":{"minItems":1,"type":"array","description":"Event types to deliver, e.g. monitor.created, incident.resolved","items":{"minLength":1,"type":"string","description":"Event types to deliver, e.g. monitor.created, incident.resolved"}}}},"TestWebhookEndpointRequest":{"type":"object","properties":{"eventType":{"type":"string","description":"Event type to simulate (e.g. monitor.created); null uses a default","nullable":true}},"description":"Event type to use for a test webhook delivery"},"SingleValueResponseWebhookTestResult":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WebhookTestResult"}}},"WebhookTestResult":{"type":"object","properties":{"success":{"type":"boolean"},"statusCode":{"type":"integer","format":"int32","nullable":true},"message":{"type":"string"},"durationMs":{"type":"integer","format":"int64","nullable":true}}},"SingleValueResponseString":{"type":"object","properties":{"data":{"type":"string","nullable":true}}},"DekRotationResultDto":{"type":"object","properties":{"previousDekVersion":{"type":"integer","description":"DEK version before rotation","format":"int32"},"newDekVersion":{"type":"integer","description":"DEK version after rotation","format":"int32"},"secretsReEncrypted":{"type":"integer","description":"Number of secrets re-encrypted with the new DEK","format":"int32"},"channelsReEncrypted":{"type":"integer","description":"Number of alert channels re-encrypted with the new DEK","format":"int32"},"rotatedAt":{"type":"string","description":"Timestamp when the rotation was performed","format":"date-time"}},"description":"Result of a data encryption key rotation operation"},"SingleValueResponseDekRotationResultDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DekRotationResultDto"}}},"CreateTagRequest":{"required":["name"],"type":"object","properties":{"name":{"maxLength":100,"minLength":0,"type":"string","description":"Tag name, unique within the org"},"color":{"pattern":"^#([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$","type":"string","description":"Hex color code (defaults to #6B7280 if omitted)","nullable":true}},"description":"Request body for creating a tag"},"ServiceSubscribeRequest":{"type":"object","properties":{"componentId":{"type":"string","description":"ID of the component to subscribe to. Omit or null for whole-service subscription.","format":"uuid","nullable":true},"alertSensitivity":{"type":"string","description":"Alert sensitivity level. Defaults to INCIDENTS_ONLY when not provided.","nullable":true}},"description":"Optional body for subscribing to a specific component of a service"},"ComponentUptimeSummaryDto":{"type":"object","properties":{"day":{"type":"number","description":"Uptime percentage over the last 24 hours","format":"double","nullable":true,"example":99.95},"week":{"type":"number","description":"Uptime percentage over the last 7 days","format":"double","nullable":true,"example":99.98},"month":{"type":"number","description":"Uptime percentage over the last 30 days","format":"double","nullable":true,"example":99.92},"source":{"type":"string","description":"Data source: vendor_reported or incident_derived","example":"vendor_reported"}},"description":"Inline uptime percentages for 24h, 7d, 30d"},"ServiceComponentDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"externalId":{"type":"string"},"name":{"type":"string"},"status":{"type":"string"},"description":{"type":"string","nullable":true},"groupId":{"type":"string","format":"uuid","nullable":true},"position":{"type":"integer","format":"int32","nullable":true},"showcase":{"type":"boolean"},"onlyShowIfDegraded":{"type":"boolean"},"startDate":{"type":"string","format":"date-time","nullable":true},"vendorCreatedAt":{"type":"string","format":"date-time","nullable":true},"lifecycleStatus":{"type":"string"},"dataType":{"type":"string","description":"Data classification: full, status_only, or metric_only","example":"full"},"hasUptime":{"type":"boolean","description":"Whether uptime data is available for this component"},"region":{"type":"string","description":"Geographic region for regional components (AWS, GCP, Azure)","nullable":true},"groupName":{"type":"string","description":"Display name of the parent group","nullable":true},"uptime":{"$ref":"#/components/schemas/ComponentUptimeSummaryDto"},"statusChangedAt":{"type":"string","format":"date-time","nullable":true},"firstSeenAt":{"type":"string","format":"date-time"},"lastSeenAt":{"type":"string","format":"date-time"},"group":{"type":"boolean"}},"description":"A first-class service component with lifecycle and uptime data"},"ServiceSubscriptionDto":{"type":"object","properties":{"subscriptionId":{"type":"string","description":"Unique subscription identifier","format":"uuid"},"serviceId":{"type":"string","description":"Service identifier","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"category":{"type":"string","nullable":true},"officialStatusUrl":{"type":"string","nullable":true},"adapterType":{"type":"string"},"pollingIntervalSeconds":{"type":"integer","format":"int32"},"enabled":{"type":"boolean"},"logoUrl":{"type":"string","description":"Logo URL from the service catalog","nullable":true},"overallStatus":{"type":"string","description":"Current overall status; null when the service has never been polled","nullable":true},"componentId":{"type":"string","description":"Subscribed component id; null for whole-service subscription","format":"uuid","nullable":true},"component":{"$ref":"#/components/schemas/ServiceComponentDto"},"alertSensitivity":{"type":"string","description":"Alert sensitivity: ALL (synthetic + real incidents), INCIDENTS_ONLY (real vendor incidents, default), MAJOR_ONLY (real + DOWN severity)","enum":["ALL","INCIDENTS_ONLY","MAJOR_ONLY"]},"subscribedAt":{"type":"string","description":"When the organization subscribed to this service","format":"date-time"}},"description":"An org-level service subscription with current status information"},"SingleValueResponseServiceSubscriptionDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ServiceSubscriptionDto"}}},"CreateSecretRequest":{"required":["key","value"],"type":"object","properties":{"key":{"maxLength":255,"minLength":0,"type":"string","description":"Unique secret key within the workspace (max 255 chars)"},"value":{"maxLength":32768,"minLength":0,"type":"string","description":"Secret value, stored encrypted (max 32KB)"}}},"CreateResourceGroupRequest":{"required":["name"],"type":"object","properties":{"name":{"maxLength":255,"minLength":0,"type":"string","description":"Human-readable name for this group"},"description":{"type":"string","description":"Optional description","nullable":true},"alertPolicyId":{"type":"string","description":"Optional notification policy to apply for this group","format":"uuid","nullable":true},"defaultFrequency":{"maximum":86400,"minimum":30,"type":"integer","description":"Default check frequency in seconds applied to members (30–86400)","format":"int32","nullable":true},"defaultRegions":{"type":"array","description":"Default regions applied to member monitors","nullable":true,"items":{"type":"string","description":"Default regions applied to member monitors","nullable":true}},"defaultRetryStrategy":{"$ref":"#/components/schemas/RetryStrategy"},"defaultAlertChannels":{"type":"array","description":"Default alert channel IDs applied to member monitors","nullable":true,"items":{"type":"string","description":"Default alert channel IDs applied to member monitors","format":"uuid","nullable":true}},"defaultEnvironmentId":{"type":"string","description":"Default environment ID applied to member monitors","format":"uuid","nullable":true},"healthThresholdType":{"type":"string","description":"Health threshold type: COUNT or PERCENTAGE","nullable":true,"enum":["COUNT","PERCENTAGE"]},"healthThresholdValue":{"maximum":100,"exclusiveMaximum":false,"minimum":0,"exclusiveMinimum":false,"type":"number","description":"Health threshold value: count (0+) or percentage (0–100)","nullable":true},"suppressMemberAlerts":{"type":"boolean","description":"Suppress member-level alert notifications when group manages alerting","nullable":true},"confirmationDelaySeconds":{"maximum":600,"minimum":0,"type":"integer","description":"Confirmation delay in seconds before group incident creation (0–600)","format":"int32","nullable":true},"recoveryCooldownMinutes":{"maximum":60,"minimum":0,"type":"integer","description":"Recovery cooldown in minutes after group incident resolves (0–60)","format":"int32","nullable":true}},"description":"Request body for creating a resource group"},"AddResourceGroupMemberRequest":{"required":["memberId","memberType"],"type":"object","properties":{"memberType":{"minLength":1,"pattern":"monitor|service","type":"string","description":"Type of member: 'monitor' or 'service'"},"memberId":{"type":"string","description":"ID of the monitor or service to add","format":"uuid"}},"description":"Request body for adding a member to a resource group"},"SingleValueResponseResourceGroupMemberDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ResourceGroupMemberDto"}}},"CreateNotificationPolicyRequest":{"required":["escalation","name"],"type":"object","properties":{"name":{"maxLength":255,"minLength":0,"type":"string","description":"Human-readable name for this policy"},"matchRules":{"type":"array","description":"Match rules to evaluate (all must pass; omit or empty for catch-all)","items":{"$ref":"#/components/schemas/MatchRule"}},"escalation":{"$ref":"#/components/schemas/EscalationChain"},"enabled":{"type":"boolean","description":"Whether this policy is enabled (default true)","default":true},"priority":{"type":"integer","description":"Evaluation priority; higher value = evaluated first (default 0)","format":"int32","default":0}},"description":"Request body for creating a notification policy"},"TestNotificationPolicyRequest":{"type":"object","properties":{"severity":{"type":"string","description":"Incident severity to test against (e.g. DOWN, DEGRADED, MAINTENANCE)","nullable":true},"monitorId":{"type":"string","description":"Monitor UUID to test against (monitoring events)","format":"uuid","nullable":true},"regions":{"type":"array","description":"Affected region identifiers to test against (monitoring events)","nullable":true,"items":{"type":"string","description":"Affected region identifiers to test against (monitoring events)","nullable":true}},"eventType":{"type":"string","description":"Incident event type to test against — short form (e.g. created, resolved, reopened) or full form (e.g. incident.created)","nullable":true},"monitorType":{"type":"string","description":"Monitor check type to test against (e.g. HTTP, DNS, MCP_SERVER)","nullable":true},"serviceId":{"type":"string","description":"Service catalog UUID to test against (status data events)","format":"uuid","nullable":true},"componentName":{"type":"string","description":"Component name to test against (status data events, e.g. \"Actions\")","nullable":true},"resourceGroupIds":{"type":"array","description":"Resource group UUIDs the entity belongs to, for resource_group_id_in rules","nullable":true,"items":{"type":"string","description":"Resource group UUIDs the entity belongs to, for resource_group_id_in rules","format":"uuid","nullable":true}}},"description":"Event context for a dry-run match evaluation against a notification policy"},"SingleValueResponseTestMatchResult":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TestMatchResult"}}},"TestMatchResult":{"type":"object","properties":{"matched":{"type":"boolean","description":"Whether the policy would match the supplied incident context"},"matchedRules":{"type":"array","description":"Rules that passed evaluation","items":{"type":"string","description":"Rules that passed evaluation"}},"unmatchedRules":{"type":"array","description":"Rules that did not pass evaluation","items":{"type":"string","description":"Rules that did not pass evaluation"}}},"description":"Result of a dry-run match evaluation against a notification policy"},"AlertDeliveryDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"incidentId":{"type":"string","description":"Incident that triggered this delivery","format":"uuid"},"dispatchId":{"type":"string","description":"Notification dispatch that created this delivery","format":"uuid","nullable":true},"channelId":{"type":"string","description":"Alert channel ID","format":"uuid"},"channel":{"type":"string","description":"Human-readable channel name"},"channelType":{"type":"string","description":"Alert channel type (e.g. slack, email, webhook)"},"status":{"type":"string","description":"Current delivery status","enum":["PENDING","DELIVERED","RETRY_PENDING","FAILED","CANCELLED"]},"eventType":{"type":"string","description":"Incident lifecycle event that triggered this delivery","enum":["INCIDENT_CREATED","INCIDENT_RESOLVED","INCIDENT_REOPENED"]},"stepNumber":{"type":"integer","description":"1-based escalation step this delivery belongs to","format":"int32"},"fireCount":{"type":"integer","description":"Fire sequence within the step: 1 = initial, 2+ = repeat re-fires","format":"int32"},"attemptCount":{"type":"integer","description":"Number of delivery attempts made","format":"int32"},"lastAttemptAt":{"type":"string","description":"When the last attempt was made","format":"date-time","nullable":true},"nextRetryAt":{"type":"string","description":"When the next retry is scheduled (null if not retrying)","format":"date-time","nullable":true},"deliveredAt":{"type":"string","description":"Timestamp when the delivery was confirmed (null if not yet delivered)","format":"date-time","nullable":true},"errorMessage":{"type":"string","description":"Error message from the last failed attempt","nullable":true},"createdAt":{"type":"string","format":"date-time"}},"description":"Delivery record for a single channel within a notification dispatch"},"NotificationDispatchDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique dispatch record identifier","format":"uuid"},"incidentId":{"type":"string","description":"Incident this dispatch is for","format":"uuid"},"policyId":{"type":"string","description":"Notification policy that matched this incident","format":"uuid"},"policyName":{"type":"string","description":"Human-readable name of the matched policy (null if policy has been deleted)","nullable":true},"status":{"type":"string","description":"Current dispatch state","enum":["PENDING","DISPATCHING","DELIVERED","ESCALATING","ACKNOWLEDGED","COMPLETED"]},"completionReason":{"type":"string","description":"Why the dispatch reached COMPLETED: EXHAUSTED (all steps ran, no ack), RESOLVED (incident resolved), NO_STEPS (policy had no steps). Null for non-terminal states.","nullable":true,"enum":["EXHAUSTED","RESOLVED","NO_STEPS"]},"currentStep":{"type":"integer","description":"1-based index of the currently active escalation step","format":"int32"},"totalSteps":{"type":"integer","description":"Total number of escalation steps in the policy (null if policy has been deleted)","format":"int32","nullable":true},"acknowledgedAt":{"type":"string","description":"Timestamp when this dispatch was acknowledged (null if not acknowledged)","format":"date-time","nullable":true},"nextEscalationAt":{"type":"string","description":"Timestamp when the next escalation step will fire (null if not scheduled)","format":"date-time","nullable":true},"lastNotifiedAt":{"type":"string","description":"Timestamp of the most recent notification delivery","format":"date-time","nullable":true},"deliveries":{"type":"array","description":"Delivery records for all channels associated with this dispatch","items":{"$ref":"#/components/schemas/AlertDeliveryDto"}},"createdAt":{"type":"string","description":"Timestamp when the dispatch was created","format":"date-time"},"updatedAt":{"type":"string","description":"Timestamp when the dispatch was last updated","format":"date-time"}},"description":"Dispatch state for a single (incident, notification policy) pair, with delivery history"},"SingleValueResponseNotificationDispatchDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/NotificationDispatchDto"}}},"CreateMonitorRequest":{"required":["config","managedBy","name","type"],"type":"object","properties":{"name":{"maxLength":255,"minLength":0,"type":"string","description":"Human-readable name for this monitor"},"type":{"type":"string","description":"Monitor protocol type","enum":["HTTP","DNS","MCP_SERVER","TCP","ICMP","HEARTBEAT"]},"config":{"oneOf":[{"$ref":"#/components/schemas/DnsMonitorConfig"},{"$ref":"#/components/schemas/HeartbeatMonitorConfig"},{"$ref":"#/components/schemas/HttpMonitorConfig"},{"$ref":"#/components/schemas/IcmpMonitorConfig"},{"$ref":"#/components/schemas/McpServerMonitorConfig"},{"$ref":"#/components/schemas/TcpMonitorConfig"}]},"frequencySeconds":{"type":"integer","description":"Check frequency in seconds (30–86400, default: 60)","format":"int32"},"enabled":{"type":"boolean","description":"Whether the monitor is active (default: true)","nullable":true},"regions":{"type":"array","description":"Probe regions to run checks from, e.g. us-east, eu-west","nullable":true,"items":{"type":"string","description":"Probe regions to run checks from, e.g. us-east, eu-west","nullable":true}},"managedBy":{"type":"string","description":"Who manages this monitor: DASHBOARD or CLI","enum":["DASHBOARD","CLI"]},"environmentId":{"type":"string","description":"Environment to associate with this monitor","format":"uuid","nullable":true},"assertions":{"type":"array","description":"Assertions to evaluate against each check result","nullable":true,"items":{"$ref":"#/components/schemas/CreateAssertionRequest"}},"auth":{"oneOf":[{"$ref":"#/components/schemas/ApiKeyAuthConfig"},{"$ref":"#/components/schemas/BasicAuthConfig"},{"$ref":"#/components/schemas/BearerAuthConfig"},{"$ref":"#/components/schemas/HeaderAuthConfig"}]},"incidentPolicy":{"$ref":"#/components/schemas/UpdateIncidentPolicyRequest"},"alertChannelIds":{"type":"array","description":"Alert channels to notify when this monitor triggers","nullable":true,"items":{"type":"string","description":"Alert channels to notify when this monitor triggers","format":"uuid","nullable":true}},"tags":{"$ref":"#/components/schemas/AddMonitorTagsRequest"}}},"SetMonitorAuthRequest":{"required":["config"],"type":"object","properties":{"config":{"oneOf":[{"$ref":"#/components/schemas/ApiKeyAuthConfig"},{"$ref":"#/components/schemas/BasicAuthConfig"},{"$ref":"#/components/schemas/BearerAuthConfig"},{"$ref":"#/components/schemas/HeaderAuthConfig"}]}}},"AssertionTestResultDto":{"type":"object","properties":{"assertionType":{"type":"string","description":"Assertion type evaluated","enum":["status_code","response_time","body_contains","json_path","header","regex","dns_resolves","dns_response_time","dns_expected_ips","dns_expected_cname","dns_record_contains","dns_record_equals","dns_txt_contains","dns_min_answers","dns_max_answers","dns_response_time_warn","dns_ttl_low","dns_ttl_high","mcp_connects","mcp_response_time","mcp_has_capability","mcp_tool_available","mcp_min_tools","mcp_protocol_version","mcp_response_time_warn","mcp_tool_count_changed","ssl_expiry","response_size","redirect_count","redirect_target","response_time_warn","tcp_connects","tcp_response_time","tcp_response_time_warn","icmp_reachable","icmp_response_time","icmp_response_time_warn","icmp_packet_loss","heartbeat_received","heartbeat_max_interval","heartbeat_interval_drift","heartbeat_payload_contains"]},"passed":{"type":"boolean","description":"Whether the assertion passed"},"severity":{"type":"string","description":"Assertion severity: FAIL or WARN","enum":["fail","warn"]},"message":{"type":"string","description":"Human-readable result description"},"expected":{"type":"string","description":"Expected value","nullable":true},"actual":{"type":"string","description":"Actual value observed during the test","nullable":true}}},"MonitorTestResultDto":{"type":"object","properties":{"passed":{"type":"boolean"},"error":{"type":"string","nullable":true},"statusCode":{"type":"integer","format":"int32","nullable":true},"responseTimeMs":{"type":"integer","format":"int64","nullable":true},"responseHeaders":{"type":"object","additionalProperties":{"type":"array","nullable":true,"items":{"type":"string","nullable":true}},"nullable":true},"bodyPreview":{"type":"string","nullable":true},"responseSizeBytes":{"type":"integer","format":"int64","nullable":true},"redirectCount":{"type":"integer","format":"int32","nullable":true},"finalUrl":{"type":"string","nullable":true},"assertionResults":{"type":"array","items":{"$ref":"#/components/schemas/AssertionTestResultDto"}},"warnings":{"type":"array","nullable":true,"items":{"type":"string","nullable":true}}}},"SingleValueResponseMonitorTestResultDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MonitorTestResultDto"}}},"TableValueResultTagDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TagDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"MonitorTestRequest":{"required":["config","type"],"type":"object","properties":{"type":{"type":"string","description":"Monitor protocol type to test","enum":["HTTP","DNS","MCP_SERVER","TCP","ICMP","HEARTBEAT"]},"config":{"oneOf":[{"$ref":"#/components/schemas/DnsMonitorConfig"},{"$ref":"#/components/schemas/HeartbeatMonitorConfig"},{"$ref":"#/components/schemas/HttpMonitorConfig"},{"$ref":"#/components/schemas/IcmpMonitorConfig"},{"$ref":"#/components/schemas/McpServerMonitorConfig"},{"$ref":"#/components/schemas/TcpMonitorConfig"}]},"assertions":{"type":"array","description":"Optional assertions to evaluate against the test result","nullable":true,"items":{"$ref":"#/components/schemas/CreateAssertionRequest"}}}},"BulkMonitorActionRequest":{"required":["action","monitorIds"],"type":"object","properties":{"monitorIds":{"maxItems":200,"minItems":0,"type":"array","description":"IDs of monitors to act on (max 200)","items":{"type":"string","description":"IDs of monitors to act on (max 200)","format":"uuid"}},"action":{"type":"string","description":"Action to perform: PAUSE, RESUME, DELETE, ADD_TAG, REMOVE_TAG","enum":["PAUSE","RESUME","DELETE","ADD_TAG","REMOVE_TAG"]},"tagIds":{"type":"array","description":"Tag IDs to attach or detach (required for ADD_TAG and REMOVE_TAG)","nullable":true,"items":{"type":"string","description":"Tag IDs to attach or detach (required for ADD_TAG and REMOVE_TAG)","format":"uuid","nullable":true}},"newTags":{"type":"array","description":"New tags to create and attach (only for ADD_TAG)","nullable":true,"items":{"$ref":"#/components/schemas/NewTagRequest"}}},"description":"Request body for performing a bulk action on multiple monitors"},"BulkMonitorActionResult":{"type":"object","properties":{"succeeded":{"type":"array","description":"IDs of monitors on which the action succeeded","items":{"type":"string","description":"IDs of monitors on which the action succeeded","format":"uuid"}},"failed":{"type":"array","description":"Monitors on which the action failed, with the reason for each failure","items":{"$ref":"#/components/schemas/FailureDetail"}}},"description":"Result of a bulk monitor action, including partial-success details"},"FailureDetail":{"type":"object","properties":{"monitorId":{"type":"string","description":"Monitor ID that failed","format":"uuid"},"reason":{"type":"string","description":"Human-readable reason for the failure"}},"description":"Details about a single monitor that failed the bulk action"},"SingleValueResponseBulkMonitorActionResult":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/BulkMonitorActionResult"}}},"CreateMaintenanceWindowRequest":{"required":["endsAt","startsAt"],"type":"object","properties":{"monitorId":{"type":"string","description":"Monitor to attach this maintenance window to; null for org-wide","format":"uuid"},"startsAt":{"type":"string","description":"Scheduled start of the maintenance window (ISO 8601)","format":"date-time"},"endsAt":{"type":"string","description":"Scheduled end of the maintenance window (ISO 8601)","format":"date-time"},"repeatRule":{"maxLength":100,"minLength":0,"type":"string","description":"iCal RRULE for recurring windows (max 100 chars); null for one-time"},"reason":{"type":"string","description":"Human-readable reason for the maintenance"},"suppressAlerts":{"type":"boolean","description":"Whether to suppress alerts during this window (default: true)"}}},"CreateInviteRequest":{"required":["email","roleOffered"],"type":"object","properties":{"email":{"minLength":1,"type":"string","description":"Email address to invite","format":"email"},"roleOffered":{"type":"string","description":"Role to assign on acceptance","enum":["OWNER","ADMIN","MEMBER"]}},"description":"Invite a new member to the organization by email"},"InviteDto":{"type":"object","properties":{"inviteId":{"type":"integer","description":"Unique invite identifier","format":"int32"},"email":{"type":"string","description":"Email address the invite was sent to"},"roleOffered":{"type":"string","description":"Role that will be assigned to the invitee on acceptance","enum":["OWNER","ADMIN","MEMBER"]},"expiresAt":{"type":"string","description":"Timestamp when the invite expires","format":"date-time"},"consumedAt":{"type":"string","description":"Timestamp when the invite was accepted; null if not yet used","format":"date-time","nullable":true},"revokedAt":{"type":"string","description":"Timestamp when the invite was revoked; null if active","format":"date-time","nullable":true}},"description":"Organization invite sent to an email address"},"SingleValueResponseInviteDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/InviteDto"}}},"CreateManualIncidentRequest":{"required":["severity","title"],"type":"object","properties":{"title":{"minLength":1,"type":"string","description":"Short summary of the incident"},"severity":{"type":"string","description":"Incident severity: DOWN, DEGRADED, or MAINTENANCE","enum":["DOWN","DEGRADED","MAINTENANCE"]},"monitorId":{"type":"string","description":"Monitor to associate with this incident","format":"uuid","nullable":true},"body":{"type":"string","description":"Detailed description or context for the incident","nullable":true}}},"IncidentDetailDto":{"type":"object","properties":{"incident":{"$ref":"#/components/schemas/IncidentDto"},"updates":{"type":"array","items":{"$ref":"#/components/schemas/IncidentUpdateDto"}}}},"IncidentUpdateDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"incidentId":{"type":"string","format":"uuid"},"oldStatus":{"type":"string","nullable":true,"enum":["WATCHING","TRIGGERED","CONFIRMED","RESOLVED"]},"newStatus":{"type":"string","nullable":true,"enum":["WATCHING","TRIGGERED","CONFIRMED","RESOLVED"]},"body":{"type":"string","nullable":true},"createdBy":{"type":"string","enum":["SYSTEM","USER"]},"notifySubscribers":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"}}},"SingleValueResponseIncidentDetailDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/IncidentDetailDto"}}},"AddIncidentUpdateRequest":{"type":"object","properties":{"body":{"type":"string","description":"Update message or post-mortem notes"},"newStatus":{"type":"string","description":"Updated incident status; null to keep current status","enum":["WATCHING","TRIGGERED","CONFIRMED","RESOLVED"]},"notifySubscribers":{"type":"boolean","description":"Whether to notify subscribers of this update"}}},"ResolveIncidentRequest":{"type":"object","properties":{"body":{"type":"string","description":"Optional resolution message or post-mortem notes"}}},"CreateEnvironmentRequest":{"required":["name","slug"],"type":"object","properties":{"name":{"maxLength":100,"minLength":0,"type":"string","description":"Human-readable environment name"},"slug":{"maxLength":100,"minLength":0,"pattern":"^[a-z0-9][a-z0-9_-]*$","type":"string","description":"URL-safe identifier (lowercase alphanumeric, hyphens, underscores)"},"variables":{"type":"object","additionalProperties":{"type":"string","description":"Initial key-value variable pairs for this environment","nullable":true},"description":"Initial key-value variable pairs for this environment","nullable":true},"isDefault":{"type":"boolean","description":"Whether this is the default environment for new monitors"}}},"AcquireDeployLockRequest":{"required":["lockedBy"],"type":"object","properties":{"lockedBy":{"minLength":1,"type":"string","description":"Identity of the lock requester (e.g. hostname, CI job ID)"},"ttlMinutes":{"type":"integer","description":"Lock TTL in minutes (default: 30, max: 60)","format":"int32","nullable":true,"example":30}},"description":"Request to acquire a deploy lock for the current workspace"},"DeployLockDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique lock identifier","format":"uuid"},"lockedBy":{"type":"string","description":"Identity of the lock holder (e.g. CLI session ID, username)"},"lockedAt":{"type":"string","description":"Timestamp when the lock was acquired","format":"date-time"},"expiresAt":{"type":"string","description":"Timestamp when the lock automatically expires","format":"date-time"}},"description":"Represents an active deploy lock for a workspace"},"SingleValueResponseDeployLockDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DeployLockDto"}}},"CreateApiKeyRequest":{"required":["name"],"type":"object","properties":{"name":{"maxLength":200,"minLength":0,"type":"string","description":"Human-readable name to identify this API key"},"expiresAt":{"type":"string","description":"Optional expiration timestamp in ISO 8601 format","format":"date-time","nullable":true}}},"ApiKeyCreateResponse":{"type":"object","properties":{"id":{"type":"integer","description":"Unique API key identifier","format":"int32"},"name":{"type":"string","description":"Human-readable name for this API key"},"key":{"type":"string","description":"Full API key value in dh_live_* format; store this now"},"createdAt":{"type":"string","description":"Timestamp when the key was created","format":"date-time"},"expiresAt":{"type":"string","description":"Timestamp when the key expires; null if no expiration","format":"date-time","nullable":true}},"description":"Created API key with the full key value — store it now, it won't be shown again"},"SingleValueResponseApiKeyCreateResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ApiKeyCreateResponse"}}},"ApiKeyDto":{"type":"object","properties":{"id":{"type":"integer","description":"Unique API key identifier","format":"int32"},"name":{"type":"string","description":"Human-readable name for this API key"},"key":{"type":"string","description":"Full API key value in dh_live_* format"},"createdAt":{"type":"string","description":"Timestamp when the key was created","format":"date-time"},"updatedAt":{"type":"string","description":"Timestamp when the key was last updated","format":"date-time"},"lastUsedAt":{"type":"string","description":"Timestamp of the most recent API call; null if never used","format":"date-time","nullable":true},"revokedAt":{"type":"string","description":"Timestamp when the key was revoked; null if active","format":"date-time","nullable":true},"expiresAt":{"type":"string","description":"Timestamp when the key expires; null if no expiration","format":"date-time","nullable":true}},"description":"API key for programmatic access to the DevHelm API"},"SingleValueResponseApiKeyDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ApiKeyDto"}}},"SingleValueResponseAlertDeliveryDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AlertDeliveryDto"}}},"CreateAlertChannelRequest":{"required":["config","name"],"type":"object","properties":{"name":{"maxLength":255,"minLength":0,"type":"string","description":"Human-readable name for this alert channel"},"config":{"oneOf":[{"$ref":"#/components/schemas/DiscordChannelConfig"},{"$ref":"#/components/schemas/EmailChannelConfig"},{"$ref":"#/components/schemas/OpsGenieChannelConfig"},{"$ref":"#/components/schemas/PagerDutyChannelConfig"},{"$ref":"#/components/schemas/SlackChannelConfig"},{"$ref":"#/components/schemas/TeamsChannelConfig"},{"$ref":"#/components/schemas/WebhookChannelConfig"}]}}},"SingleValueResponseTestChannelResult":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/TestChannelResult"}}},"TestChannelResult":{"type":"object","properties":{"success":{"type":"boolean"},"message":{"type":"string"}}},"TestAlertChannelRequest":{"required":["config"],"type":"object","properties":{"config":{"oneOf":[{"$ref":"#/components/schemas/DiscordChannelConfig"},{"$ref":"#/components/schemas/EmailChannelConfig"},{"$ref":"#/components/schemas/OpsGenieChannelConfig"},{"$ref":"#/components/schemas/PagerDutyChannelConfig"},{"$ref":"#/components/schemas/SlackChannelConfig"},{"$ref":"#/components/schemas/TeamsChannelConfig"},{"$ref":"#/components/schemas/WebhookChannelConfig"}]}},"description":"Alert channel configuration to test without saving"},"ComponentUpdateRequest":{"required":["addComponents"],"type":"object","properties":{"addComponents":{"minItems":1,"type":"array","items":{"type":"string"}}}},"UpdateAlertSensitivityRequest":{"required":["alertSensitivity"],"type":"object","properties":{"alertSensitivity":{"minLength":1,"pattern":"ALL|INCIDENTS_ONLY|MAJOR_ONLY","type":"string","description":"Alert sensitivity: ALL (any status change), INCIDENTS_ONLY (real vendor incidents, default), MAJOR_ONLY (only DOWN-level incidents)"}},"description":"Request body for updating alert sensitivity on a service subscription"},"UpdateApiKeyRequest":{"required":["name"],"type":"object","properties":{"name":{"maxLength":200,"minLength":0,"type":"string","description":"New name for this API key"}}},"TableValueResultWorkspaceDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"SingleValueResponseMapStringString":{"type":"object","properties":{"data":{"type":"object","additionalProperties":{"type":"string","nullable":true},"nullable":true}}},"SingleValueResponseListMonitorAssertionDto":{"type":"object","properties":{"data":{"type":"array","nullable":true,"items":{"$ref":"#/components/schemas/MonitorAssertionDto"}}}},"SchedulableMonitorDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique monitor identifier","format":"uuid"},"type":{"type":"string","description":"Monitor protocol type","enum":["HTTP","DNS","MCP_SERVER","TCP","ICMP","HEARTBEAT"]},"config":{"oneOf":[{"$ref":"#/components/schemas/DnsMonitorConfig"},{"$ref":"#/components/schemas/HeartbeatMonitorConfig"},{"$ref":"#/components/schemas/HttpMonitorConfig"},{"$ref":"#/components/schemas/IcmpMonitorConfig"},{"$ref":"#/components/schemas/McpServerMonitorConfig"},{"$ref":"#/components/schemas/TcpMonitorConfig"}]},"frequencySeconds":{"type":"integer","description":"Check frequency in seconds","format":"int32"},"regions":{"type":"array","description":"Probe regions to execute checks from","items":{"type":"string","description":"Probe regions to execute checks from"}},"organizationId":{"type":"integer","description":"Organization this monitor belongs to","format":"int32"}}},"TableValueResultAdapterHealthDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AdapterHealthDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"SingleValueResponseListBillingPlanDto":{"type":"object","properties":{"data":{"type":"array","nullable":true,"items":{"$ref":"#/components/schemas/BillingPlanDto"}}}},"TableValueResultTransactionDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/TransactionDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultSubscriptionDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/SubscriptionDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"SingleValueResponseUpcomingChargeResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/UpcomingChargeResponse"}}},"UpcomingChargeResponse":{"type":"object","properties":{"action":{"type":"string","description":"Type of subscription action being previewed","enum":["UPGRADE","DOWNGRADE","NOOP"]},"immediateAmount":{"type":"integer","description":"Amount due immediately (proration) in smallest currency unit","format":"int32"},"nextBillingAmount":{"type":"integer","description":"Amount that will be charged on the next billing cycle","format":"int32"},"nextBillingDate":{"type":"string","description":"Date of the next billing cycle; null if cancelling","format":"date-time","nullable":true}},"description":"Preview of upcoming subscription charge after a plan change"},"EntitlementDto":{"type":"object","properties":{"key":{"type":"string","description":"Entitlement key"},"value":{"type":"integer","description":"Effective limit value (overrides applied)","format":"int64"},"defaultValue":{"type":"integer","description":"Plan-tier default value before overrides","format":"int64"},"overridden":{"type":"boolean","description":"Whether this entitlement has an org-level override"}},"description":"A single resolved entitlement for the organization"},"EntitlementResponse":{"type":"object","properties":{"tier":{"type":"string","description":"Resolved billing plan tier","enum":["FREE","STARTER","PRO","TEAM","BUSINESS","ENTERPRISE"]},"entitlements":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/EntitlementDto"},"description":"All entitlements keyed by entitlement key"},"usage":{"type":"object","additionalProperties":{"type":"integer","description":"Current usage counters keyed by entitlement key (only for countable resources)","format":"int64"},"description":"Current usage counters keyed by entitlement key (only for countable resources)"},"trialActive":{"type":"boolean","description":"Whether the org is currently on a trial"},"trialExpiresAt":{"type":"string","description":"Trial expiry date (null if not trialing)","format":"date-time","nullable":true},"subscriptionStatus":{"type":"string","description":"Current subscription status (null if no subscription)","nullable":true}},"description":"Full entitlement state for an organization: resolved limits, usage, and trial info"},"SingleValueResponseEntitlementResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/EntitlementResponse"}}},"PaginationParams":{"required":["sortBy","sortOrder"],"type":"object","properties":{"sortBy":{"type":"string"},"sortOrder":{"type":"string","enum":["ASC","DESC"]},"page":{"minimum":0,"type":"integer","format":"int32"},"size":{"maximum":200,"minimum":1,"type":"integer","format":"int32"}}},"IdValuePair":{"type":"object","properties":{"id":{"type":"integer","description":"Numeric identifier","format":"int32"},"value":{"type":"string","description":"Display label or value"}},"description":"Generic id/value pair for select options and autocomplete"},"TableValueResultIdValuePair":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/IdValuePair"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"MyOrgItemDto":{"type":"object","properties":{"orgId":{"type":"integer","description":"Organization identifier","format":"int32"},"orgName":{"type":"string","description":"Organization name"},"orgRole":{"type":"string","description":"Member role within this organization","enum":["OWNER","ADMIN","MEMBER"]},"status":{"type":"string","description":"Membership status","enum":["INVITED","ACTIVE","SUSPENDED","LEFT","REMOVED","DECLINED"]}},"description":"Membership summary for an organization the user belongs to"},"TableValueResultMyOrgItemDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MyOrgItemDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"SseEmitter":{"type":"object","properties":{"timeout":{"type":"integer","format":"int64","nullable":true}}},"Pageable":{"type":"object","properties":{"page":{"minimum":0,"type":"integer","format":"int32"},"size":{"minimum":1,"type":"integer","format":"int32"},"sort":{"type":"array","items":{"type":"string"}}}},"TableValueResultUserDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/UserDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"AdminStatsDto":{"type":"object","properties":{"userCount":{"type":"integer","format":"int64"},"orgCount":{"type":"integer","format":"int64"},"memberCount":{"type":"integer","format":"int64"}}},"SingleValueResponseAdminStatsDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AdminStatsDto"}}},"TableValueResultOrganizationDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/OrganizationDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultMemberDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MemberDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultWebhookEndpointDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookEndpointDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultWebhookDeliveryDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/WebhookDeliveryDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"WebhookDeliveryDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"endpointId":{"type":"string","format":"uuid"},"eventId":{"type":"string"},"eventType":{"type":"string"},"status":{"type":"string"},"attemptCount":{"type":"integer","format":"int32"},"maxAttempts":{"type":"integer","format":"int32"},"responseStatus":{"type":"integer","format":"int32","nullable":true},"responseLatencyMs":{"type":"integer","format":"int32","nullable":true},"errorMessage":{"type":"string","nullable":true},"deliveredAt":{"type":"string","format":"date-time","nullable":true},"failedAt":{"type":"string","format":"date-time","nullable":true},"nextRetryAt":{"type":"string","format":"date-time","nullable":true},"createdAt":{"type":"string","format":"date-time"}}},"SingleValueResponseWebhookSigningSecretDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/WebhookSigningSecretDto"}}},"WebhookSigningSecretDto":{"type":"object","properties":{"configured":{"type":"boolean"},"maskedSecret":{"type":"string","nullable":true}}},"WebhookEventCatalogEntry":{"type":"object","properties":{"type":{"type":"string","description":"Dot-notation event type identifier, e.g. \"monitor.created\""},"surface":{"type":"string","description":"Product surface this event belongs to, e.g. \"monitoring\" or \"status_data\""},"description":{"type":"string","description":"Human-readable description of when this event fires"}},"description":"List of all available webhook event types"},"WebhookEventCatalogResponse":{"type":"object","properties":{"data":{"type":"array","description":"List of all available webhook event types","items":{"$ref":"#/components/schemas/WebhookEventCatalogEntry"}}}},"CursorPageServiceCatalogDto":{"type":"object","properties":{"data":{"type":"array","description":"Items on this page","items":{"$ref":"#/components/schemas/ServiceCatalogDto"}},"nextCursor":{"type":"string","description":"Opaque cursor for the next page; null when there are no more results","nullable":true},"hasMore":{"type":"boolean","description":"Whether more results exist beyond this page"}},"description":"Cursor-paginated response for time-series and append-only data"},"ServiceCatalogDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"category":{"type":"string","nullable":true},"officialStatusUrl":{"type":"string","nullable":true},"developerContext":{"type":"string","nullable":true},"logoUrl":{"type":"string","nullable":true},"adapterType":{"type":"string"},"pollingIntervalSeconds":{"type":"integer","format":"int32"},"enabled":{"type":"boolean"},"overallStatus":{"type":"string","nullable":true},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"componentCount":{"type":"integer","format":"int64"},"activeIncidentCount":{"type":"integer","format":"int64"},"dataCompleteness":{"type":"string"}},"description":"Items on this page"},"MaintenanceComponentRef":{"type":"object","properties":{"id":{"type":"string","description":"Component identifier","format":"uuid"},"name":{"type":"string","description":"Component name"},"status":{"type":"string","description":"Component status at the time of the maintenance update"}},"description":"A component affected by a scheduled maintenance window"},"MaintenanceUpdateDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique update identifier","format":"uuid"},"status":{"type":"string","description":"Status at the time of this update"},"body":{"type":"string","description":"Update message from the vendor","nullable":true},"displayAt":{"type":"string","description":"Timestamp when this update was posted","format":"date-time","nullable":true}},"description":"A status update within a scheduled maintenance lifecycle"},"ScheduledMaintenanceDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique maintenance record identifier","format":"uuid"},"externalId":{"type":"string","description":"Vendor-assigned maintenance identifier"},"title":{"type":"string","description":"Maintenance title as reported by the vendor"},"status":{"type":"string","description":"Current maintenance status (scheduled, in_progress, completed)"},"impact":{"type":"string","description":"Reported impact level","nullable":true},"shortlink":{"type":"string","description":"Vendor-provided short URL to the maintenance page","nullable":true},"scheduledFor":{"type":"string","description":"Timestamp when the maintenance is scheduled to begin","format":"date-time","nullable":true},"scheduledUntil":{"type":"string","description":"Timestamp when the maintenance is scheduled to end","format":"date-time","nullable":true},"startedAt":{"type":"string","description":"Timestamp when the maintenance actually started","format":"date-time","nullable":true},"completedAt":{"type":"string","description":"Timestamp when the maintenance was completed","format":"date-time","nullable":true},"affectedComponents":{"type":"array","description":"Components affected by this maintenance","items":{"$ref":"#/components/schemas/MaintenanceComponentRef"}},"updates":{"type":"array","description":"Status updates posted during the maintenance lifecycle","items":{"$ref":"#/components/schemas/MaintenanceUpdateDto"}}},"description":"A scheduled maintenance window from a vendor status page"},"ServiceDetailDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"slug":{"type":"string"},"name":{"type":"string"},"category":{"type":"string","nullable":true},"officialStatusUrl":{"type":"string","nullable":true},"developerContext":{"type":"string","nullable":true},"logoUrl":{"type":"string","nullable":true},"adapterType":{"type":"string"},"pollingIntervalSeconds":{"type":"integer","format":"int32"},"enabled":{"type":"boolean"},"createdAt":{"type":"string","format":"date-time"},"updatedAt":{"type":"string","format":"date-time"},"currentStatus":{"$ref":"#/components/schemas/ServiceStatusDto"},"recentIncidents":{"type":"array","items":{"$ref":"#/components/schemas/ServiceIncidentDto"}},"components":{"type":"array","items":{"$ref":"#/components/schemas/ServiceComponentDto"}},"uptime":{"$ref":"#/components/schemas/ComponentUptimeSummaryDto"},"activeMaintenances":{"type":"array","items":{"$ref":"#/components/schemas/ScheduledMaintenanceDto"}},"dataCompleteness":{"type":"string"}}},"ServiceIncidentDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"serviceId":{"type":"string","format":"uuid"},"serviceSlug":{"type":"string","nullable":true},"serviceName":{"type":"string","nullable":true},"externalId":{"type":"string","nullable":true},"title":{"type":"string"},"status":{"type":"string"},"impact":{"type":"string","nullable":true},"startedAt":{"type":"string","format":"date-time","nullable":true},"resolvedAt":{"type":"string","format":"date-time","nullable":true},"updatedAt":{"type":"string","format":"date-time","nullable":true},"shortlink":{"type":"string","nullable":true},"detectedAt":{"type":"string","format":"date-time","nullable":true},"vendorCreatedAt":{"type":"string","format":"date-time","nullable":true}}},"ServiceStatusDto":{"type":"object","properties":{"overallStatus":{"type":"string"},"lastPolledAt":{"type":"string","format":"date-time","nullable":true}}},"SingleValueResponseServiceDetailDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ServiceDetailDto"}}},"ServiceUptimeResponse":{"type":"object","properties":{"overallUptimePct":{"type":"number","description":"Overall uptime percentage across the entire period; null when no polling data exists","format":"double","nullable":true,"example":99.95},"period":{"type":"string","description":"Requested period","example":"7d"},"granularity":{"type":"string","description":"Requested granularity","example":"hourly"},"buckets":{"type":"array","description":"Per-bucket breakdown ordered by time ascending","items":{"$ref":"#/components/schemas/UptimeBucketDto"}},"source":{"type":"string","description":"Data source: vendor_reported, incident_derived, or poll_derived","nullable":true,"example":"vendor_reported"}},"description":"Uptime response with per-bucket breakdown and overall percentage for the period"},"SingleValueResponseServiceUptimeResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ServiceUptimeResponse"}}},"UptimeBucketDto":{"type":"object","properties":{"timestamp":{"type":"string","description":"Start of the bucket interval (ISO 8601)","format":"date-time","example":"2024-01-01T00:00:00Z"},"uptimePct":{"type":"number","description":"Uptime percentage for this bucket; null when no polls occurred","format":"double","nullable":true,"example":100.0},"totalPolls":{"type":"integer","description":"Total number of polls recorded in this bucket","format":"int64","example":12}},"description":"Uptime statistics for a single time bucket"},"TableValueResultScheduledMaintenanceDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ScheduledMaintenanceDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultServiceIncidentDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ServiceIncidentDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"ServiceIncidentDetailDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"title":{"type":"string"},"status":{"type":"string"},"impact":{"type":"string","nullable":true},"startedAt":{"type":"string","format":"date-time","nullable":true},"resolvedAt":{"type":"string","format":"date-time","nullable":true},"detectedAt":{"type":"string","format":"date-time","nullable":true},"shortlink":{"type":"string","nullable":true},"affectedComponents":{"type":"array","nullable":true,"items":{"type":"string","nullable":true}},"updates":{"type":"array","items":{"$ref":"#/components/schemas/ServiceIncidentUpdateDto"}}}},"ServiceIncidentUpdateDto":{"type":"object","properties":{"status":{"type":"string"},"body":{"type":"string","nullable":true},"displayAt":{"type":"string","format":"date-time","nullable":true}}},"SingleValueResponseServiceIncidentDetailDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ServiceIncidentDetailDto"}}},"TableValueResultServiceComponentDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ServiceComponentDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"ComponentUptimeDayDto":{"type":"object","properties":{"date":{"type":"string","description":"Date of the daily bucket (ISO 8601)","format":"date-time"},"partialOutageSeconds":{"type":"integer","description":"Seconds of partial outage observed on this day","format":"int32"},"majorOutageSeconds":{"type":"integer","description":"Seconds of major outage observed on this day","format":"int32"},"uptimePercentage":{"type":"number","description":"Computed uptime percentage for the day","format":"double"},"eventsJson":{"type":"string","description":"Incident event references for this day as raw JSON","nullable":true},"source":{"type":"string","description":"Data source: vendor_reported or incident_derived"}},"description":"Daily uptime data for a component"},"TableValueResultComponentUptimeDayDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ComponentUptimeDayDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"GlobalStatusSummaryDto":{"type":"object","properties":{"totalServices":{"type":"integer","description":"Total number of services in the catalog","format":"int32"},"operationalCount":{"type":"integer","description":"Number of services currently fully operational","format":"int32"},"degradedCount":{"type":"integer","description":"Number of services with degraded status","format":"int32"},"partialOutageCount":{"type":"integer","description":"Number of services with partial outage","format":"int32"},"majorOutageCount":{"type":"integer","description":"Number of services with major outage","format":"int32"},"maintenanceCount":{"type":"integer","description":"Number of services currently under maintenance","format":"int32"},"activeIncidentCount":{"type":"integer","description":"Total number of active incidents across all services","format":"int64"},"servicesWithIssues":{"type":"array","description":"Services that are not fully operational","items":{"$ref":"#/components/schemas/ServiceCatalogDto"}}},"description":"Global status summary across all subscribed vendor services"},"SingleValueResponseGlobalStatusSummaryDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/GlobalStatusSummaryDto"}}},"TableValueResultServiceSubscriptionDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ServiceSubscriptionDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultSecretDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/SecretDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultResourceGroupDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ResourceGroupDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"SingleValueResponseResourceGroupHealthDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ResourceGroupHealthDto"}}},"NotificationDto":{"type":"object","properties":{"id":{"type":"integer","description":"Unique notification identifier","format":"int64"},"type":{"type":"string","description":"Notification category (e.g. incident, monitor, team)"},"title":{"type":"string","description":"Short notification title"},"body":{"type":"string","description":"Full notification body; null for title-only notifications","nullable":true},"resourceType":{"type":"string","description":"Type of the resource this notification is about","nullable":true},"resourceId":{"type":"string","description":"ID of the resource this notification is about","nullable":true},"read":{"type":"boolean","description":"Whether the notification has been read"},"createdAt":{"type":"string","description":"Timestamp when the notification was created","format":"date-time"}},"description":"In-app notification for the current user"},"TableValueResultNotificationDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/NotificationDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"SingleValueResponseLong":{"type":"object","properties":{"data":{"type":"integer","format":"int64","nullable":true}}},"TableValueResultNotificationPolicyDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/NotificationPolicyDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultNotificationDispatchDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/NotificationDispatchDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultMonitorDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MonitorDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"MonitorVersionDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique version record identifier","format":"uuid"},"monitorId":{"type":"string","description":"Monitor this version belongs to","format":"uuid"},"version":{"type":"integer","description":"Monotonically increasing version number","format":"int32"},"snapshot":{"$ref":"#/components/schemas/MonitorDto"},"changedById":{"type":"integer","description":"User ID who made the change; null for automated changes","format":"int32","nullable":true},"changedVia":{"type":"string","description":"Change source (DASHBOARD, CLI, API)","enum":["API","DASHBOARD","CLI","TERRAFORM"]},"changeSummary":{"type":"string","description":"Human-readable description of what changed","nullable":true},"createdAt":{"type":"string","description":"Timestamp when this version was recorded","format":"date-time"}},"description":"A point-in-time version snapshot of a monitor configuration"},"TableValueResultMonitorVersionDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MonitorVersionDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"SingleValueResponseMonitorVersionDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/MonitorVersionDto"}}},"UptimeDto":{"type":"object","properties":{"uptimePercentage":{"type":"number","description":"Uptime percentage over the requested window; null when no data","format":"double","nullable":true,"example":99.95},"totalChecks":{"type":"integer","description":"Total number of checks executed","format":"int64","example":1440},"passedChecks":{"type":"integer","description":"Number of checks that passed","format":"int64","example":1439},"avgLatencyMs":{"type":"number","description":"Weighted average latency in milliseconds; null when no data","format":"double","nullable":true,"example":142.5},"p95LatencyMs":{"type":"number","description":"95th-percentile latency in milliseconds (upper bound across regions); null when no data","format":"double","nullable":true,"example":312.0}},"description":"Uptime statistics aggregated from continuous aggregates"},"SingleValueResponseUptimeDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/UptimeDto"}}},"CursorPage":{"type":"object","properties":{"data":{"type":"array","description":"Items on this page","items":{"type":"object","description":"Items on this page"}},"nextCursor":{"type":"string","description":"Opaque cursor for the next page; null when there are no more results","nullable":true},"hasMore":{"type":"boolean","description":"Whether more results exist beyond this page"}},"description":"Cursor-paginated response for time-series and append-only data"},"AssertionResultDto":{"type":"object","properties":{"type":{"type":"string","description":"Assertion type","example":"status_code"},"passed":{"type":"boolean","description":"Whether the assertion passed"},"severity":{"type":"string","description":"Assertion severity","enum":["fail","warn"]},"message":{"type":"string","description":"Human-readable result message","nullable":true},"expected":{"type":"string","description":"Expected value","nullable":true,"example":"200"},"actual":{"type":"string","description":"Actual value observed","nullable":true,"example":"503"}},"description":"Result of evaluating a single assertion against a check result"},"CheckResultDetailsDto":{"type":"object","properties":{"statusCode":{"type":"integer","description":"HTTP status code of the response","format":"int32","nullable":true,"example":200},"responseHeaders":{"type":"object","additionalProperties":{"type":"array","description":"HTTP response headers","nullable":true,"items":{"type":"string","description":"HTTP response headers","nullable":true}},"description":"HTTP response headers","nullable":true},"responseBodySnapshot":{"type":"string","description":"Raw response body snapshot (may be HTML, XML, JSON, or plain text)","nullable":true},"assertionResults":{"type":"array","description":"Individual assertion evaluation results","nullable":true,"items":{"$ref":"#/components/schemas/AssertionResultDto"}},"tlsInfo":{"$ref":"#/components/schemas/TlsInfoDto"},"redirectCount":{"type":"integer","description":"Number of HTTP redirects followed","format":"int32","nullable":true,"example":2},"redirectTarget":{"type":"string","description":"Final URL after redirects","nullable":true},"responseSizeBytes":{"type":"integer","description":"Response body size in bytes","format":"int32","nullable":true,"example":4096},"checkDetails":{"oneOf":[{"$ref":"#/components/schemas/Dns"},{"$ref":"#/components/schemas/Http"},{"$ref":"#/components/schemas/Icmp"},{"$ref":"#/components/schemas/McpServer"},{"$ref":"#/components/schemas/Tcp"}]}},"description":"Type-specific details captured during a check execution"},"CheckResultDto":{"type":"object","properties":{"id":{"type":"string","description":"Unique identifier of the check result","format":"uuid"},"timestamp":{"type":"string","description":"Timestamp when the check was executed (ISO 8601)","format":"date-time"},"region":{"type":"string","description":"Region where the check was executed","example":"us-east"},"responseTimeMs":{"type":"integer","description":"Response time in milliseconds","format":"int32","nullable":true,"example":123},"passed":{"type":"boolean","description":"Whether the check passed","example":true},"failureReason":{"type":"string","description":"Reason for failure when passed=false","nullable":true},"severityHint":{"type":"string","description":"Severity hint: 'down' for hard failures, 'degraded' for warn-only failures, null when passing","nullable":true},"details":{"$ref":"#/components/schemas/CheckResultDetailsDto"}},"description":"A single check result from a monitor run"},"CheckTypeDetailsDto":{"required":["check_type"],"type":"object","properties":{"check_type":{"type":"string"}},"description":"Check-type-specific details — polymorphic by check_type discriminator","discriminator":{"propertyName":"check_type"}},"CursorPageCheckResultDto":{"type":"object","properties":{"data":{"type":"array","description":"Items on this page","items":{"$ref":"#/components/schemas/CheckResultDto"}},"nextCursor":{"type":"string","description":"Opaque cursor for the next page; null when there are no more results","nullable":true},"hasMore":{"type":"boolean","description":"Whether more results exist beyond this page"}},"description":"Cursor-paginated response for time-series and append-only data"},"Dns":{"type":"object","description":"DNS check-type-specific details","allOf":[{"$ref":"#/components/schemas/CheckTypeDetailsDto"},{"type":"object","properties":{"hostname":{"type":"string","description":"Target hostname","nullable":true},"requestedTypes":{"type":"array","description":"Requested DNS record types","nullable":true,"items":{"type":"string","description":"Requested DNS record types","nullable":true}},"usedResolver":{"type":"string","description":"Resolver used for lookup","nullable":true},"records":{"type":"object","additionalProperties":{"type":"array","description":"Resolved DNS records keyed by record type","nullable":true,"items":{"type":"object","additionalProperties":{"type":"object","description":"Resolved DNS records keyed by record type","nullable":true},"description":"Resolved DNS records keyed by record type","nullable":true}},"description":"Resolved DNS records keyed by record type","nullable":true},"attempts":{"type":"array","description":"DNS resolution attempts","nullable":true,"items":{"type":"object","additionalProperties":{"type":"object","description":"DNS resolution attempts","nullable":true},"description":"DNS resolution attempts","nullable":true}},"failureKind":{"type":"string","description":"Kind of DNS failure, if any","nullable":true}}}]},"Http":{"type":"object","description":"HTTP check-type-specific details","allOf":[{"$ref":"#/components/schemas/CheckTypeDetailsDto"},{"type":"object","properties":{"timing":{"type":"object","additionalProperties":{"type":"object","description":"Request phase timing breakdown","nullable":true},"description":"Request phase timing breakdown","nullable":true},"bodyTruncated":{"type":"boolean","description":"Whether the response body was truncated before storage","nullable":true}}}]},"Icmp":{"type":"object","description":"ICMP (ping) check-type-specific details","allOf":[{"$ref":"#/components/schemas/CheckTypeDetailsDto"},{"type":"object","properties":{"host":{"type":"string","description":"Target host","example":"1.1.1.1"},"packetsSent":{"type":"integer","description":"Number of ICMP packets sent","format":"int32","nullable":true},"packetsReceived":{"type":"integer","description":"Number of ICMP packets received","format":"int32","nullable":true},"packetLoss":{"type":"number","description":"Packet loss percentage","format":"double","nullable":true,"example":0.0},"avgRttMs":{"type":"number","description":"Average round-trip time in ms","format":"double","nullable":true},"minRttMs":{"type":"number","description":"Minimum round-trip time in ms","format":"double","nullable":true},"maxRttMs":{"type":"number","description":"Maximum round-trip time in ms","format":"double","nullable":true},"jitterMs":{"type":"number","description":"Jitter in ms","format":"double","nullable":true}}}]},"McpServer":{"type":"object","description":"MCP server check-type-specific details","allOf":[{"$ref":"#/components/schemas/CheckTypeDetailsDto"},{"type":"object","properties":{"url":{"type":"string","description":"MCP server URL","nullable":true},"protocolVersion":{"type":"string","description":"MCP protocol version","nullable":true},"serverInfo":{"type":"object","additionalProperties":{"type":"object","description":"MCP server info (name, version, etc.)","nullable":true},"description":"MCP server info (name, version, etc.)","nullable":true},"toolCount":{"type":"integer","description":"Number of tools exposed","format":"int32","nullable":true},"resourceCount":{"type":"integer","description":"Number of resources exposed","format":"int32","nullable":true},"promptCount":{"type":"integer","description":"Number of prompts exposed","format":"int32","nullable":true}}}]},"Tcp":{"type":"object","description":"TCP check-type-specific details","allOf":[{"$ref":"#/components/schemas/CheckTypeDetailsDto"},{"type":"object","properties":{"host":{"type":"string","description":"Target host","example":"db.example.com"},"port":{"type":"integer","description":"Target port","format":"int32","example":5432},"connected":{"type":"boolean","description":"Whether a TCP connection was established"}}}]},"TlsInfoDto":{"type":"object","properties":{"subjectCn":{"type":"string","description":"Certificate subject common name","nullable":true,"example":"*.example.com"},"subjectSan":{"type":"array","description":"Subject Alternative Names","nullable":true,"items":{"type":"string","description":"Subject Alternative Names","nullable":true}},"issuerCn":{"type":"string","description":"Issuer common name","nullable":true,"example":"R3"},"issuerOrg":{"type":"string","description":"Issuer organisation","nullable":true,"example":"Let's Encrypt"},"notBefore":{"type":"string","description":"Certificate validity start (ISO 8601 UTC)","nullable":true},"notAfter":{"type":"string","description":"Certificate validity end (ISO 8601 UTC)","nullable":true},"serialNumber":{"type":"string","description":"Certificate serial number","nullable":true},"tlsVersion":{"type":"string","description":"TLS protocol version","nullable":true,"example":"TLSv1.3"},"cipherSuite":{"type":"string","description":"Negotiated cipher suite","nullable":true},"chainValid":{"type":"boolean","description":"Whether the chain validated against the OS trust store","nullable":true}},"description":"TLS/SSL certificate details for HTTPS targets"},"ChartBucketDto":{"type":"object","properties":{"bucket":{"type":"string","description":"Start of the time bucket (ISO 8601)","format":"date-time","example":"2026-03-12T10:00:00Z"},"uptimePercent":{"type":"number","description":"Uptime percentage for this bucket; null when no data","format":"double","nullable":true,"example":100.0},"avgLatencyMs":{"type":"number","description":"Weighted average latency in milliseconds for this bucket","format":"double","nullable":true,"example":120.3},"p95LatencyMs":{"type":"number","description":"95th percentile latency in milliseconds (max across regions)","format":"double","nullable":true,"example":250.0},"p99LatencyMs":{"type":"number","description":"99th percentile latency in milliseconds (max across regions)","format":"double","nullable":true,"example":480.0}},"description":"Aggregated metrics for a time bucket"},"RegionStatusDto":{"type":"object","properties":{"region":{"type":"string","description":"Region identifier","example":"us-east"},"passed":{"type":"boolean","description":"Whether the last check in this region passed","example":true},"responseTimeMs":{"type":"integer","description":"Response time in milliseconds for the last check","format":"int32","nullable":true,"example":95},"timestamp":{"type":"string","description":"Timestamp of the last check in this region (ISO 8601)","format":"date-time"},"severityHint":{"type":"string","description":"Severity hint: 'down' for hard failures, 'degraded' for warn-only failures, null when passing","nullable":true}},"description":"Latest check result for a single region"},"ResultSummaryDto":{"type":"object","properties":{"currentStatus":{"type":"string","description":"Derived current status across all regions","enum":["up","degraded","down","unknown"]},"latestPerRegion":{"type":"array","description":"Latest check result per region","items":{"$ref":"#/components/schemas/RegionStatusDto"}},"chartData":{"type":"array","description":"Time-bucketed chart data for the requested window","items":{"$ref":"#/components/schemas/ChartBucketDto"}},"uptime24h":{"type":"number","description":"Uptime percentage over the last 24 hours; null when no data","format":"double","nullable":true,"example":99.95},"uptimeWindow":{"type":"number","description":"Uptime percentage for the selected chart window; null when no data","format":"double","nullable":true,"example":99.8}},"description":"Dashboard summary: current status, per-region latest results, and chart data"},"SingleValueResponseResultSummaryDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/ResultSummaryDto"}}},"TableValueResultMaintenanceWindowDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/MaintenanceWindowDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultInviteDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/InviteDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"IntegrationCatalogResponse":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/IntegrationDto"}}}},"IntegrationConfigSchemaDto":{"type":"object","properties":{"connectionFields":{"type":"array","items":{"$ref":"#/components/schemas/IntegrationFieldDto"}},"channelFields":{"type":"array","items":{"$ref":"#/components/schemas/IntegrationFieldDto"}}}},"IntegrationDto":{"type":"object","properties":{"type":{"type":"string"},"name":{"type":"string"},"description":{"type":"string"},"logoUrl":{"type":"string"},"authType":{"type":"string"},"tierAvailability":{"type":"string","enum":["FREE","STARTER","PRO","TEAM","BUSINESS","ENTERPRISE"]},"lifecycle":{"type":"string"},"setupGuideUrl":{"type":"string"},"configSchema":{"$ref":"#/components/schemas/IntegrationConfigSchemaDto"}}},"IntegrationFieldDto":{"required":["key","label","required","sensitive","type"],"type":"object","properties":{"key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string"},"required":{"type":"boolean"},"sensitive":{"type":"boolean"},"placeholder":{"type":"string","nullable":true},"helpText":{"type":"string","nullable":true},"options":{"type":"array","nullable":true,"items":{"type":"string","nullable":true}},"default":{"type":"string","nullable":true}}},"IncidentFilterParams":{"type":"object","properties":{"status":{"type":"string","enum":["WATCHING","TRIGGERED","CONFIRMED","RESOLVED"]},"severity":{"type":"string","enum":["DOWN","DEGRADED","MAINTENANCE"]},"source":{"type":"string","enum":["AUTOMATIC","MANUAL","MONITORS","STATUS_DATA","RESOURCE_GROUP"]},"monitorId":{"type":"string","format":"uuid"},"serviceId":{"type":"string","format":"uuid"},"resourceGroupId":{"type":"string","format":"uuid"},"tagId":{"type":"string","format":"uuid","nullable":true},"environmentId":{"type":"string","format":"uuid","nullable":true},"startedFrom":{"type":"string","format":"date-time","nullable":true},"startedTo":{"type":"string","format":"date-time","nullable":true},"page":{"minimum":0,"type":"integer","format":"int32"},"size":{"maximum":200,"minimum":1,"type":"integer","format":"int32"}}},"TableValueResultEnvironmentDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/EnvironmentDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"DashboardOverviewDto":{"type":"object","properties":{"monitors":{"$ref":"#/components/schemas/MonitorsSummaryDto"},"incidents":{"$ref":"#/components/schemas/IncidentsSummaryDto"}},"description":"Combined dashboard overview for monitors and incidents"},"IncidentsSummaryDto":{"type":"object","properties":{"active":{"type":"integer","format":"int64"},"resolvedToday":{"type":"integer","format":"int64"},"mttr30d":{"type":"number","format":"double","nullable":true}},"description":"Incident summary counters"},"MonitorsSummaryDto":{"type":"object","properties":{"total":{"type":"integer","description":"Total number of monitors in the organization","format":"int64"},"up":{"type":"integer","description":"Number of monitors currently passing","format":"int64"},"down":{"type":"integer","description":"Number of monitors currently failing (DOWN severity)","format":"int64"},"degraded":{"type":"integer","description":"Number of monitors with degraded status","format":"int64"},"paused":{"type":"integer","description":"Number of disabled monitors","format":"int64"},"avgUptime24h":{"type":"number","description":"Average uptime percentage across all monitors over last 24h","format":"double","nullable":true},"avgUptime30d":{"type":"number","description":"Average uptime percentage across all monitors over last 30 days","format":"double","nullable":true}},"description":"Dashboard summary counters for monitors"},"SingleValueResponseDashboardOverviewDto":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/DashboardOverviewDto"}}},"CategoryDto":{"type":"object","properties":{"category":{"type":"string","description":"Category name (e.g. CI/CD, Cloud, Payments)"},"serviceCount":{"type":"integer","description":"Number of services in this category","format":"int64"}},"description":"Service category with its count of catalog entries"},"TableValueResultCategoryDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/CategoryDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"AuthMeResponse":{"type":"object","properties":{"key":{"$ref":"#/components/schemas/KeyInfo"},"organization":{"$ref":"#/components/schemas/OrgInfo"},"plan":{"$ref":"#/components/schemas/PlanInfo"},"rateLimits":{"$ref":"#/components/schemas/RateLimitInfo"}},"description":"Identity, organization, plan, and rate-limit info for the authenticated API key"},"KeyInfo":{"type":"object","properties":{"id":{"type":"integer","description":"Key ID","format":"int32"},"name":{"type":"string","description":"Human-readable key name"},"createdAt":{"type":"string","description":"When the key was created","format":"date-time"},"expiresAt":{"type":"string","description":"When the key expires (null = never)","format":"date-time","nullable":true},"lastUsedAt":{"type":"string","description":"Last time the key was used","format":"date-time","nullable":true}},"description":"API key metadata"},"OrgInfo":{"type":"object","properties":{"id":{"type":"integer","description":"Organization ID","format":"int32"},"name":{"type":"string","description":"Organization name"}},"description":"Organization the key belongs to"},"PlanInfo":{"type":"object","properties":{"tier":{"type":"string","description":"Resolved plan tier","enum":["FREE","STARTER","PRO","TEAM","BUSINESS","ENTERPRISE"]},"subscriptionStatus":{"type":"string","description":"Subscription status (null if no subscription)","nullable":true},"trialActive":{"type":"boolean","description":"Whether the org is on a trial"},"trialExpiresAt":{"type":"string","description":"Trial expiry (null if not trialing)","format":"date-time","nullable":true},"entitlements":{"type":"object","additionalProperties":{"$ref":"#/components/schemas/EntitlementDto"},"description":"Entitlement limits keyed by entitlement name"},"usage":{"type":"object","additionalProperties":{"type":"integer","description":"Current usage counters keyed by entitlement name","format":"int64"},"description":"Current usage counters keyed by entitlement name"}},"description":"Billing plan and entitlement state"},"RateLimitInfo":{"type":"object","properties":{"requestsPerMinute":{"type":"integer","description":"Maximum requests allowed per window","format":"int64"},"remaining":{"type":"integer","description":"Requests remaining in the current window","format":"int64"},"windowMs":{"type":"integer","description":"Sliding window size in milliseconds","format":"int64"}},"description":"Rate-limit quota for the current sliding window"},"SingleValueResponseAuthMeResponse":{"type":"object","properties":{"data":{"$ref":"#/components/schemas/AuthMeResponse"}}},"AuditEventDto":{"type":"object","properties":{"id":{"type":"integer","description":"Unique audit event identifier","format":"int64"},"actorId":{"type":"integer","description":"User ID who performed the action; null for system actions","format":"int32","nullable":true},"actorEmail":{"type":"string","description":"Email of the actor; null for system actions","nullable":true},"action":{"type":"string","description":"Audit action type (e.g. monitor.created, api_key.revoked)"},"resourceType":{"type":"string","description":"Type of resource affected (e.g. monitor, api_key)","nullable":true},"resourceId":{"type":"string","description":"ID of the affected resource","nullable":true},"resourceName":{"type":"string","description":"Human-readable name of the affected resource","nullable":true},"metadata":{"type":"object","additionalProperties":{"type":"object","description":"Additional context about the action","nullable":true},"description":"Additional context about the action","nullable":true},"createdAt":{"type":"string","description":"Timestamp when the action was performed","format":"date-time"}}},"PageResultAuditEventDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AuditEventDto"}},"page":{"type":"integer","format":"int32"},"size":{"type":"integer","format":"int32"},"totalElements":{"type":"integer","format":"int64"},"totalPages":{"type":"integer","format":"int32"},"hasNext":{"type":"boolean"}}},"TableValueResultApiKeyDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/ApiKeyDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"DeliveryAttemptDto":{"type":"object","properties":{"id":{"type":"string","format":"uuid"},"deliveryId":{"type":"string","format":"uuid"},"attemptNumber":{"type":"integer","description":"1-based attempt number","format":"int32"},"status":{"type":"string","description":"Outcome: SUCCESS, FAILED, TIMEOUT, ERROR"},"responseStatusCode":{"type":"integer","description":"HTTP response status code from the external service","format":"int32","nullable":true},"requestPayload":{"type":"string","description":"JSON payload sent to the external service","nullable":true},"responseBody":{"type":"string","description":"Response body from the external service (truncated)","nullable":true},"errorMessage":{"type":"string","description":"Error message if the attempt failed","nullable":true},"responseTimeMs":{"type":"integer","description":"Round-trip time in milliseconds","format":"int32","nullable":true},"externalId":{"type":"string","description":"External identifier (e.g. PagerDuty dedup_key, SES MessageId, webhook delivery UUID)","nullable":true},"requestHeaders":{"type":"object","additionalProperties":{"type":"string","description":"HTTP request headers sent to the external service","nullable":true},"description":"HTTP request headers sent to the external service","nullable":true},"attemptedAt":{"type":"string","format":"date-time"}},"description":"Single delivery attempt with request/response audit data"},"TableValueResultDeliveryAttemptDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/DeliveryAttemptDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultAlertChannelDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AlertChannelDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"TableValueResultAlertDeliveryDto":{"type":"object","properties":{"data":{"type":"array","items":{"$ref":"#/components/schemas/AlertDeliveryDto"}},"hasNext":{"type":"boolean"},"hasPrev":{"type":"boolean"}}},"RemoveMonitorTagsRequest":{"required":["tagIds"],"type":"object","properties":{"tagIds":{"minItems":1,"type":"array","description":"IDs of the tags to detach from the monitor","items":{"type":"string","description":"IDs of the tags to detach from the monitor","format":"uuid"}}},"description":"Request body for removing tags from a monitor"},"DeleteChannelResult":{"type":"object","properties":{"affectedPolicies":{"type":"integer","description":"Number of notification policies whose escalation steps were modified","format":"int32"},"disabledPolicies":{"type":"integer","description":"Number of notification policies disabled because they had no remaining channels","format":"int32"}},"description":"Summary of policies affected by channel deletion"}},"securitySchemes":{"BearerAuth":{"type":"http","description":"API key (dh_live_...) or Auth0 JWT token","scheme":"bearer","bearerFormat":"JWT"}}}} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ebb6eae..f021bb3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@oclif/plugin-not-found": "^3.2.80", "chalk": "^5.6.2", "cli-table3": "^0.6.5", + "lodash-es": "^4.18.1", "openapi-fetch": "^0.17.0", "yaml": "^2.8.3" }, @@ -21,6 +22,7 @@ "devhelm": "bin/run.js" }, "devDependencies": { + "@types/lodash-es": "^4.17.12", "@types/node": "^25.5.2", "@typescript-eslint/eslint-plugin": "^8.58.1", "@typescript-eslint/parser": "^8.58.1", @@ -2911,6 +2913,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/lodash-es": { + "version": "4.17.12", + "resolved": "https://registry.npmjs.org/@types/lodash-es/-/lodash-es-4.17.12.tgz", + "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/lodash": "*" + } + }, "node_modules/@types/mute-stream": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/@types/mute-stream/-/mute-stream-0.0.4.tgz", @@ -5075,6 +5094,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, "node_modules/lower-case": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", diff --git a/package.json b/package.json index 1225a44..68c38a6 100644 --- a/package.json +++ b/package.json @@ -63,10 +63,12 @@ "@oclif/plugin-not-found": "^3.2.80", "chalk": "^5.6.2", "cli-table3": "^0.6.5", + "lodash-es": "^4.18.1", "openapi-fetch": "^0.17.0", "yaml": "^2.8.3" }, "devDependencies": { + "@types/lodash-es": "^4.17.12", "@types/node": "^25.5.2", "@typescript-eslint/eslint-plugin": "^8.58.1", "@typescript-eslint/parser": "^8.58.1", diff --git a/scripts/extract-descriptions.mjs b/scripts/extract-descriptions.mjs index 7b63448..2a03fee 100644 --- a/scripts/extract-descriptions.mjs +++ b/scripts/extract-descriptions.mjs @@ -16,7 +16,8 @@ const OUT_PATH = resolve(__dirname, '../src/lib/descriptions.generated.ts'); const spec = JSON.parse(readFileSync(SPEC_PATH, 'utf8')); const schemas = spec.components?.schemas ?? {}; -// Schemas we care about for CLI flag descriptions +// Schemas we care about for CLI flag descriptions. +// Keep in sync with MUST_HAVE in tests/spec/test_openapi_descriptions.py (monorepo). const TARGET_SCHEMAS = [ 'CreateMonitorRequest', 'UpdateMonitorRequest', 'CreateManualIncidentRequest', @@ -29,6 +30,7 @@ const TARGET_SCHEMAS = [ 'CreateWebhookEndpointRequest', 'UpdateWebhookEndpointRequest', 'CreateApiKeyRequest', 'UpdateApiKeyRequest', 'ResolveIncidentRequest', 'MonitorTestRequest', + 'AcquireDeployLockRequest', 'HttpMonitorConfig', 'TcpMonitorConfig', 'DnsMonitorConfig', 'IcmpMonitorConfig', 'HeartbeatMonitorConfig', 'McpServerMonitorConfig', 'SlackChannelConfig', 'DiscordChannelConfig', 'EmailChannelConfig', diff --git a/src/commands/alert-channels/test.ts b/src/commands/alert-channels/test.ts index b80b171..5d6d460 100644 --- a/src/commands/alert-channels/test.ts +++ b/src/commands/alert-channels/test.ts @@ -11,10 +11,7 @@ export default class AlertChannelsTest extends Command { async run() { const {args, flags} = await this.parse(AlertChannelsTest) const client = buildClient(flags) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.POST(`/api/v1/alert-channels/${args.id}/test` as any, {} as any)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = (resp as any)?.data ?? resp - this.log(result.success ? 'Test notification sent successfully.' : 'Test notification failed.') + const resp = await checkedFetch(client.POST('/api/v1/alert-channels/{id}/test', {params: {path: {id: args.id}}})) + this.log(resp.data?.success ? 'Test notification sent successfully.' : 'Test notification failed.') } } diff --git a/src/commands/api-keys/revoke.ts b/src/commands/api-keys/revoke.ts index 1e91b82..13d29b9 100644 --- a/src/commands/api-keys/revoke.ts +++ b/src/commands/api-keys/revoke.ts @@ -11,8 +11,7 @@ export default class ApiKeysRevoke extends Command { async run() { const {args, flags} = await this.parse(ApiKeysRevoke) const client = buildClient(flags) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await checkedFetch(client.POST(`/api/v1/api-keys/${args.id}/revoke` as any, {} as any)) + await checkedFetch(client.POST('/api/v1/api-keys/{id}/revoke', {params: {path: {id: Number(args.id)}}})) this.log(`API key '${args.id}' revoked.`) } } diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index b979b6f..f6f0cd1 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -1,6 +1,6 @@ import {Command, Flags} from '@oclif/core' import {globalFlags} from '../../lib/base-command.js' -import {createApiClient, checkedFetch} from '../../lib/api-client.js' +import {createApiClient, checkedFetch, apiGet} from '../../lib/api-client.js' import {saveContext, resolveApiUrl} from '../../lib/auth.js' import * as readline from 'node:readline' @@ -24,20 +24,16 @@ export default class AuthLogin extends Command { this.log('Validating token...') const client = createApiClient({baseUrl: apiUrl, token}) - // Try /api/v1/auth/me first (API key — returns rich identity info). - // Falls back to /api/v1/dashboard/overview for non-API-key tokens (dev tokens, JWTs). try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.GET('/api/v1/auth/me' as any, {} as any)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const me = (resp as any)?.data ?? resp + const resp = await checkedFetch(client.GET('/api/v1/auth/me')) + const me = resp.data saveContext({name: flags.name, apiUrl, token}, true) this.log('') this.log(` Authenticated successfully.`) - this.log(` Organization: ${me.organization?.name ?? 'unknown'} (ID: ${me.organization?.id ?? '?'})`) - this.log(` Key: ${me.key?.name ?? 'unknown'}`) - this.log(` Plan: ${me.plan?.tier ?? 'unknown'}`) + this.log(` Organization: ${me?.organization?.name ?? 'unknown'} (ID: ${me?.organization?.id ?? '?'})`) + this.log(` Key: ${me?.key?.name ?? 'unknown'}`) + this.log(` Plan: ${me?.plan?.tier ?? 'unknown'}`) this.log('') this.log(` Context '${flags.name}' saved to ~/.devhelm/contexts.json`) return @@ -46,8 +42,7 @@ export default class AuthLogin extends Command { } try { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await checkedFetch(client.GET('/api/v1/dashboard/overview' as any, {} as any)) + await apiGet(client, '/api/v1/dashboard/overview') saveContext({name: flags.name, apiUrl, token}, true) this.log('') this.log(` Authenticated successfully.`) diff --git a/src/commands/auth/me.ts b/src/commands/auth/me.ts index 120e30d..3c9c09e 100644 --- a/src/commands/auth/me.ts +++ b/src/commands/auth/me.ts @@ -11,10 +11,8 @@ export default class AuthMe extends Command { async run() { const {flags} = await this.parse(AuthMe) const client = buildClient(flags) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.GET('/api/v1/auth/me' as any, {} as any)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const me = (resp as any)?.data ?? resp + const resp = await checkedFetch(client.GET('/api/v1/auth/me')) + const me = resp.data const format = flags.output as OutputFormat if (format === 'json' || format === 'yaml') { @@ -22,10 +20,10 @@ export default class AuthMe extends Command { return } - const k = me.key ?? {} - const o = me.organization ?? {} - const p = me.plan ?? {} - const r = me.rateLimits ?? {} + const k = me?.key ?? {} + const o = me?.organization ?? {} + const p = me?.plan ?? {} + const r = me?.rateLimits ?? {} this.log('') this.log(' API Key') @@ -42,8 +40,8 @@ export default class AuthMe extends Command { this.log(' Rate Limits') this.log(` Limit: ${r.requestsPerMinute ?? '–'} req/min Remaining: ${r.remaining ?? '–'} Window: ${r.windowMs ? `${r.windowMs / 1000}s` : '–'}`) - const usage = p.usage as Record | undefined - const entitlements = p.entitlements as Record | undefined + const usage = p.usage + const entitlements = p.entitlements if (usage && entitlements) { this.log('') this.log(' Usage') diff --git a/src/commands/data/services/status.ts b/src/commands/data/services/status.ts index 0df5429..bcf4d4a 100644 --- a/src/commands/data/services/status.ts +++ b/src/commands/data/services/status.ts @@ -11,10 +11,7 @@ export default class DataServicesStatus extends Command { async run() { const {args, flags} = await this.parse(DataServicesStatus) const client = buildClient(flags) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.GET(`/api/v1/services/${args.slug}` as any, {} as any)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const service = (resp as any)?.data ?? resp - display(this, service, flags.output) + const resp = await checkedFetch(client.GET('/api/v1/services/{slugOrId}', {params: {path: {slugOrId: args.slug}}})) + display(this, resp.data ?? resp, flags.output) } } diff --git a/src/commands/data/services/uptime.ts b/src/commands/data/services/uptime.ts index 1236dd7..ed8010a 100644 --- a/src/commands/data/services/uptime.ts +++ b/src/commands/data/services/uptime.ts @@ -1,6 +1,6 @@ import {Command, Args, Flags} from '@oclif/core' import {globalFlags, buildClient, display} from '../../../lib/base-command.js' -import {checkedFetch} from '../../../lib/api-client.js' +import {apiGet} from '../../../lib/api-client.js' export default class DataServicesUptime extends Command { static description = 'Get uptime data for a service' @@ -18,12 +18,9 @@ export default class DataServicesUptime extends Command { async run() { const {args, flags} = await this.parse(DataServicesUptime) const client = buildClient(flags) - let path = `/api/v1/services/${args.slug}/uptime?period=${flags.period}` - if (flags.granularity) path += `&granularity=${flags.granularity}` - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.GET(path as any, {} as any)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const uptime = (resp as any)?.data ?? resp - display(this, uptime, flags.output) + const query: Record = {period: flags.period} + if (flags.granularity) query.granularity = flags.granularity + const resp = await apiGet<{data?: unknown}>(client, `/api/v1/services/${args.slug}/uptime`, {query}) + display(this, resp.data ?? resp, flags.output) } } diff --git a/src/commands/dependencies/track.ts b/src/commands/dependencies/track.ts index 0887f34..fd520f5 100644 --- a/src/commands/dependencies/track.ts +++ b/src/commands/dependencies/track.ts @@ -11,10 +11,7 @@ export default class DependenciesTrack extends Command { async run() { const {args, flags} = await this.parse(DependenciesTrack) const client = buildClient(flags) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.POST(`/api/v1/service-subscriptions/${args.slug}` as any, {} as any)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const sub = (resp as any)?.data ?? resp - this.log(`Now tracking '${sub.serviceName}' as a dependency.`) + const resp = await checkedFetch(client.POST('/api/v1/service-subscriptions/{slug}', {params: {path: {slug: args.slug}}})) + this.log(`Now tracking '${resp.data?.name}' as a dependency.`) } } diff --git a/src/commands/deploy/force-unlock.ts b/src/commands/deploy/force-unlock.ts new file mode 100644 index 0000000..deb7696 --- /dev/null +++ b/src/commands/deploy/force-unlock.ts @@ -0,0 +1,63 @@ +import {Command, Flags} from '@oclif/core' +import {createApiClient, apiDelete} from '../../lib/api-client.js' +import {resolveToken, resolveApiUrl} from '../../lib/auth.js' + +export default class DeployForceUnlock extends Command { + static description = 'Force-release a stuck deploy lock on the current workspace' + + static examples = [ + '<%= config.bin %> deploy force-unlock', + '<%= config.bin %> deploy force-unlock --yes', + ] + + static flags = { + yes: Flags.boolean({ + char: 'y', + description: 'Skip confirmation prompt', + default: false, + }), + 'api-url': Flags.string({description: 'Override API base URL'}), + 'api-token': Flags.string({description: 'Override API token'}), + verbose: Flags.boolean({char: 'v', description: 'Show verbose output', default: false}), + } + + async run() { + const {flags} = await this.parse(DeployForceUnlock) + + if (!flags.yes) { + const {createInterface} = await import('node:readline') + const rl = createInterface({input: process.stdin, output: process.stdout}) + const answer = await new Promise((resolve) => { + rl.question('Force-unlock removes any active deploy lock. This is dangerous if another deploy is in progress.\nContinue? (yes/no): ', resolve) + }) + rl.close() + if (answer.toLowerCase() !== 'yes' && answer.toLowerCase() !== 'y') { + this.log('Cancelled.') + return + } + } + + const token = flags['api-token'] ?? resolveToken() + if (!token) { + this.error('No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.', {exit: 1}) + } + + const client = createApiClient({ + baseUrl: flags['api-url'] ?? resolveApiUrl(), + token, + verbose: flags.verbose, + }) + + try { + await apiDelete(client, '/api/v1/deploy/lock/force') + this.log('Deploy lock released.') + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (msg.includes('404') || msg.includes('Not Found')) { + this.log('No active deploy lock found.') + return + } + this.error(`Failed to release lock: ${msg}`, {exit: 1}) + } + } +} diff --git a/src/commands/deploy/index.ts b/src/commands/deploy/index.ts new file mode 100644 index 0000000..b443f89 --- /dev/null +++ b/src/commands/deploy/index.ts @@ -0,0 +1,262 @@ +import {hostname} from 'node:os' +import {Command, Flags} from '@oclif/core' +import {createApiClient, apiPost, apiDelete} from '../../lib/api-client.js' +import {resolveToken, resolveApiUrl} from '../../lib/auth.js' +import {EXIT_CODES} from '../../lib/errors.js' +import {loadConfig, validate, fetchAllRefs, diff, formatPlan, changesetToJson, apply, writeState, buildState} from '../../lib/yaml/index.js' +import {checkEntitlements, formatEntitlementWarnings} from '../../lib/yaml/entitlements.js' + +const DEFAULT_LOCK_TTL = 30 + +export default class Deploy extends Command { + static description = 'Deploy devhelm.yml configuration to the DevHelm API' + + static examples = [ + '<%= config.bin %> deploy', + '<%= config.bin %> deploy --yes', + '<%= config.bin %> deploy -f monitors.yml', + '<%= config.bin %> deploy --prune --yes', + '<%= config.bin %> deploy --prune-all --yes', + '<%= config.bin %> deploy --dry-run', + '<%= config.bin %> deploy --dry-run --detailed-exitcode', + '<%= config.bin %> deploy -o json --yes', + ] + + static flags = { + file: Flags.string({ + char: 'f', + description: 'Config file or directory (can be specified multiple times)', + multiple: true, + default: ['devhelm.yml'], + }), + yes: Flags.boolean({ + char: 'y', + description: 'Skip confirmation prompt (for CI)', + default: false, + }), + prune: Flags.boolean({ + description: 'Delete CLI-managed resources not present in config', + default: false, + }), + 'prune-all': Flags.boolean({ + description: 'Delete ALL resources not in config, including those not managed by the CLI (use with caution)', + default: false, + }), + 'dry-run': Flags.boolean({ + description: 'Show what would change without applying (same as "devhelm plan")', + default: false, + }), + 'detailed-exitcode': Flags.boolean({ + description: 'Return exit code 10 if dry-run has changes (for CI)', + default: false, + }), + output: Flags.string({ + char: 'o', + description: 'Output format (text or json)', + options: ['text', 'json'], + default: 'text', + }), + 'force-unlock': Flags.boolean({ + description: 'Force-break an existing deploy lock before acquiring', + default: false, + }), + 'no-lock': Flags.boolean({ + description: 'Skip deploy locking (not recommended for team use)', + default: false, + }), + 'lock-timeout': Flags.integer({ + description: 'Seconds to wait for a conflicting lock to release (0 = fail immediately)', + default: 0, + }), + 'api-url': Flags.string({description: 'Override API base URL'}), + 'api-token': Flags.string({description: 'Override API token'}), + verbose: Flags.boolean({char: 'v', description: 'Show verbose output', default: false}), + } + + async run() { + const {flags} = await this.parse(Deploy) + const isJson = flags.output === 'json' + + let config + try { + config = loadConfig(flags.file) + } catch (err) { + this.error((err as Error).message, {exit: 1}) + } + + const result = validate(config) + if (result.errors.length > 0) { + this.log(`\nValidation failed: ${result.errors.length} error(s)\n`) + for (const e of result.errors) { + this.log(` ✗ ${e.path}: ${e.message}`) + } + this.error('Fix validation errors before deploying', {exit: 4}) + } + + const token = flags['api-token'] ?? resolveToken() + if (!token) { + this.error('No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.', {exit: 1}) + } + + const client = createApiClient({ + baseUrl: flags['api-url'] ?? resolveApiUrl(), + token, + verbose: flags.verbose, + }) + + if (!isJson) this.log('Fetching current state from API...') + const refs = await fetchAllRefs(client) + + const changeset = diff(config, refs, {prune: flags.prune || flags['prune-all'], pruneAll: flags['prune-all']}) + + const entitlementCheck = await checkEntitlements(client, changeset) + + if (isJson && flags['dry-run']) { + this.log(JSON.stringify(changesetToJson(changeset), null, 2)) + const totalChanges = changeset.creates.length + changeset.updates.length + changeset.deletes.length + changeset.memberships.length + if (totalChanges > 0 && flags['detailed-exitcode']) { + this.exit(EXIT_CODES.CHANGES_PENDING) + } + return + } + + if (!isJson) { + if (entitlementCheck) { + this.log(entitlementCheck.header) + } + + const plan = formatPlan(changeset) + this.log(`\n${plan}\n`) + + if (entitlementCheck && entitlementCheck.warnings.length > 0) { + this.log(formatEntitlementWarnings(entitlementCheck.warnings)) + this.log('') + } + } + + const totalChanges = changeset.creates.length + changeset.updates.length + changeset.deletes.length + changeset.memberships.length + if (totalChanges === 0) { + if (isJson) this.log(JSON.stringify({plan: changesetToJson(changeset), result: {succeeded: [], failed: []}}, null, 2)) + return + } + + if (flags['dry-run']) { + if (!isJson) this.log('Dry run — no changes applied.') + if (flags['detailed-exitcode']) { + this.exit(EXIT_CODES.CHANGES_PENDING) + } + return + } + + if (!flags.yes) { + const {createInterface} = await import('node:readline') + const rl = createInterface({input: process.stdin, output: process.stdout}) + const answer = await new Promise((resolve) => { + rl.question('Apply these changes? (yes/no): ', resolve) + }) + rl.close() + if (answer.toLowerCase() !== 'yes' && answer.toLowerCase() !== 'y') { + this.log('Cancelled.') + return + } + } + + let lockId: string | undefined + if (!flags['no-lock']) { + lockId = await this.acquireLock(client, flags['force-unlock'], flags['lock-timeout']) + } + + try { + if (!isJson) this.log('Applying changes...') + const applyResult = await apply(changeset, refs, client) + + if (isJson) { + this.log(JSON.stringify({ + plan: changesetToJson(changeset), + result: {succeeded: applyResult.succeeded, failed: applyResult.failed}, + }, null, 2)) + } else { + for (const s of applyResult.succeeded) { + const icon = s.action === 'delete' ? '-' : s.action === 'update' ? '~' : '+' + this.log(` ${icon} ${s.resourceType} "${s.refKey}" — ${s.action}d`) + } + + if (applyResult.failed.length > 0) { + this.log('') + for (const f of applyResult.failed) { + this.log(` ✗ ${f.resourceType} "${f.refKey}" — ${f.action} failed: ${f.error}`) + } + } + + this.log(`\nDone: ${applyResult.succeeded.length} succeeded, ${applyResult.failed.length} failed.`) + } + + writeState(buildState(applyResult.stateEntries)) + + if (applyResult.failed.length > 0) { + this.exit(EXIT_CODES.PARTIAL_FAILURE) + } + } finally { + if (lockId) { + await this.releaseLock(client, lockId) + } + } + } + + private async acquireLock( + client: ReturnType, + forceUnlock: boolean, + lockTimeout: number, + ): Promise { + if (forceUnlock) { + try { + await apiDelete(client, '/api/v1/deploy/lock/force') + } catch { + // Force-unlock is best-effort; the lock may not exist + } + } + + const deadline = Date.now() + lockTimeout * 1000 + let lastError: string | undefined + + while (true) { + try { + const resp = await apiPost<{data?: {id?: string}}>( + client, '/api/v1/deploy/lock', + {lockedBy: `${process.env.USER ?? 'cli'}@${hostname()}`, ttlMinutes: DEFAULT_LOCK_TTL}, + ) + const lockId = resp.data?.id + if (!lockId) { + this.warn('Deploy lock acquired but no lock ID returned. Proceeding without lock protection.') + } + return lockId + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + const isConflict = msg.includes('409') || msg.includes('Conflict') || msg.includes('lock held') + + if (isConflict && Date.now() < deadline) { + lastError = msg + const remaining = Math.ceil((deadline - Date.now()) / 1000) + this.log(`Lock held by another session. Retrying... (${remaining}s remaining)`) + await new Promise((r) => setTimeout(r, 5000)) + continue + } + + if (isConflict) { + this.warn(`Deploy lock conflict: ${lastError ?? msg}`) + this.warn('Use --force-unlock to break the existing lock, --lock-timeout to wait, or --no-lock to skip.') + this.exit(EXIT_CODES.API) + } + this.warn(`Failed to acquire deploy lock: ${msg}`) + this.warn('Use --no-lock to skip locking if the lock service is unavailable.') + this.exit(EXIT_CODES.API) + } + } + } + + private async releaseLock(client: ReturnType, lockId: string): Promise { + try { + await apiDelete(client, `/api/v1/deploy/lock/${lockId}`) + } catch { /* best-effort release */ } + } +} diff --git a/src/commands/incidents/delete.ts b/src/commands/incidents/delete.ts deleted file mode 100644 index f542288..0000000 --- a/src/commands/incidents/delete.ts +++ /dev/null @@ -1,4 +0,0 @@ -import {createDeleteCommand} from '../../lib/crud-commands.js' -import {INCIDENTS} from '../../lib/resources.js' - -export default createDeleteCommand(INCIDENTS) diff --git a/src/commands/incidents/resolve.ts b/src/commands/incidents/resolve.ts index 93b7360..84b9f23 100644 --- a/src/commands/incidents/resolve.ts +++ b/src/commands/incidents/resolve.ts @@ -15,12 +15,7 @@ export default class IncidentsResolve extends Command { const {args, flags} = await this.parse(IncidentsResolve) const client = buildClient(flags) const body = flags.message ? {message: flags.message} : undefined - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const opts = body ? {body: body as any} : {} - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.POST(`/api/v1/incidents/${args.id}/resolve` as any, opts as any)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const incident = (resp as any)?.data ?? resp - this.log(`Incident '${incident.title}' resolved.`) + const resp = await checkedFetch(client.POST('/api/v1/incidents/{id}/resolve', {params: {path: {id: args.id}}, body})) + this.log(`Incident '${resp.data?.incident?.title}' resolved.`) } } diff --git a/src/commands/incidents/update.ts b/src/commands/incidents/update.ts deleted file mode 100644 index e4e004e..0000000 --- a/src/commands/incidents/update.ts +++ /dev/null @@ -1,4 +0,0 @@ -import {createUpdateCommand} from '../../lib/crud-commands.js' -import {INCIDENTS} from '../../lib/resources.js' - -export default createUpdateCommand(INCIDENTS) diff --git a/src/commands/init.ts b/src/commands/init.ts index bc03cc9..2cbf94f 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -1,43 +1,112 @@ import {Command, Flags} from '@oclif/core' import {existsSync, writeFileSync} from 'node:fs' -const TEMPLATE = `# devhelm.yml — DevHelm monitor configuration +const TEMPLATE = `# devhelm.yml — DevHelm monitoring-as-code configuration # Docs: https://docs.devhelm.io/cli/configuration +# Run "devhelm validate" to check, "devhelm deploy" to apply. +version: "1" + +# defaults: +# monitors: +# frequency: 60 +# regions: [us-east, eu-west] +# enabled: true + +tags: + - name: production + color: "#EF4444" + +# environments: +# - name: Production +# slug: production +# isDefault: true + +# secrets: +# - key: bearer-token +# value: \${API_TOKEN} + +alertChannels: + - name: ops-slack + type: slack + config: + webhookUrl: \${SLACK_WEBHOOK_URL:-https://hooks.slack.com/services/REPLACE_ME} + +# notificationPolicies: +# - name: critical-escalation +# enabled: true +# priority: 1 +# escalation: +# steps: +# - channels: [ops-slack] +# delayMinutes: 0 + +# webhooks: +# - url: https://hooks.example.com/devhelm +# events: [monitor.down, monitor.recovered] + +# resourceGroups: +# - name: API Services +# monitors: [API Health Check] monitors: - name: Website Health Check type: HTTP - url: https://example.com - interval: 60 - regions: - - us-east-1 - - eu-west-1 + config: + url: https://example.com + method: GET + verifyTls: true + frequency: 60 + regions: [us-east, eu-west] + tags: [production] + alertChannels: [ops-slack] assertions: - - type: STATUS_CODE - operator: EQUALS - value: "200" - - type: RESPONSE_TIME - operator: LESS_THAN - value: "2000" - alertChannels: - - default-slack - - # - name: API Endpoint + - type: StatusCodeAssertion + config: + expected: "200" + operator: equals + severity: fail + - type: ResponseTimeAssertion + config: + thresholdMs: 2000 + severity: warn + - type: SslExpiryAssertion + config: + minDaysRemaining: 30 + severity: warn + + # - name: API Health Check # type: HTTP - # url: https://api.example.com/health - # method: GET - # interval: 30 - # timeout: 10000 + # config: + # url: https://api.example.com/health + # method: GET + # frequency: 30 # - name: DNS Check # type: DNS - # url: example.com - # interval: 300 + # config: + # hostname: example.com + # recordTypes: [A, AAAA, MX] + # frequency: 300 + # assertions: + # - type: DnsResolvesAssertion + # severity: fail - # - name: Heartbeat + # - name: Heartbeat Worker # type: HEARTBEAT - # interval: 120 - # grace: 300 + # config: + # expectedInterval: 120 + # gracePeriod: 300 + + # - name: MCP Assistant + # type: MCP_SERVER + # config: + # command: npx + # args: ["-y", "@company/mcp-server"] + # frequency: 300 + +# dependencies: +# - service: github +# alertSensitivity: INCIDENTS_ONLY ` export default class Init extends Command { @@ -60,8 +129,13 @@ export default class Init extends Command { this.error(`${flags.path} already exists. Use --force to overwrite.`, {exit: 1}) } - writeFileSync(flags.path, TEMPLATE) + try { + writeFileSync(flags.path, TEMPLATE) + } catch (err) { + this.error(`Failed to write ${flags.path}: ${err instanceof Error ? err.message : String(err)}`, {exit: 1}) + } this.log(`Created ${flags.path}`) - this.log('Edit the file, then run `devhelm validate` to check it.') + this.log('Edit the file, then run "devhelm validate" to check it.') + this.log('When ready, run "devhelm deploy" to apply it to your DevHelm account.') } } diff --git a/src/commands/monitors/pause.ts b/src/commands/monitors/pause.ts index f0cb71f..e54677b 100644 --- a/src/commands/monitors/pause.ts +++ b/src/commands/monitors/pause.ts @@ -11,10 +11,7 @@ export default class MonitorsPause extends Command { async run() { const {args, flags} = await this.parse(MonitorsPause) const client = buildClient(flags) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.POST(`/api/v1/monitors/${args.id}/pause` as any, {} as any)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const monitor = (resp as any)?.data ?? resp - this.log(`Monitor '${monitor.name}' paused.`) + const resp = await checkedFetch(client.POST('/api/v1/monitors/{id}/pause', {params: {path: {id: args.id}}})) + this.log(`Monitor '${resp.data?.name}' paused.`) } } diff --git a/src/commands/monitors/results.ts b/src/commands/monitors/results.ts index b7e4c47..b4a169f 100644 --- a/src/commands/monitors/results.ts +++ b/src/commands/monitors/results.ts @@ -1,6 +1,9 @@ import {Command, Args, Flags} from '@oclif/core' import {globalFlags, buildClient, display} from '../../lib/base-command.js' -import {checkedFetch} from '../../lib/api-client.js' +import {apiGet} from '../../lib/api-client.js' +import type {components} from '../../lib/api.generated.js' + +type CheckResultDto = components['schemas']['CheckResultDto'] export default class MonitorsResults extends Command { static description = 'Show recent check results for a monitor' @@ -14,17 +17,18 @@ export default class MonitorsResults extends Command { async run() { const {args, flags} = await this.parse(MonitorsResults) const client = buildClient(flags) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.GET(`/api/v1/monitors/${args.id}/results?limit=${flags.limit}` as any, {} as any)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const items = (resp as any)?.data ?? resp - display(this, items, flags.output, [ - {header: 'ID', get: (r: Record) => String(r.id ?? '')}, - {header: 'STATUS', get: (r: Record) => String(r.status ?? '')}, - {header: 'RESPONSE TIME', get: (r: Record) => String(r.responseTime ?? '')}, - {header: 'CODE', get: (r: Record) => String(r.statusCode ?? '')}, - {header: 'REGION', get: (r: Record) => String(r.region ?? '')}, - {header: 'CHECKED AT', get: (r: Record) => String(r.checkedAt ?? '')}, + const resp = await apiGet<{data?: CheckResultDto[]}>( + client, + `/api/v1/monitors/${args.id}/results`, + {query: {limit: flags.limit}}, + ) + display(this, resp.data ?? [], flags.output, [ + {header: 'ID', get: (r) => String(r.id ?? '')}, + {header: 'PASSED', get: (r) => (r.passed == null ? '' : r.passed ? 'Pass' : 'Fail')}, + {header: 'RESPONSE TIME', get: (r) => (r.responseTimeMs != null ? `${r.responseTimeMs}ms` : '')}, + {header: 'CODE', get: (r) => String(r.details?.statusCode ?? '')}, + {header: 'REGION', get: (r) => String(r.region ?? '')}, + {header: 'TIMESTAMP', get: (r) => String(r.timestamp ?? '')}, ]) } } diff --git a/src/commands/monitors/resume.ts b/src/commands/monitors/resume.ts index 29d0636..c84bfa6 100644 --- a/src/commands/monitors/resume.ts +++ b/src/commands/monitors/resume.ts @@ -11,10 +11,7 @@ export default class MonitorsResume extends Command { async run() { const {args, flags} = await this.parse(MonitorsResume) const client = buildClient(flags) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.POST(`/api/v1/monitors/${args.id}/resume` as any, {} as any)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const monitor = (resp as any)?.data ?? resp - this.log(`Monitor '${monitor.name}' resumed.`) + const resp = await checkedFetch(client.POST('/api/v1/monitors/{id}/resume', {params: {path: {id: args.id}}})) + this.log(`Monitor '${resp.data?.name}' resumed.`) } } diff --git a/src/commands/monitors/test.ts b/src/commands/monitors/test.ts index c7ff541..2d37af1 100644 --- a/src/commands/monitors/test.ts +++ b/src/commands/monitors/test.ts @@ -12,10 +12,7 @@ export default class MonitorsTest extends Command { const {args, flags} = await this.parse(MonitorsTest) const client = buildClient(flags) this.log('Running test...') - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.POST(`/api/v1/monitors/${args.id}/test` as any, {} as any)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = (resp as any)?.data ?? resp - display(this, result, flags.output) + const resp = await checkedFetch(client.POST('/api/v1/monitors/{id}/test', {params: {path: {id: args.id}}})) + display(this, resp.data ?? resp, flags.output) } } diff --git a/src/commands/monitors/versions/get.ts b/src/commands/monitors/versions/get.ts new file mode 100644 index 0000000..dbe7b11 --- /dev/null +++ b/src/commands/monitors/versions/get.ts @@ -0,0 +1,31 @@ +import {Command, Args} from '@oclif/core' +import {globalFlags, buildClient, display} from '../../../lib/base-command.js' +import {apiGet} from '../../../lib/api-client.js' +import type {components} from '../../../lib/api.generated.js' + +type MonitorVersionDto = components['schemas']['MonitorVersionDto'] + +export default class MonitorsVersionsGet extends Command { + static description = 'Get a specific version snapshot for a monitor' + static examples = [ + '<%= config.bin %> monitors versions get 42 3', + '<%= config.bin %> monitors versions get 42 3 -o json', + ] + + static args = { + id: Args.string({description: 'Monitor ID', required: true}), + version: Args.integer({description: 'Version number', required: true}), + } + + static flags = {...globalFlags} + + async run() { + const {args, flags} = await this.parse(MonitorsVersionsGet) + const client = buildClient(flags) + const resp = await apiGet<{data?: MonitorVersionDto}>( + client, + `/api/v1/monitors/${args.id}/versions/${args.version}`, + ) + display(this, resp.data ?? resp, flags.output) + } +} diff --git a/src/commands/monitors/versions/list.ts b/src/commands/monitors/versions/list.ts new file mode 100644 index 0000000..afe4bad --- /dev/null +++ b/src/commands/monitors/versions/list.ts @@ -0,0 +1,38 @@ +import {Command, Args, Flags} from '@oclif/core' +import {globalFlags, buildClient, display} from '../../../lib/base-command.js' +import {apiGet} from '../../../lib/api-client.js' +import type {components} from '../../../lib/api.generated.js' + +type MonitorVersionDto = components['schemas']['MonitorVersionDto'] + +export default class MonitorsVersionsList extends Command { + static description = 'List version history for a monitor' + static examples = [ + '<%= config.bin %> monitors versions list 42', + '<%= config.bin %> monitors versions list 42 --limit 5', + '<%= config.bin %> monitors versions list 42 -o json', + ] + + static args = {id: Args.string({description: 'Monitor ID', required: true})} + static flags = { + ...globalFlags, + limit: Flags.integer({description: 'Number of versions to show', default: 20}), + } + + async run() { + const {args, flags} = await this.parse(MonitorsVersionsList) + const client = buildClient(flags) + const resp = await apiGet<{data?: MonitorVersionDto[]}>( + client, + `/api/v1/monitors/${args.id}/versions`, + {query: {size: flags.limit}}, + ) + display(this, resp.data ?? [], flags.output, [ + {header: 'VERSION', get: (r) => String(r.version ?? '')}, + {header: 'CHANGED VIA', get: (r) => String(r.changedVia ?? '')}, + {header: 'SUMMARY', get: (r) => r.changeSummary ?? ''}, + {header: 'CREATED AT', get: (r) => String(r.createdAt ?? '')}, + {header: 'ID', get: (r) => String(r.id ?? '')}, + ]) + } +} diff --git a/src/commands/notification-policies/test.ts b/src/commands/notification-policies/test.ts index 110f507..1ef25c6 100644 --- a/src/commands/notification-policies/test.ts +++ b/src/commands/notification-policies/test.ts @@ -11,8 +11,7 @@ export default class NotificationPoliciesTest extends Command { async run() { const {args, flags} = await this.parse(NotificationPoliciesTest) const client = buildClient(flags) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await checkedFetch(client.POST(`/api/v1/notification-policies/${args.id}/test` as any, {} as any)) + await checkedFetch(client.POST('/api/v1/notification-policies/{id}/test', {params: {path: {id: args.id}}, body: {}})) this.log('Test dispatch sent.') } } diff --git a/src/commands/plan.ts b/src/commands/plan.ts new file mode 100644 index 0000000..bb22566 --- /dev/null +++ b/src/commands/plan.ts @@ -0,0 +1,107 @@ +import {Command, Flags} from '@oclif/core' +import {createApiClient} from '../lib/api-client.js' +import {resolveToken, resolveApiUrl} from '../lib/auth.js' +import {EXIT_CODES} from '../lib/errors.js' +import {loadConfig, validate, fetchAllRefs, diff, formatPlan, changesetToJson} from '../lib/yaml/index.js' +import {checkEntitlements, formatEntitlementWarnings} from '../lib/yaml/entitlements.js' + +export default class Plan extends Command { + static description = 'Show what "devhelm deploy" would change without applying' + + static examples = [ + '<%= config.bin %> plan', + '<%= config.bin %> plan -f monitors.yml', + '<%= config.bin %> plan --prune', + '<%= config.bin %> plan --prune-all', + '<%= config.bin %> plan --detailed-exitcode', + '<%= config.bin %> plan -o json', + ] + + static flags = { + file: Flags.string({ + char: 'f', + description: 'Config file or directory (can be specified multiple times)', + multiple: true, + default: ['devhelm.yml'], + }), + prune: Flags.boolean({ + description: 'Include deletions of CLI-managed resources not in config', + default: false, + }), + 'prune-all': Flags.boolean({ + description: 'Include deletions of ALL resources not in config, including those not managed by the CLI', + default: false, + }), + 'detailed-exitcode': Flags.boolean({ + description: 'Return exit code 10 if plan has changes (for CI)', + default: false, + }), + output: Flags.string({ + char: 'o', + description: 'Output format (text or json)', + options: ['text', 'json'], + default: 'text', + }), + 'api-url': Flags.string({description: 'Override API base URL'}), + 'api-token': Flags.string({description: 'Override API token'}), + verbose: Flags.boolean({char: 'v', description: 'Show verbose output', default: false}), + } + + async run() { + const {flags} = await this.parse(Plan) + + let config + try { + config = loadConfig(flags.file) + } catch (err) { + this.error((err as Error).message, {exit: 1}) + } + + const result = validate(config) + if (result.errors.length > 0) { + this.log(`\nValidation failed: ${result.errors.length} error(s)\n`) + for (const e of result.errors) { + this.log(` ✗ ${e.path}: ${e.message}`) + } + this.error('Fix validation errors first', {exit: 4}) + } + + const token = flags['api-token'] ?? resolveToken() + if (!token) { + this.error('No API token configured. Run "devhelm auth login" or set DEVHELM_API_TOKEN.', {exit: 1}) + } + + const client = createApiClient({ + baseUrl: flags['api-url'] ?? resolveApiUrl(), + token, + verbose: flags.verbose, + }) + + this.log('Fetching current state from API...') + const refs = await fetchAllRefs(client) + + const changeset = diff(config, refs, {prune: flags.prune || flags['prune-all'], pruneAll: flags['prune-all']}) + + const entitlementCheck = await checkEntitlements(client, changeset) + + if (flags.output === 'json') { + this.log(JSON.stringify(changesetToJson(changeset), null, 2)) + } else { + if (entitlementCheck) { + this.log(entitlementCheck.header) + } + + this.log(`\n${formatPlan(changeset)}`) + + if (entitlementCheck && entitlementCheck.warnings.length > 0) { + this.log('') + this.log(formatEntitlementWarnings(entitlementCheck.warnings)) + } + } + + const total = changeset.creates.length + changeset.updates.length + changeset.deletes.length + changeset.memberships.length + if (total > 0 && flags['detailed-exitcode']) { + this.exit(EXIT_CODES.CHANGES_PENDING) + } + } +} diff --git a/src/commands/status.ts b/src/commands/status.ts index 36150be..f52dd7e 100644 --- a/src/commands/status.ts +++ b/src/commands/status.ts @@ -1,7 +1,10 @@ import {Command} from '@oclif/core' import {globalFlags, buildClient} from '../lib/base-command.js' -import {checkedFetch} from '../lib/api-client.js' +import {apiGet} from '../lib/api-client.js' import {formatOutput, OutputFormat} from '../lib/output.js' +import type {components} from '../lib/api.generated.js' + +type DashboardOverviewDto = components['schemas']['DashboardOverviewDto'] export default class Status extends Command { static description = 'Show dashboard overview' @@ -11,10 +14,8 @@ export default class Status extends Command { async run() { const {flags} = await this.parse(Status) const client = buildClient(flags) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.GET('/api/v1/dashboard/overview' as any, {} as any)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const overview = (resp as any)?.data ?? resp + const resp = await apiGet<{data?: DashboardOverviewDto}>(client, '/api/v1/dashboard/overview') + const overview = resp.data ?? {} const format = flags.output as OutputFormat if (format === 'json' || format === 'yaml') { @@ -32,7 +33,7 @@ export default class Status extends Command { this.log(` Uptime (24h): ${u24}% Uptime (30d): ${u30}%`) this.log('') this.log(' Incidents') - this.log(` Active: ${i.active ?? 0} Resolved today: ${i.resolvedToday ?? 0} MTTR (30d): ${i.mttr30d != null ? `${Math.round(i.mttr30d / 60)}m` : '–'}`) + this.log(` Active: ${i.active ?? 0} Resolved today: ${i.resolvedToday ?? 0} MTTR (30d): ${i.mttr30d != null ? `${Math.round(Number(i.mttr30d) / 60)}m` : '–'}`) this.log('') } } diff --git a/src/commands/validate.ts b/src/commands/validate.ts index 777ff06..f3a374d 100644 --- a/src/commands/validate.ts +++ b/src/commands/validate.ts @@ -1,85 +1,104 @@ -import {Command, Args} from '@oclif/core' -import {existsSync, readFileSync} from 'node:fs' -import {parse as parseYaml} from 'yaml' - -interface MonitorConfig { - name?: string - type?: string - url?: string - interval?: number -} - -interface DevhelmConfig { - monitors?: MonitorConfig[] -} - -const VALID_TYPES = new Set(['HTTP', 'DNS', 'TCP', 'ICMP', 'HEARTBEAT']) +import {Command, Args, Flags} from '@oclif/core' +import {parseConfigFile, validate} from '../lib/yaml/index.js' export default class Validate extends Command { - static description = 'Validate a devhelm.yml configuration file' + static description = 'Validate a devhelm.yml configuration file against the full schema' static examples = [ '<%= config.bin %> validate', '<%= config.bin %> validate devhelm.yml', + '<%= config.bin %> validate --strict', + '<%= config.bin %> validate -o json', ] static args = { file: Args.string({description: 'Config file path', default: 'devhelm.yml'}), } - async run() { - const {args} = await this.parse(Validate) - - if (!existsSync(args.file)) { - this.error(`File not found: ${args.file}`, {exit: 1}) - } + static flags = { + strict: Flags.boolean({ + description: 'Fail on warnings (unresolved cross-references, etc.)', + default: false, + }), + 'skip-env': Flags.boolean({ + description: 'Skip environment variable interpolation (syntax check only)', + default: false, + }), + output: Flags.string({ + char: 'o', + description: 'Output format (text or json)', + options: ['text', 'json'], + default: 'text', + }), + } - const raw = readFileSync(args.file, 'utf8') - let config: DevhelmConfig + async run() { + const {args, flags} = await this.parse(Validate) + const isJson = flags.output === 'json' + let config try { - config = parseYaml(raw) as DevhelmConfig + config = parseConfigFile(args.file, !flags['skip-env']) } catch (err) { - this.error(`Invalid YAML: ${(err as Error).message}`, {exit: 4}) + if (isJson) { + this.log(JSON.stringify({valid: false, errors: [{path: '', message: (err as Error).message}], warnings: []}, null, 2)) + this.exit(1) + } + this.error((err as Error).message, {exit: 1}) } - const errors: string[] = [] - - if (!config.monitors || !Array.isArray(config.monitors)) { - errors.push('Missing or invalid "monitors" array') - } else { - for (let i = 0; i < config.monitors.length; i++) { - const m = config.monitors[i] - const prefix = `monitors[${i}]` - - if (!m.name) errors.push(`${prefix}: "name" is required`) - if (!m.type) { - errors.push(`${prefix}: "type" is required`) - } else if (!VALID_TYPES.has(m.type.toUpperCase())) { - errors.push(`${prefix}: invalid type "${m.type}" (must be one of: ${[...VALID_TYPES].join(', ')})`) - } - - if (m.type && m.type.toUpperCase() !== 'HEARTBEAT' && !m.url) { - errors.push(`${prefix}: "url" is required for ${m.type} monitors`) - } + const result = validate(config) + const hasErrors = result.errors.length > 0 + const hasWarnings = result.warnings.length > 0 + const strictFail = flags.strict && hasWarnings + + if (isJson) { + this.log(JSON.stringify({ + valid: !hasErrors && !strictFail, + errors: result.errors, + warnings: result.warnings, + }, null, 2)) + if (hasErrors || strictFail) this.exit(4) + return + } - if (m.interval !== undefined && (typeof m.interval !== 'number' || m.interval < 10)) { - errors.push(`${prefix}: "interval" must be a number >= 10`) - } + if (hasWarnings && !flags.strict) { + this.log(`\n${args.file}: ${result.warnings.length} warning(s)\n`) + for (const w of result.warnings) { + this.log(` ⚠ ${w.path}: ${w.message}`) } + this.log('') } - if (errors.length > 0) { - this.log(`\n${args.file}: ${errors.length} error(s)\n`) - for (const e of errors) { - this.log(` ✗ ${e}`) + if (hasErrors) { + this.log(`\n${args.file}: ${result.errors.length} error(s)\n`) + for (const e of result.errors) { + this.log(` ✗ ${e.path}: ${e.message}`) } + this.log('') + this.exit(4) + } + if (strictFail) { + this.log(`\n${args.file}: ${result.warnings.length} warning(s) (strict mode)\n`) + for (const w of result.warnings) { + this.log(` ✗ ${w.path}: ${w.message}`) + } this.log('') this.exit(4) } - const count = config.monitors?.length ?? 0 - this.log(`${args.file}: valid (${count} monitor${count !== 1 ? 's' : ''})`) + const sections: string[] = [] + if (config.monitors?.length) sections.push(`${config.monitors.length} monitor(s)`) + if (config.alertChannels?.length) sections.push(`${config.alertChannels.length} alert channel(s)`) + if (config.tags?.length) sections.push(`${config.tags.length} tag(s)`) + if (config.environments?.length) sections.push(`${config.environments.length} environment(s)`) + if (config.secrets?.length) sections.push(`${config.secrets.length} secret(s)`) + if (config.notificationPolicies?.length) sections.push(`${config.notificationPolicies.length} notification policy(ies)`) + if (config.webhooks?.length) sections.push(`${config.webhooks.length} webhook(s)`) + if (config.resourceGroups?.length) sections.push(`${config.resourceGroups.length} resource group(s)`) + if (config.dependencies?.length) sections.push(`${config.dependencies.length} dependency(ies)`) + + this.log(`${args.file}: valid (${sections.join(', ')})`) } } diff --git a/src/commands/webhooks/test.ts b/src/commands/webhooks/test.ts index b2ef883..2474361 100644 --- a/src/commands/webhooks/test.ts +++ b/src/commands/webhooks/test.ts @@ -11,10 +11,7 @@ export default class WebhooksTest extends Command { async run() { const {args, flags} = await this.parse(WebhooksTest) const client = buildClient(flags) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.POST(`/api/v1/webhooks/${args.id}/test` as any, {} as any)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = (resp as any)?.data ?? resp - this.log(result.success ? 'Test event delivered.' : 'Test delivery failed.') + const resp = await checkedFetch(client.POST('/api/v1/webhooks/{id}/test', {params: {path: {id: args.id}}})) + this.log(resp.data?.success ? 'Test event delivered.' : 'Test delivery failed.') } } diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 432572a..3cb9c29 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -1,5 +1,6 @@ import createClient, {type Middleware} from 'openapi-fetch' import type {paths, components} from './api.generated.js' +import {AuthError, DevhelmError, EXIT_CODES} from './errors.js' export type {paths, components} @@ -22,6 +23,18 @@ export class ApiRequestError extends Error { return body || 'Unknown API error' } } + + toTypedError(): DevhelmError { + if (this.status === 401 || this.status === 403) { + return new AuthError(`Authentication failed: ${this.message}`) + } + + if (this.status === 404) { + return new DevhelmError(this.message, EXIT_CODES.NOT_FOUND) + } + + return new DevhelmError(this.message, EXIT_CODES.API) + } } // Backward-compatible wrapper types matching the API response shapes @@ -74,15 +87,47 @@ export function createApiClient(opts: { export type ApiClient = ReturnType /** - * Unwrap an openapi-fetch response: returns `data` on success, throws `ApiRequestError` on failure. + * Unwrap an openapi-fetch response: returns `data` on success, throws a typed + * DevhelmError on failure (AuthError for 401/403, NOT_FOUND for 404, API for others). * Every client.GET / POST / PUT / DELETE call should be wrapped with this. */ -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export async function checkedFetch(promise: Promise<{data?: T; error?: any; response: Response}>): Promise { +export async function checkedFetch(promise: Promise<{data?: T; error?: unknown; response: Response}>): Promise { const {data, error, response} = await promise if (error || !response.ok) { - const body = typeof error === 'object' ? JSON.stringify(error) : String(error ?? 'Unknown error') - throw new ApiRequestError(response.status, response.statusText, body) + const body = typeof error === 'object' && error !== null ? JSON.stringify(error) : String(error ?? 'Unknown error') + const apiError = new ApiRequestError(response.status, response.statusText, body) + throw apiError.toTypedError() } return data as T } + +// ── Dynamic-path helpers ──────────────────────────────────────────────── +// +// openapi-fetch requires literal path strings for type inference. When +// paths are constructed at runtime (CRUD factory, YAML applier), this +// breaks. These helpers centralize the single `as any` cast — every +// call site uses a clean, typed API. + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export function apiGet(client: ApiClient, path: string, params?: object): Promise { + return checkedFetch(client.GET(path as any, (params ? {params} : {}) as any)) +} + +export function apiPost(client: ApiClient, path: string, body: object): Promise { + return checkedFetch(client.POST(path as any, {body} as any)) +} + +export function apiPut(client: ApiClient, path: string, body: object): Promise { + return checkedFetch(client.PUT(path as any, {body} as any)) +} + +export function apiPatch(client: ApiClient, path: string, body: object): Promise { + return checkedFetch(client.PATCH(path as any, {body} as any)) +} + +export function apiDelete(client: ApiClient, path: string): Promise { + return checkedFetch(client.DELETE(path as any, {params: {path: {}}} as any)) +} + +/* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/src/lib/api.generated.ts b/src/lib/api.generated.ts index 73ba0fd..bca7c5b 100644 --- a/src/lib/api.generated.ts +++ b/src/lib/api.generated.ts @@ -200,7 +200,8 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + /** Get a tag by ID */ + get: operations["getById"]; /** Update a tag's name and/or color */ put: operations["update_2"]; post?: never; @@ -311,7 +312,7 @@ export interface paths { cookie?: never; }; /** Get a notification policy by ID */ - get: operations["getById"]; + get: operations["getById_1"]; /** Update a notification policy */ put: operations["update_6"]; post?: never; @@ -461,7 +462,7 @@ export interface paths { cookie?: never; }; /** Get a single maintenance window by ID */ - get: operations["getById_1"]; + get: operations["getById_2"]; /** Update a maintenance window */ put: operations["update_11"]; post?: never; @@ -1427,6 +1428,30 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/deploy/lock": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get current deploy lock + * @description Returns the active deploy lock for the current workspace, if any. + */ + get: operations["current"]; + put?: never; + /** + * Acquire deploy lock + * @description Acquires an exclusive deploy lock for the current workspace. Returns 409 Conflict if the workspace is already locked by another session. + */ + post: operations["acquire"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/api-keys": { parameters: { query?: never; @@ -2323,7 +2348,7 @@ export interface paths { * Get a single dispatch with full escalation and delivery history * @description Returns the dispatch state including current escalation step, acknowledgment info, and all delivery attempts made across every step. */ - get: operations["getById_2"]; + get: operations["getById_3"]; put?: never; post?: never; delete?: never; @@ -2523,6 +2548,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/auth/me": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get current API key identity + * @description Returns the authenticated API key's metadata, organization, billing plan, entitlements with usage, and current rate-limit quota. Only available for API key authentication (Bearer dh_live_...). + */ + get: operations["me_1"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/audit-log": { parameters: { query?: never; @@ -2644,179 +2689,326 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/deploy/lock/{lockId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Release deploy lock + * @description Releases a deploy lock by ID. Only the lock holder should call this. + */ + delete: operations["release"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/deploy/lock/force": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Force-release deploy lock + * @description Forcibly removes any deploy lock on the current workspace. Use to break stale locks. + */ + delete: operations["forceRelease"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { schemas: { - Actor: Record; - ApiKey: components["schemas"]["Actor"] & { - /** Format: int32 */ - orgId?: number; - /** Format: int32 */ - keyId?: number; - }; - Internal: components["schemas"]["Actor"]; - OrgContext: { - /** Format: int32 */ - id?: number; - /** @enum {string} */ - role?: "OWNER" | "ADMIN" | "MEMBER"; - }; - UI: components["schemas"]["Actor"] & { - userContext?: components["schemas"]["UserContext"]; - orgContext?: components["schemas"]["OrgContext"]; - /** Format: int32 */ - workspaceId?: number | null; - }; - UserContext: { - /** Format: int32 */ - id?: number; - /** @enum {string} */ - role?: "SUPERADMIN" | "ADMIN" | "USER"; - }; CreateSubscriptionRequest: { /** Format: int32 */ priceId?: number; }; + /** @description Associated billing plan; null when not requested */ BillingPlanDto: { - /** Format: int32 */ + /** + * Format: int32 + * @description Unique billing plan identifier + */ id?: number; + /** @description Paddle product identifier */ paddleId?: string; + /** @description Billing plan display name */ name?: string; + /** @description Plan description */ description?: string | null; + /** @description Available prices for this plan; null when not requested */ prices?: components["schemas"]["BillingPriceDto"][] | null; } | null; + /** @description Price details for this line item */ BillingPriceDto: { - /** Format: int32 */ + /** + * Format: int32 + * @description Unique billing price identifier + */ id?: number; + /** @description Paddle price identifier */ paddleId?: string; - /** Format: int32 */ + /** + * Format: int32 + * @description Price amount in smallest currency unit (e.g. cents) + */ amount?: number; - /** @enum {string} */ + /** + * @description Billing interval (MONTH or YEAR) + * @enum {string} + */ interval?: "DAY" | "WEEK" | "MONTH" | "YEAR"; - /** Format: int32 */ + /** + * Format: int32 + * @description Number of intervals between billing cycles + */ intervalCount?: number; + /** @description Price description */ description?: string | null; billingPlan?: components["schemas"]["BillingPlanDto"]; }; + /** @description Line items included in this subscription */ ItemDto: { billingPrice?: components["schemas"]["BillingPriceDto"]; - /** Format: int32 */ + /** + * Format: int32 + * @description Quantity of this price + */ quantity?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description Line item total in smallest currency unit + */ amount?: number; }; SingleValueResponseSubscriptionDto: { data?: components["schemas"]["SubscriptionDto"]; }; + /** @description Current billing subscription details */ SubscriptionDto: { - /** Format: int32 */ + /** + * Format: int32 + * @description Internal subscription identifier + */ id?: number; + /** @description Paddle subscription identifier */ paddleId?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the subscription was created + */ createdAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the subscription was last updated + */ updatedAt?: string; - /** Format: int32 */ + /** + * Format: int32 + * @description Organization this subscription belongs to + */ organizationId?: number; - /** @enum {string} */ + /** + * @description Current subscription status + * @enum {string} + */ status?: "ACTIVE" | "CANCELED" | "PAST_DUE" | "PAUSED" | "TRIALING"; - /** Format: date-time */ + /** + * Format: date-time + * @description Next billing date; null when cancelled or expired + */ nextBilledAt?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Scheduled cancellation date; null if no cancellation pending + */ willCancelAt?: string | null; + /** @description Line items included in this subscription */ items?: components["schemas"]["ItemDto"][]; }; UpdateOrgDetailsRequest: { + /** @description New organization name (max 200 chars) */ name: string; - /** Format: email */ + /** + * Format: email + * @description New billing and contact email address + */ email: string; + /** @description Team size range (e.g. 1-10, 11-50) */ size?: string; + /** @description Industry vertical (e.g. SaaS, Fintech) */ industry?: string; + /** @description Organization website URL (max 255 chars) */ websiteUrl?: string; }; + /** @description Organization account details */ OrganizationDto: { - /** Format: int32 */ + /** + * Format: int32 + * @description Unique organization identifier + */ id?: number; + /** @description Organization name */ name?: string; + /** @description Billing and contact email */ email?: string | null; + /** @description Team size range (e.g. 1-10, 11-50) */ size?: string | null; + /** @description Industry vertical (e.g. SaaS, Fintech) */ industry?: string | null; + /** @description Organization website URL */ websiteUrl?: string | null; }; SingleValueResponseOrganizationDto: { data?: components["schemas"]["OrganizationDto"]; }; + /** @description Advance the user's onboarding stage */ UpdateOnboardingStageRequest: { - /** @enum {string} */ + /** + * @description New onboarding stage + * @enum {string} + */ stage: "WELCOME" | "FIRST_MONITOR" | "SETUP_COMPLETE" | "COMPLETED"; }; SingleValueResponseUserDto: { data?: components["schemas"]["UserDto"]; }; + /** @description User account details */ UserDto: { - /** Format: int32 */ + /** + * Format: int32 + * @description Unique user identifier + */ id?: number; + /** @description User email address */ email?: string; + /** @description Whether the email address has been verified */ emailVerified?: boolean; + /** @description Display name; null if not set */ name?: string | null; - /** @enum {string} */ + /** + * @description Platform role: USER or SUPERADMIN + * @enum {string} + */ userRole?: "SUPERADMIN" | "ADMIN" | "USER"; - /** @enum {string|null} */ + /** + * @description Current onboarding progress stage; null when completed + * @enum {string|null} + */ onboardingStage?: "WELCOME" | "FIRST_MONITOR" | "SETUP_COMPLETE" | "COMPLETED" | null; + /** @description Profile image URL; null if not set */ imageUrl?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the account was created + */ createdAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the account was last updated + */ updatedAt?: string; }; UpdateProfileRequest: { + /** @description New display name (max 200 chars) */ name?: string; }; + /** @description Replace notification preferences for the current user */ UpdateNotificationPreferencesRequest: { + /** @description Map of category keys to enabled/disabled flags */ preferences: { [key: string]: boolean; }; }; + /** @description User notification preferences keyed by notification category */ NotificationPreferencesDto: { + /** @description Map of category keys to enabled/disabled flags */ preferences?: { [key: string]: boolean; }; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when preferences were last updated + */ updatedAt?: string; }; SingleValueResponseNotificationPreferencesDto: { data?: components["schemas"]["NotificationPreferencesDto"]; }; + /** @description Update workspace details */ UpdateWorkspaceRequest: { + /** @description New workspace name */ name: string; }; SingleValueResponseWorkspaceDto: { data?: components["schemas"]["WorkspaceDto"]; }; + /** @description Workspace within an organization */ WorkspaceDto: { - /** Format: int32 */ + /** + * Format: int32 + * @description Unique workspace identifier + */ id?: number; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the workspace was created + */ createdAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the workspace was last updated + */ updatedAt?: string; + /** @description Workspace name */ name?: string; - /** Format: int32 */ + /** + * Format: int32 + * @description Organization this workspace belongs to + */ orgId?: number; }; UpdateUserRequest: { + /** @description New display name (max 200 chars) */ name?: string; - /** Format: email */ + /** + * Format: email + * @description New email address + */ email?: string; - /** @enum {string} */ + /** + * @description New platform role + * @enum {string} + */ userRole?: "SUPERADMIN" | "ADMIN" | "USER"; - /** @enum {string} */ + /** + * @description New onboarding stage + * @enum {string} + */ onboardingStage?: "WELCOME" | "FIRST_MONITOR" | "SETUP_COMPLETE" | "COMPLETED"; + /** @description New profile image URL (max 500 chars) */ imageUrl?: string; }; + /** @description Update an organization member's role */ ChangeRoleRequest: { - /** @enum {string} */ + /** + * @description New role to assign + * @enum {string} + */ orgRole: "OWNER" | "ADMIN" | "MEMBER"; }; UpdateWebhookEndpointRequest: { @@ -2832,21 +3024,42 @@ export interface components { SingleValueResponseWebhookEndpointDto: { data?: components["schemas"]["WebhookEndpointDto"]; }; + /** @description Webhook endpoint that receives event delivery payloads */ WebhookEndpointDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique webhook endpoint identifier + */ id?: string; + /** @description HTTPS endpoint URL that receives event payloads */ url?: string; + /** @description Human-readable description of this endpoint */ description?: string | null; + /** @description Event types this endpoint is subscribed to */ subscribedEvents?: string[]; + /** @description Whether delivery is enabled for this endpoint */ enabled?: boolean; - /** Format: int32 */ + /** + * Format: int32 + * @description Number of consecutive delivery failures + */ consecutiveFailures?: number; + /** @description Reason the endpoint was auto-disabled */ disabledReason?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the endpoint was auto-disabled + */ disabledAt?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the endpoint was created + */ createdAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the endpoint was last updated + */ updatedAt?: string; }; /** @description Request body for updating a tag; null fields are left unchanged */ @@ -2859,38 +3072,74 @@ export interface components { SingleValueResponseTagDto: { data?: components["schemas"]["TagDto"]; }; + /** @description Tag for organizing and filtering monitors */ TagDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique tag identifier + */ id?: string; - /** Format: int32 */ + /** + * Format: int32 + * @description Organization this tag belongs to + */ organizationId?: number; + /** @description Tag name, unique within the org */ name?: string; + /** @description Hex color code for display (e.g. #6B7280) */ color?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the tag was created + */ createdAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the tag was last updated + */ updatedAt?: string; }; UpdateSecretRequest: { /** @description New secret value, stored encrypted (max 32KB) */ value: string; }; + /** @description Monitors that reference this secret; null on create/update responses */ MonitorReference: { - /** Format: uuid */ + /** + * Format: uuid + * @description Monitor identifier + */ id?: string; + /** @description Monitor name */ name?: string; }; + /** @description Secret with change-detection hash; plaintext value is never returned */ SecretDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique secret identifier + */ id?: string; + /** @description Secret key name, unique within the workspace */ key?: string; - /** Format: int32 */ + /** + * Format: int32 + * @description DEK version at the time of last encryption + */ dekVersion?: number; + /** @description SHA-256 hex digest of the current plaintext; use for change detection */ valueHash?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the secret was created + */ createdAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the secret was last updated + */ updatedAt?: string; + /** @description Monitors that reference this secret; null on create/update responses */ usedByMonitors?: components["schemas"]["MonitorReference"][] | null; }; SingleValueResponseSecretDto: { @@ -2898,10 +3147,17 @@ export interface components { }; /** @description Default retry strategy for member monitors; null clears */ RetryStrategy: { + /** @description Retry strategy kind, e.g. fixed interval between attempts */ type: string; - /** Format: int32 */ + /** + * Format: int32 + * @description Maximum number of retries after a failed check + */ maxRetries?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description Delay between retry attempts in seconds + */ interval?: number; }; /** @description Request body for updating a resource group */ @@ -2952,12 +3208,21 @@ export interface components { }; /** @description Resource group with health summary and optional member details */ ResourceGroupDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique resource group identifier + */ id?: string; - /** Format: int32 */ + /** + * Format: int32 + * @description Organization this group belongs to + */ organizationId?: number; + /** @description Human-readable group name */ name?: string; + /** @description URL-safe group identifier */ slug?: string; + /** @description Optional group description */ description?: string | null; /** * Format: uuid @@ -3001,9 +3266,15 @@ export interface components { health?: components["schemas"]["ResourceGroupHealthDto"]; /** @description Member list with individual statuses; populated on detail GET only */ members?: components["schemas"]["ResourceGroupMemberDto"][] | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the group was created + */ createdAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the group was last updated + */ updatedAt?: string; }; /** @description Aggregated health summary for a resource group */ @@ -3041,9 +3312,15 @@ export interface components { }; /** @description A single member of a resource group with its computed health status */ ResourceGroupMemberDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique group member record identifier + */ id?: string; - /** Format: uuid */ + /** + * Format: uuid + * @description Resource group this member belongs to + */ groupId?: string; /** @description Type of member: 'monitor' or 'service' */ memberType?: string; @@ -3073,7 +3350,10 @@ export interface components { status?: "operational" | "maintenance" | "degraded" | "down"; /** @description Effective check frequency label showing the group default when the monitor inherits it; null for services or when no group default is configured */ effectiveFrequency?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the member was added to the group + */ createdAt?: string; /** * Format: double @@ -3105,7 +3385,7 @@ export interface components { SingleValueResponseResourceGroupDto: { data?: components["schemas"]["ResourceGroupDto"]; }; - /** @description Escalation chain defining which channels to notify */ + /** @description Escalation chain defining which channels to notify; null preserves current */ EscalationChain: { /** @description Ordered escalation steps, evaluated in sequence */ steps: components["schemas"]["EscalationStep"][]; @@ -3144,26 +3424,32 @@ export interface components { /** @description Values list for multi-value rules like monitor_type_in */ values?: (string | null)[] | null; }; - /** @description Request body for updating a notification policy */ + /** @description Request body for updating a notification policy (null fields are preserved) */ UpdateNotificationPolicyRequest: { - /** @description Human-readable name for this policy */ - name: string; + /** @description Human-readable name for this policy; null preserves current */ + name?: string; /** @description Match rules to evaluate (all must pass; omit or empty for catch-all) */ matchRules?: components["schemas"]["MatchRule"][]; - escalation: components["schemas"]["EscalationChain"]; - /** @description Whether this policy is enabled */ - enabled: boolean; + escalation?: components["schemas"]["EscalationChain"]; + /** @description Whether this policy is enabled; null preserves current */ + enabled?: boolean; /** * Format: int32 - * @description Evaluation priority; higher value = evaluated first + * @description Evaluation priority; higher value = evaluated first; null preserves current */ - priority: number; + priority?: number; }; /** @description Org-level notification policy with match rules and escalation chain */ NotificationPolicyDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique notification policy identifier + */ id?: string; - /** Format: int32 */ + /** + * Format: int32 + * @description Organization this policy belongs to + */ organizationId?: number; /** @description Human-readable name for this policy */ name?: string; @@ -3177,9 +3463,15 @@ export interface components { * @description Evaluation order; higher value = evaluated first */ priority?: number; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the policy was created + */ createdAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the policy was last updated + */ updatedAt?: string; }; SingleValueResponseNotificationPolicyDto: { @@ -3187,26 +3479,47 @@ export interface components { }; /** @description Multi-region confirmation settings */ ConfirmationPolicy: { - /** @enum {string} */ + /** + * @description How incident confirmation is coordinated across regions + * @enum {string} + */ type: "multi_region"; - /** Format: int32 */ + /** + * Format: int32 + * @description Minimum failing regions required to confirm an incident + */ minRegionsFailing?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description Maximum seconds to wait for enough regions to fail after first trigger + */ maxWaitSeconds?: number; }; /** @description Incident detection, confirmation, and recovery policy for a monitor */ IncidentPolicyDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique incident policy identifier + */ id?: string; - /** Format: uuid */ + /** + * Format: uuid + * @description Monitor this policy is attached to + */ monitorId?: string; /** @description Array of trigger rules defining when an incident should be raised */ triggerRules?: components["schemas"]["TriggerRule"][]; confirmation?: components["schemas"]["ConfirmationPolicy"]; recovery?: components["schemas"]["RecoveryPolicy"]; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the policy was created + */ createdAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the policy was last updated + */ updatedAt?: string; /** * Format: int32 @@ -3221,28 +3534,58 @@ export interface components { }; /** @description Auto-recovery settings */ RecoveryPolicy: { - /** Format: int32 */ + /** + * Format: int32 + * @description Consecutive passing checks required to auto-resolve the incident + */ consecutiveSuccesses?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description Minimum regions that must be passing before recovery can complete + */ minRegionsPassing?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description Minutes after resolve before a new incident may open on the same monitor + */ cooldownMinutes?: number; }; /** @description Array of trigger rules defining when an incident should be raised */ TriggerRule: { - /** @enum {string} */ + /** + * @description Condition that opens or escalates an incident from check results + * @enum {string} + */ type: "consecutive_failures" | "failures_in_window" | "response_time"; - /** Format: int32 */ + /** + * Format: int32 + * @description Failure count for consecutive or windowed failure rules + */ count?: number | null; - /** Format: int32 */ + /** + * Format: int32 + * @description Window length in minutes for failures-in-window rules + */ windowMinutes?: number | null; - /** @enum {string|null} */ + /** + * @description Whether the rule applies per region or across regions + * @enum {string|null} + */ scope: "per_region" | "any_region" | null; - /** Format: int32 */ + /** + * Format: int32 + * @description Response time threshold in milliseconds for response-time rules + */ thresholdMs?: number | null; - /** @enum {string} */ + /** + * @description Incident severity when this rule fires + * @enum {string} + */ severity: "down" | "degraded"; - /** @enum {string|null} */ + /** + * @description How response times are aggregated for response-time rules + * @enum {string|null} + */ aggregationType?: "all_exceed" | "average" | "p95" | "max" | null; }; /** @description Request body for updating an incident policy */ @@ -3258,29 +3601,44 @@ export interface components { ApiKeyAuthConfig: { type: "ApiKeyAuthConfig"; } & (Omit & { + /** @description HTTP header name that carries the API key */ headerName: string; - /** Format: uuid */ + /** + * Format: uuid + * @description Vault secret ID for the API key value + */ vaultSecretId?: string | null; }); BasicAuthConfig: { type: "BasicAuthConfig"; } & (Omit & { - /** Format: uuid */ + /** + * Format: uuid + * @description Vault secret ID holding Basic auth username and password + */ vaultSecretId?: string | null; }); BearerAuthConfig: { type: "BearerAuthConfig"; } & (Omit & { - /** Format: uuid */ + /** + * Format: uuid + * @description Vault secret ID holding the bearer token value + */ vaultSecretId?: string | null; }); HeaderAuthConfig: { type: "HeaderAuthConfig"; } & (Omit & { + /** @description Custom HTTP header name for the secret value */ headerName: string; - /** Format: uuid */ + /** + * Format: uuid + * @description Vault secret ID for the header value + */ vaultSecretId?: string | null; }); + /** @description New authentication configuration (full replacement) */ MonitorAuthConfig: { type: string; }; @@ -3299,48 +3657,64 @@ export interface components { SingleValueResponseMonitorAuthDto: { data?: components["schemas"]["MonitorAuthDto"]; }; + /** @description New assertion configuration (full replacement) */ AssertionConfig: { type: string; }; BodyContainsAssertion: { type: "BodyContainsAssertion"; } & (Omit & { + /** @description Substring that must appear in the response body */ substring: string; }); DnsExpectedCnameAssertion: { type: "DnsExpectedCnameAssertion"; } & (Omit & { + /** @description Expected CNAME target the resolution must include */ value: string; }); DnsExpectedIpsAssertion: { type: "DnsExpectedIpsAssertion"; } & (Omit & { + /** @description Allowed IP addresses; at least one resolved address must match */ ips: string[]; }); DnsMaxAnswersAssertion: { type: "DnsMaxAnswersAssertion"; } & (Omit & { + /** @description DNS record type whose answer count is checked */ recordType: string; - /** Format: int32 */ + /** + * Format: int32 + * @description Maximum number of answers allowed for that record type + */ max?: number; }); DnsMinAnswersAssertion: { type: "DnsMinAnswersAssertion"; } & (Omit & { + /** @description DNS record type whose answer count is checked */ recordType: string; - /** Format: int32 */ + /** + * Format: int32 + * @description Minimum number of answers required for that record type + */ min?: number; }); DnsRecordContainsAssertion: { type: "DnsRecordContainsAssertion"; } & (Omit & { + /** @description DNS record type to assert on (A, AAAA, CNAME, MX, TXT) */ recordType: string; + /** @description Substring that must appear in a matching record value */ substring: string; }); DnsRecordEqualsAssertion: { type: "DnsRecordEqualsAssertion"; } & (Omit & { + /** @description DNS record type to assert on (A, AAAA, CNAME, MX, TXT) */ recordType: string; + /** @description Expected DNS record value for an exact match */ value: string; }); DnsResolvesAssertion: { @@ -3349,56 +3723,82 @@ export interface components { DnsResponseTimeAssertion: { type: "DnsResponseTimeAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description Maximum allowed DNS resolution time in milliseconds + */ maxMs?: number; }); DnsResponseTimeWarnAssertion: { type: "DnsResponseTimeWarnAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description DNS resolution time in milliseconds that triggers a warning only + */ warnMs?: number; }); DnsTtlHighAssertion: { type: "DnsTtlHighAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description Maximum TTL in seconds before a high-TTL warning is raised + */ maxTtl?: number; }); DnsTtlLowAssertion: { type: "DnsTtlLowAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description Minimum acceptable TTL in seconds before a warning is raised + */ minTtl?: number; }); DnsTxtContainsAssertion: { type: "DnsTxtContainsAssertion"; } & (Omit & { + /** @description Substring that must appear in at least one TXT record */ substring: string; }); HeaderValueAssertion: { type: "HeaderValueAssertion"; } & (Omit & { + /** @description HTTP header name to assert on */ headerName: string; + /** @description Expected value to compare against */ expected: string; - /** @enum {string} */ + /** + * @description Comparison operator (equals, contains, less_than, greater_than, etc.) + * @enum {string} + */ operator: "equals" | "contains" | "less_than" | "greater_than" | "matches" | "range"; }); HeartbeatIntervalDriftAssertion: { type: "HeartbeatIntervalDriftAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description Max percent drift from expected ping interval before warning (non-fatal) + */ maxDeviationPercent: number; }); HeartbeatMaxIntervalAssertion: { type: "HeartbeatMaxIntervalAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description Maximum allowed gap in seconds between consecutive heartbeat pings + */ maxSeconds: number; }); HeartbeatPayloadContainsAssertion: { type: "HeartbeatPayloadContainsAssertion"; } & (Omit & { + /** @description JSONPath expression into the heartbeat ping JSON payload */ path: string; + /** @description Expected value to compare against at that path */ value: string; }); HeartbeatReceivedAssertion: { @@ -3407,7 +3807,10 @@ export interface components { IcmpPacketLossAssertion: { type: "IcmpPacketLossAssertion"; } & (Omit & { - /** Format: double */ + /** + * Format: double + * @description Maximum allowed packet loss percentage before the check fails (0–100) + */ maxPercent?: number; }); IcmpReachableAssertion: { @@ -3416,21 +3819,32 @@ export interface components { IcmpResponseTimeAssertion: { type: "IcmpResponseTimeAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description Maximum average ICMP round-trip time in milliseconds + */ maxMs?: number; }); IcmpResponseTimeWarnAssertion: { type: "IcmpResponseTimeWarnAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description ICMP round-trip time in milliseconds that triggers a warning only + */ warnMs?: number; }); JsonPathAssertion: { type: "JsonPathAssertion"; } & (Omit & { + /** @description JSONPath expression to extract a value from the response body */ path: string; + /** @description Expected value to compare against */ expected: string; - /** @enum {string} */ + /** + * @description Comparison operator (equals, contains, less_than, greater_than, etc.) + * @enum {string} + */ operator: "equals" | "contains" | "less_than" | "greater_than" | "matches" | "range"; }); McpConnectsAssertion: { @@ -3439,89 +3853,128 @@ export interface components { McpHasCapabilityAssertion: { type: "McpHasCapabilityAssertion"; } & (Omit & { + /** @description Capability name the server must advertise, e.g. tools or resources */ capability: string; }); McpMinToolsAssertion: { type: "McpMinToolsAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description Minimum number of tools the server must expose + */ min?: number; }); McpProtocolVersionAssertion: { type: "McpProtocolVersionAssertion"; } & (Omit & { + /** @description Expected MCP protocol version string from the server handshake */ version: string; }); McpResponseTimeAssertion: { type: "McpResponseTimeAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description Maximum allowed MCP check duration in milliseconds + */ maxMs?: number; }); McpResponseTimeWarnAssertion: { type: "McpResponseTimeWarnAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description MCP check duration in milliseconds that triggers a warning only + */ warnMs?: number; }); McpToolAvailableAssertion: { type: "McpToolAvailableAssertion"; } & (Omit & { + /** @description MCP tool name that must appear in the server's tool list */ toolName: string; }); McpToolCountChangedAssertion: { type: "McpToolCountChangedAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description Expected tool count; warns when the live count differs + */ expectedCount?: number; }); RedirectCountAssertion: { type: "RedirectCountAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description Maximum number of HTTP redirects allowed before the check fails + */ maxCount?: number; }); RedirectTargetAssertion: { type: "RedirectTargetAssertion"; } & (Omit & { + /** @description Expected final URL after following redirects */ expected: string; - /** @enum {string} */ + /** + * @description Comparison operator (equals, contains, less_than, greater_than, etc.) + * @enum {string} + */ operator: "equals" | "contains" | "less_than" | "greater_than" | "matches" | "range"; }); RegexBodyAssertion: { type: "RegexBodyAssertion"; } & (Omit & { + /** @description Regular expression the response body must match */ pattern: string; }); ResponseSizeAssertion: { type: "ResponseSizeAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description Maximum response body size in bytes before the check fails + */ maxBytes?: number; }); ResponseTimeAssertion: { type: "ResponseTimeAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description Maximum allowed response time in milliseconds before the check fails + */ thresholdMs?: number; }); ResponseTimeWarnAssertion: { type: "ResponseTimeWarnAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description HTTP response time in milliseconds that triggers a warning only + */ warnMs?: number; }); SslExpiryAssertion: { type: "SslExpiryAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description Minimum days before TLS certificate expiry; fails or warns below this threshold + */ minDaysRemaining?: number; }); StatusCodeAssertion: { type: "StatusCodeAssertion"; } & (Omit & { + /** @description Expected status code, range pattern, or wildcard such as 2xx */ expected: string; - /** @enum {string} */ + /** + * @description Comparison operator (equals, contains, less_than, greater_than, etc.) + * @enum {string} + */ operator: "equals" | "contains" | "less_than" | "greater_than" | "matches" | "range"; }); TcpConnectsAssertion: { @@ -3530,18 +3983,27 @@ export interface components { TcpResponseTimeAssertion: { type: "TcpResponseTimeAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description Maximum TCP connect time in milliseconds before the check fails + */ maxMs?: number; }); TcpResponseTimeWarnAssertion: { type: "TcpResponseTimeWarnAssertion"; } & (Omit & { - /** Format: int32 */ + /** + * Format: int32 + * @description TCP connect time in milliseconds that triggers a warning only + */ warnMs?: number; }); UpdateAssertionRequest: { config: components["schemas"]["BodyContainsAssertion"] | components["schemas"]["DnsExpectedCnameAssertion"] | components["schemas"]["DnsExpectedIpsAssertion"] | components["schemas"]["DnsMaxAnswersAssertion"] | components["schemas"]["DnsMinAnswersAssertion"] | components["schemas"]["DnsRecordContainsAssertion"] | components["schemas"]["DnsRecordEqualsAssertion"] | components["schemas"]["DnsResolvesAssertion"] | components["schemas"]["DnsResponseTimeAssertion"] | components["schemas"]["DnsResponseTimeWarnAssertion"] | components["schemas"]["DnsTtlHighAssertion"] | components["schemas"]["DnsTtlLowAssertion"] | components["schemas"]["DnsTxtContainsAssertion"] | components["schemas"]["HeaderValueAssertion"] | components["schemas"]["HeartbeatIntervalDriftAssertion"] | components["schemas"]["HeartbeatMaxIntervalAssertion"] | components["schemas"]["HeartbeatPayloadContainsAssertion"] | components["schemas"]["HeartbeatReceivedAssertion"] | components["schemas"]["IcmpPacketLossAssertion"] | components["schemas"]["IcmpReachableAssertion"] | components["schemas"]["IcmpResponseTimeAssertion"] | components["schemas"]["IcmpResponseTimeWarnAssertion"] | components["schemas"]["JsonPathAssertion"] | components["schemas"]["McpConnectsAssertion"] | components["schemas"]["McpHasCapabilityAssertion"] | components["schemas"]["McpMinToolsAssertion"] | components["schemas"]["McpProtocolVersionAssertion"] | components["schemas"]["McpResponseTimeAssertion"] | components["schemas"]["McpResponseTimeWarnAssertion"] | components["schemas"]["McpToolAvailableAssertion"] | components["schemas"]["McpToolCountChangedAssertion"] | components["schemas"]["RedirectCountAssertion"] | components["schemas"]["RedirectTargetAssertion"] | components["schemas"]["RegexBodyAssertion"] | components["schemas"]["ResponseSizeAssertion"] | components["schemas"]["ResponseTimeAssertion"] | components["schemas"]["ResponseTimeWarnAssertion"] | components["schemas"]["SslExpiryAssertion"] | components["schemas"]["StatusCodeAssertion"] | components["schemas"]["TcpConnectsAssertion"] | components["schemas"]["TcpResponseTimeAssertion"] | components["schemas"]["TcpResponseTimeWarnAssertion"]; - /** @enum {string} */ + /** + * @description New outcome severity: FAIL or WARN + * @enum {string} + */ severity?: "fail" | "warn"; }; MonitorAssertionDto: { @@ -3558,11 +4020,13 @@ export interface components { SingleValueResponseMonitorAssertionDto: { data?: components["schemas"]["MonitorAssertionDto"]; }; + /** @description Replace the alert channels linked to a monitor */ SetAlertChannelsRequest: { + /** @description IDs of alert channels to link (replaces current list) */ channelIds: string[]; }; SingleValueResponseListUUID: { - data?: string[]; + data?: (string | null)[] | null; }; /** @description Request body for adding tags to a monitor. Provide existing tag IDs, inline new tags, or both. */ AddMonitorTagsRequest: { @@ -3574,7 +4038,10 @@ export interface components { /** @description Replace all assertions; null preserves current */ CreateAssertionRequest: { config: components["schemas"]["BodyContainsAssertion"] | components["schemas"]["DnsExpectedCnameAssertion"] | components["schemas"]["DnsExpectedIpsAssertion"] | components["schemas"]["DnsMaxAnswersAssertion"] | components["schemas"]["DnsMinAnswersAssertion"] | components["schemas"]["DnsRecordContainsAssertion"] | components["schemas"]["DnsRecordEqualsAssertion"] | components["schemas"]["DnsResolvesAssertion"] | components["schemas"]["DnsResponseTimeAssertion"] | components["schemas"]["DnsResponseTimeWarnAssertion"] | components["schemas"]["DnsTtlHighAssertion"] | components["schemas"]["DnsTtlLowAssertion"] | components["schemas"]["DnsTxtContainsAssertion"] | components["schemas"]["HeaderValueAssertion"] | components["schemas"]["HeartbeatIntervalDriftAssertion"] | components["schemas"]["HeartbeatMaxIntervalAssertion"] | components["schemas"]["HeartbeatPayloadContainsAssertion"] | components["schemas"]["HeartbeatReceivedAssertion"] | components["schemas"]["IcmpPacketLossAssertion"] | components["schemas"]["IcmpReachableAssertion"] | components["schemas"]["IcmpResponseTimeAssertion"] | components["schemas"]["IcmpResponseTimeWarnAssertion"] | components["schemas"]["JsonPathAssertion"] | components["schemas"]["McpConnectsAssertion"] | components["schemas"]["McpHasCapabilityAssertion"] | components["schemas"]["McpMinToolsAssertion"] | components["schemas"]["McpProtocolVersionAssertion"] | components["schemas"]["McpResponseTimeAssertion"] | components["schemas"]["McpResponseTimeWarnAssertion"] | components["schemas"]["McpToolAvailableAssertion"] | components["schemas"]["McpToolCountChangedAssertion"] | components["schemas"]["RedirectCountAssertion"] | components["schemas"]["RedirectTargetAssertion"] | components["schemas"]["RegexBodyAssertion"] | components["schemas"]["ResponseSizeAssertion"] | components["schemas"]["ResponseTimeAssertion"] | components["schemas"]["ResponseTimeWarnAssertion"] | components["schemas"]["SslExpiryAssertion"] | components["schemas"]["StatusCodeAssertion"] | components["schemas"]["TcpConnectsAssertion"] | components["schemas"]["TcpResponseTimeAssertion"] | components["schemas"]["TcpResponseTimeWarnAssertion"]; - /** @enum {string} */ + /** + * @description Outcome severity: FAIL (fails the check) or WARN (warns without failing) + * @enum {string} + */ severity?: "fail" | "warn"; }; DnsMonitorConfig: components["schemas"]["MonitorConfig"] & { @@ -3708,72 +4175,137 @@ export interface components { alertChannelIds?: (string | null)[] | null; tags?: components["schemas"]["AddMonitorTagsRequest"]; }; + /** @description Full monitor representation */ MonitorDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique monitor identifier + */ id?: string; - /** Format: int32 */ + /** + * Format: int32 + * @description Organization this monitor belongs to + */ organizationId?: number; + /** @description Human-readable name for this monitor */ name?: string; /** @enum {string} */ type?: "HTTP" | "DNS" | "MCP_SERVER" | "TCP" | "ICMP" | "HEARTBEAT"; config?: components["schemas"]["DnsMonitorConfig"] | components["schemas"]["HeartbeatMonitorConfig"] | components["schemas"]["HttpMonitorConfig"] | components["schemas"]["IcmpMonitorConfig"] | components["schemas"]["McpServerMonitorConfig"] | components["schemas"]["TcpMonitorConfig"]; - /** Format: int32 */ + /** + * Format: int32 + * @description Check frequency in seconds (30–86400) + */ frequencySeconds?: number; + /** @description Whether the monitor is active */ enabled?: boolean; + /** @description Probe regions where checks are executed */ regions?: string[]; - /** @enum {string} */ + /** + * @description Management source: DASHBOARD or CLI + * @enum {string} + */ managedBy?: "DASHBOARD" | "CLI"; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the monitor was created + */ createdAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the monitor was last updated + */ updatedAt?: string; + /** @description Assertions evaluated against each check result; null on list responses */ assertions?: components["schemas"]["MonitorAssertionDto"][] | null; + /** @description Tags applied to this monitor */ tags?: components["schemas"]["TagDto"][] | null; + /** @description Heartbeat ping URL; populated for HEARTBEAT monitors only */ pingUrl?: string | null; environment?: components["schemas"]["Summary"]; - auth?: components["schemas"]["MonitorAuthDto"]; + auth?: components["schemas"]["ApiKeyAuthConfig"] | components["schemas"]["BasicAuthConfig"] | components["schemas"]["BearerAuthConfig"] | components["schemas"]["HeaderAuthConfig"]; incidentPolicy?: components["schemas"]["IncidentPolicyDto"]; + /** @description Alert channel IDs linked to this monitor; populated on single-monitor responses */ alertChannelIds?: (string | null)[] | null; }; SingleValueResponseMonitorDto: { data?: components["schemas"]["MonitorDto"]; }; + /** @description Environment associated with this monitor; null when unassigned */ Summary: { /** Format: uuid */ id?: string; name?: string; slug?: string; }; + /** @description Update an organization member's status */ ChangeStatusRequest: { - /** @enum {string} */ + /** + * @description New membership status (ACTIVE or SUSPENDED) + * @enum {string} + */ status: "INVITED" | "ACTIVE" | "SUSPENDED" | "LEFT" | "REMOVED" | "DECLINED"; }; UpdateMaintenanceWindowRequest: { - /** Format: uuid */ + /** + * Format: uuid + * @description Monitor to attach this maintenance window to; null preserves current + */ monitorId?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Updated start time (ISO 8601) + */ startsAt: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Updated end time (ISO 8601) + */ endsAt: string; + /** @description Updated iCal RRULE; null clears the repeat rule */ repeatRule?: string; + /** @description Updated reason; null clears the existing reason */ reason?: string; + /** @description Whether to suppress alerts; null preserves current */ suppressAlerts?: boolean; }; + /** @description Scheduled maintenance window for a monitor */ MaintenanceWindowDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique maintenance window identifier + */ id?: string; - /** Format: uuid */ + /** + * Format: uuid + * @description Monitor this window applies to; null for org-wide windows + */ monitorId?: string | null; - /** Format: int32 */ + /** + * Format: int32 + * @description Organization this maintenance window belongs to + */ organizationId?: number; - /** Format: date-time */ + /** + * Format: date-time + * @description Scheduled start of the maintenance window + */ startsAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Scheduled end of the maintenance window + */ endsAt?: string; + /** @description iCal RRULE for recurring windows; null for one-time */ repeatRule?: string | null; + /** @description Human-readable reason for the maintenance */ reason?: string | null; + /** @description Whether alerts are suppressed during this window */ suppressAlerts?: boolean; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the window was created + */ createdAt?: string; }; SingleValueResponseMaintenanceWindowDto: { @@ -3789,22 +4321,42 @@ export interface components { /** @description Whether this is the default environment; null preserves current */ isDefault?: boolean | null; }; + /** @description Environment with variable substitutions for monitor configs */ EnvironmentDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique environment identifier + */ id?: string; - /** Format: int32 */ + /** + * Format: int32 + * @description Organization this environment belongs to + */ orgId?: number; + /** @description Human-readable environment name */ name?: string; + /** @description URL-safe identifier */ slug?: string; + /** @description Key-value variable pairs available for interpolation */ variables?: { [key: string]: string; }; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the environment was created + */ createdAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the environment was last updated + */ updatedAt?: string; - /** Format: int32 */ + /** + * Format: int32 + * @description Number of monitors using this environment + */ monitorCount?: number; + /** @description Whether this is the default environment for new monitors */ isDefault?: boolean; }; SingleValueResponseEnvironmentDto: { @@ -3875,21 +4427,42 @@ export interface components { [key: string]: string | null; } | null; }); + /** @description Alert channel with non-sensitive configuration metadata */ AlertChannelDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique alert channel identifier + */ id: string; + /** @description Human-readable channel name */ name: string; - /** @enum {string} */ + /** + * @description Channel integration type (e.g. SLACK, PAGERDUTY, EMAIL) + * @enum {string} + */ channelType: "email" | "webhook" | "slack" | "pagerduty" | "opsgenie" | "teams" | "discord"; + /** @description Non-sensitive display metadata; null for older channels */ displayConfig?: { [key: string]: Record | null; } | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the channel was created + */ createdAt: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the channel was last updated + */ updatedAt: string; - /** Format: date-time */ + /** @description SHA-256 hash of the channel config; use for change detection */ + configHash?: string | null; + /** + * Format: date-time + * @description Timestamp of the most recent delivery attempt + */ lastDeliveryAt?: string | null; + /** @description Outcome of the most recent delivery (SUCCESS, FAILED, etc.) */ lastDeliveryStatus?: string | null; }; SingleValueResponseAlertChannelDto: { @@ -3913,54 +4486,121 @@ export interface components { action: string; statusText?: string | null; }; + /** @description Incident triggered by a monitor check failure or manual creation */ IncidentDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique incident identifier + */ id?: string; - /** Format: uuid */ + /** + * Format: uuid + * @description Monitor that triggered the incident; null for service or manual incidents + */ monitorId?: string | null; - /** Format: int32 */ + /** + * Format: int32 + * @description Organization this incident belongs to + */ organizationId?: number; - /** @enum {string} */ + /** + * @description Incident origin: MONITOR, SERVICE, or MANUAL + * @enum {string} + */ source?: "AUTOMATIC" | "MANUAL" | "MONITORS" | "STATUS_DATA" | "RESOURCE_GROUP"; - /** @enum {string} */ + /** + * @description Current lifecycle status (OPEN, RESOLVED, etc.) + * @enum {string} + */ status?: "WATCHING" | "TRIGGERED" | "CONFIRMED" | "RESOLVED"; - /** @enum {string} */ + /** + * @description Severity level: DOWN, DEGRADED, or MAINTENANCE + * @enum {string} + */ severity?: "DOWN" | "DEGRADED" | "MAINTENANCE"; + /** @description Short summary of the incident; null for auto-generated incidents */ title?: string | null; + /** @description Human-readable description of the trigger rule that fired */ triggeredByRule?: string | null; + /** @description Probe regions that observed the failure */ affectedRegions?: string[]; - /** Format: int32 */ + /** + * Format: int32 + * @description Number of times this incident has been reopened + */ reopenCount?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description User who created the incident (manual incidents only) + */ createdByUserId?: number | null; + /** @description Whether this incident is visible on the status page */ statusPageVisible?: boolean; - /** Format: uuid */ + /** + * Format: uuid + * @description Linked vendor service incident ID; null for monitor incidents + */ serviceIncidentId?: string | null; - /** Format: uuid */ + /** + * Format: uuid + * @description Linked service catalog ID; null for monitor incidents + */ serviceId?: string | null; + /** @description External reference ID (e.g. PagerDuty incident ID) */ externalRef?: string | null; + /** @description Service components affected by this incident */ affectedComponents?: (string | null)[] | null; + /** @description Short URL linking to the incident details */ shortlink?: string | null; - /** @enum {string|null} */ + /** + * @description How the incident was resolved (AUTO_RECOVERED, MANUAL, etc.) + * @enum {string|null} + */ resolutionReason?: "MANUAL" | "AUTO_RECOVERED" | "AUTO_RESOLVED" | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the incident was detected or created + */ startedAt?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the incident was confirmed (multi-region confirmation) + */ confirmedAt?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the incident was resolved + */ resolvedAt?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Cooldown window end; new incidents suppressed until this time + */ cooldownUntil?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the incident record was created + */ createdAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the incident was last updated + */ updatedAt?: string; + /** @description Name of the associated monitor; populated on list responses */ monitorName?: string | null; + /** @description Name of the associated service; populated on list responses */ serviceName?: string | null; + /** @description Slug of the associated service; populated on list responses */ serviceSlug?: string | null; + /** @description Type of the associated monitor; populated on list responses */ monitorType?: string | null; - /** Format: uuid */ + /** + * Format: uuid + * @description Resource group that owns this incident; null when not group-managed + */ resourceGroupId?: string | null; + /** @description Name of the resource group; populated on list responses */ resourceGroupName?: string | null; }; TableValueResultIncidentDto: { @@ -3970,7 +4610,7 @@ export interface components { }; SingleValueResponseInteger: { /** Format: int32 */ - data?: number; + data?: number | null; }; CreateAutoIncidentRequest: { /** Format: uuid */ @@ -3995,126 +4635,245 @@ export interface components { errorMessage?: string | null; }; AdapterHealthDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Service this health record belongs to + */ serviceId?: string; + /** @description URL-safe service identifier */ serviceSlug?: string; + /** @description Service name */ serviceName?: string; + /** @description Data source adapter type */ adapterType?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp of the last successful poll + */ lastSuccessAt?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp of the last failed poll + */ lastFailureAt?: string | null; - /** Format: int32 */ + /** + * Format: int32 + * @description Number of consecutive poll failures + */ consecutiveFailures?: number; + /** @description Error message from the most recent failure */ lastErrorMessage?: string | null; + /** @description Whether the adapter is disabled due to repeated failures */ disabledByHealth?: boolean; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when this health record was last updated + */ updatedAt?: string; }; SingleValueResponseAdapterHealthDto: { data?: components["schemas"]["AdapterHealthDto"]; }; + /** @description Create a new organization */ CreateOrgRequest: { + /** @description Organization name */ name: string; - /** Format: email */ + /** + * Format: email + * @description Billing and contact email address + */ email?: string | null; }; SingleValueResponseTransactionDto: { data?: components["schemas"]["TransactionDto"]; }; + /** @description A billing transaction from Paddle */ TransactionDto: { + /** @description Paddle transaction identifier */ id?: string; + /** @description Transaction status (e.g. completed, pending) */ status?: string | null; + /** @description ISO 4217 currency code */ currencyCode?: string | null; + /** @description Invoice number; null if not invoiced */ invoiceNumber?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the transaction was billed + */ billedAt?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the transaction was created + */ createdAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the transaction was last updated + */ updatedAt?: string; + /** @description Total amount as a decimal string (including tax) */ total?: string | null; + /** @description Subtotal before tax as a decimal string */ subtotal?: string | null; + /** @description Tax amount as a decimal string */ tax?: string | null; }; + /** @description Minimal request for creating an HTTP monitor quickly */ QuickMonitorRequest: { + /** @description Target URL to monitor */ url: string; + /** @description Human-readable monitor name; defaults to the hostname if omitted */ name?: string | null; - /** Format: int32 */ + /** + * Format: int32 + * @description Check frequency in seconds (30–86400); defaults to 60 + */ frequencySeconds?: number | null; }; OnboardingSetupRequest: { + /** @description Organization or team name (max 200 chars) */ name: string; + /** @description User's role or job title */ role?: string | null; + /** @description Team size range (e.g. 1-10, 11-50) */ teamSize?: string | null; }; + /** @description URL to analyze for monitor setup suggestions */ AnalyzeUrlRequest: { + /** @description Target URL to analyze (must be a valid HTTP/HTTPS URL) */ url: string; }; + /** @description Analysis of a URL with monitor setup suggestions */ AnalyzeUrlResponse: { + /** @description Whether the URL responded during analysis */ reachable?: boolean; - /** Format: int64 */ + /** + * Format: int64 + * @description Response time observed during analysis in milliseconds + */ responseTimeMs?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description HTTP status code from the analysis request + */ statusCode?: number; - /** Format: date-time */ + /** + * Format: date-time + * @description TLS certificate expiry date; null for non-HTTPS or unavailable + */ tlsExpiry?: string | null; - /** Format: int32 */ + /** + * Format: int32 + * @description Days until TLS certificate expires; null if not applicable + */ tlsDaysRemaining?: number | null; + /** @description Response Content-Type header value */ contentType?: string | null; + /** @description Suggested monitor name derived from the URL hostname */ suggestedName?: string; + /** @description Recommended assertions based on the URL response */ suggestedAssertions?: components["schemas"]["SuggestedAssertion"][]; - /** Format: int32 */ + /** + * Format: int32 + * @description Suggested check frequency in seconds based on the URL + */ suggestedFrequencySeconds?: number; }; SingleValueResponseAnalyzeUrlResponse: { data?: components["schemas"]["AnalyzeUrlResponse"]; }; + /** @description Recommended assertions based on the URL response */ SuggestedAssertion: { + /** @description Assertion type (e.g. status_code, response_time) */ type?: string; + /** @description Comparison operator (e.g. equals, less_than) */ operator?: string; + /** @description Expected value to compare against */ value?: string; }; + /** @description Accept an organization invite using the invite token */ AcceptInviteRequest: { + /** @description Invite token from the invitation email */ token: string; }; + /** @description Result of accepting an organization invite */ AcceptInviteDto: { - /** Format: int32 */ + /** + * Format: int32 + * @description Organization the user joined + */ orgId?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description User who accepted the invite + */ userId?: number; - /** @enum {string} */ + /** + * @description Role assigned to the new member + * @enum {string} + */ orgRole?: "OWNER" | "ADMIN" | "MEMBER"; - /** @enum {string} */ + /** + * @description Initial membership status after joining + * @enum {string} + */ status?: "INVITED" | "ACTIVE" | "SUSPENDED" | "LEFT" | "REMOVED" | "DECLINED"; }; SingleValueResponseAcceptInviteDto: { data?: components["schemas"]["AcceptInviteDto"]; }; RegisterUserRequest: { + /** @description User nickname from the identity provider */ nickname?: string | null; + /** @description User display name from the identity provider */ name?: string | null; + /** @description Profile picture URL from the identity provider */ picture?: string | null; }; + /** @description Create a new workspace within the organization */ CreateWorkspaceRequest: { + /** @description Workspace name */ name: string; }; + /** @description Add an existing user as a member of the organization */ AddMemberRequest: { - /** Format: int32 */ + /** + * Format: int32 + * @description ID of the user to add + */ userId: number; - /** @enum {string} */ + /** + * @description Role to assign to the new member + * @enum {string} + */ orgRole: "OWNER" | "ADMIN" | "MEMBER"; }; + /** @description Organization member with role and status */ MemberDto: { - /** Format: int32 */ + /** + * Format: int32 + * @description User identifier of the member + */ userId?: number; + /** @description Member email address */ email?: string; + /** @description Member display name; null if not set */ name?: string | null; - /** @enum {string} */ + /** + * @description Member role within this organization (OWNER, ADMIN, MEMBER) + * @enum {string} + */ orgRole?: "OWNER" | "ADMIN" | "MEMBER"; - /** @enum {string} */ + /** + * @description Membership status (ACTIVE, PENDING, SUSPENDED) + * @enum {string} + */ status?: "INVITED" | "ACTIVE" | "SUSPENDED" | "LEFT" | "REMOVED" | "DECLINED"; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the member was added to the organization + */ createdAt?: string; }; SingleValueResponseMemberDto: { @@ -4128,7 +4887,9 @@ export interface components { /** @description Event types to deliver, e.g. monitor.created, incident.resolved */ subscribedEvents: string[]; }; + /** @description Event type to use for a test webhook delivery */ TestWebhookEndpointRequest: { + /** @description Event type to simulate (e.g. monitor.created); null uses a default */ eventType?: string | null; }; SingleValueResponseWebhookTestResult: { @@ -4143,18 +4904,34 @@ export interface components { durationMs?: number | null; }; SingleValueResponseString: { - data?: string; + data?: string | null; }; + /** @description Result of a data encryption key rotation operation */ DekRotationResultDto: { - /** Format: int32 */ + /** + * Format: int32 + * @description DEK version before rotation + */ previousDekVersion?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description DEK version after rotation + */ newDekVersion?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description Number of secrets re-encrypted with the new DEK + */ secretsReEncrypted?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description Number of alert channels re-encrypted with the new DEK + */ channelsReEncrypted?: number; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the rotation was performed + */ rotatedAt?: string; }; SingleValueResponseDekRotationResultDto: { @@ -4477,11 +5254,20 @@ export interface components { }; /** @description Dispatch state for a single (incident, notification policy) pair, with delivery history */ NotificationDispatchDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique dispatch record identifier + */ id?: string; - /** Format: uuid */ + /** + * Format: uuid + * @description Incident this dispatch is for + */ incidentId?: string; - /** Format: uuid */ + /** + * Format: uuid + * @description Notification policy that matched this incident + */ policyId?: string; /** @description Human-readable name of the matched policy (null if policy has been deleted) */ policyName?: string | null; @@ -4522,9 +5308,15 @@ export interface components { lastNotifiedAt?: string | null; /** @description Delivery records for all channels associated with this dispatch */ deliveries?: components["schemas"]["AlertDeliveryDto"][]; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the dispatch was created + */ createdAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the dispatch was last updated + */ updatedAt?: string; }; SingleValueResponseNotificationDispatchDto: { @@ -4570,13 +5362,23 @@ export interface components { config: components["schemas"]["ApiKeyAuthConfig"] | components["schemas"]["BasicAuthConfig"] | components["schemas"]["BearerAuthConfig"] | components["schemas"]["HeaderAuthConfig"]; }; AssertionTestResultDto: { - /** @enum {string} */ + /** + * @description Assertion type evaluated + * @enum {string} + */ assertionType?: "status_code" | "response_time" | "body_contains" | "json_path" | "header" | "regex" | "dns_resolves" | "dns_response_time" | "dns_expected_ips" | "dns_expected_cname" | "dns_record_contains" | "dns_record_equals" | "dns_txt_contains" | "dns_min_answers" | "dns_max_answers" | "dns_response_time_warn" | "dns_ttl_low" | "dns_ttl_high" | "mcp_connects" | "mcp_response_time" | "mcp_has_capability" | "mcp_tool_available" | "mcp_min_tools" | "mcp_protocol_version" | "mcp_response_time_warn" | "mcp_tool_count_changed" | "ssl_expiry" | "response_size" | "redirect_count" | "redirect_target" | "response_time_warn" | "tcp_connects" | "tcp_response_time" | "tcp_response_time_warn" | "icmp_reachable" | "icmp_response_time" | "icmp_response_time_warn" | "icmp_packet_loss" | "heartbeat_received" | "heartbeat_max_interval" | "heartbeat_interval_drift" | "heartbeat_payload_contains"; + /** @description Whether the assertion passed */ passed?: boolean; - /** @enum {string} */ + /** + * @description Assertion severity: FAIL or WARN + * @enum {string} + */ severity?: "fail" | "warn"; + /** @description Human-readable result description */ message?: string; + /** @description Expected value */ expected?: string | null; + /** @description Actual value observed during the test */ actual?: string | null; }; MonitorTestResultDto: { @@ -4651,33 +5453,69 @@ export interface components { data?: components["schemas"]["BulkMonitorActionResult"]; }; CreateMaintenanceWindowRequest: { - /** Format: uuid */ + /** + * Format: uuid + * @description Monitor to attach this maintenance window to; null for org-wide + */ monitorId?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Scheduled start of the maintenance window (ISO 8601) + */ startsAt: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Scheduled end of the maintenance window (ISO 8601) + */ endsAt: string; + /** @description iCal RRULE for recurring windows (max 100 chars); null for one-time */ repeatRule?: string; + /** @description Human-readable reason for the maintenance */ reason?: string; + /** @description Whether to suppress alerts during this window (default: true) */ suppressAlerts?: boolean; }; + /** @description Invite a new member to the organization by email */ CreateInviteRequest: { - /** Format: email */ + /** + * Format: email + * @description Email address to invite + */ email: string; - /** @enum {string} */ + /** + * @description Role to assign on acceptance + * @enum {string} + */ roleOffered: "OWNER" | "ADMIN" | "MEMBER"; }; + /** @description Organization invite sent to an email address */ InviteDto: { - /** Format: int32 */ + /** + * Format: int32 + * @description Unique invite identifier + */ inviteId?: number; + /** @description Email address the invite was sent to */ email?: string; - /** @enum {string} */ + /** + * @description Role that will be assigned to the invitee on acceptance + * @enum {string} + */ roleOffered?: "OWNER" | "ADMIN" | "MEMBER"; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the invite expires + */ expiresAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the invite was accepted; null if not yet used + */ consumedAt?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the invite was revoked; null if active + */ revokedAt?: string | null; }; SingleValueResponseInviteDto: { @@ -4723,9 +5561,14 @@ export interface components { data?: components["schemas"]["IncidentDetailDto"]; }; AddIncidentUpdateRequest: { + /** @description Update message or post-mortem notes */ body?: string; - /** @enum {string} */ + /** + * @description Updated incident status; null to keep current status + * @enum {string} + */ newStatus?: "WATCHING" | "TRIGGERED" | "CONFIRMED" | "RESOLVED"; + /** @description Whether to notify subscribers of this update */ notifySubscribers?: boolean; }; ResolveIncidentRequest: { @@ -4744,6 +5587,40 @@ export interface components { /** @description Whether this is the default environment for new monitors */ isDefault?: boolean; }; + /** @description Request to acquire a deploy lock for the current workspace */ + AcquireDeployLockRequest: { + /** @description Identity of the lock requester (e.g. hostname, CI job ID) */ + lockedBy: string; + /** + * Format: int32 + * @description Lock TTL in minutes (default: 30, max: 60) + * @example 30 + */ + ttlMinutes?: number | null; + }; + /** @description Represents an active deploy lock for a workspace */ + DeployLockDto: { + /** + * Format: uuid + * @description Unique lock identifier + */ + id?: string; + /** @description Identity of the lock holder (e.g. CLI session ID, username) */ + lockedBy?: string; + /** + * Format: date-time + * @description Timestamp when the lock was acquired + */ + lockedAt?: string; + /** + * Format: date-time + * @description Timestamp when the lock automatically expires + */ + expiresAt?: string; + }; + SingleValueResponseDeployLockDto: { + data?: components["schemas"]["DeployLockDto"]; + }; CreateApiKeyRequest: { /** @description Human-readable name to identify this API key */ name: string; @@ -4753,33 +5630,66 @@ export interface components { */ expiresAt?: string | null; }; + /** @description Created API key with the full key value — store it now, it won't be shown again */ ApiKeyCreateResponse: { - /** Format: int32 */ + /** + * Format: int32 + * @description Unique API key identifier + */ id?: number; + /** @description Human-readable name for this API key */ name?: string; + /** @description Full API key value in dh_live_* format; store this now */ key?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the key was created + */ createdAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the key expires; null if no expiration + */ expiresAt?: string | null; }; SingleValueResponseApiKeyCreateResponse: { data?: components["schemas"]["ApiKeyCreateResponse"]; }; + /** @description API key for programmatic access to the DevHelm API */ ApiKeyDto: { - /** Format: int32 */ + /** + * Format: int32 + * @description Unique API key identifier + */ id?: number; + /** @description Human-readable name for this API key */ name?: string; + /** @description Full API key value in dh_live_* format */ key?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the key was created + */ createdAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the key was last updated + */ updatedAt?: string; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp of the most recent API call; null if never used + */ lastUsedAt?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the key was revoked; null if active + */ revokedAt?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the key expires; null if no expiration + */ expiresAt?: string | null; }; SingleValueResponseApiKeyDto: { @@ -4800,6 +5710,7 @@ export interface components { success?: boolean; message?: string; }; + /** @description Alert channel configuration to test without saving */ TestAlertChannelRequest: { config: components["schemas"]["DiscordChannelConfig"] | components["schemas"]["EmailChannelConfig"] | components["schemas"]["OpsGenieChannelConfig"] | components["schemas"]["PagerDutyChannelConfig"] | components["schemas"]["SlackChannelConfig"] | components["schemas"]["TeamsChannelConfig"] | components["schemas"]["WebhookChannelConfig"]; }; @@ -4822,22 +5733,35 @@ export interface components { }; SingleValueResponseMapStringString: { data?: { - [key: string]: string; - }; + [key: string]: string | null; + } | null; }; SingleValueResponseListMonitorAssertionDto: { - data?: components["schemas"]["MonitorAssertionDto"][]; + data?: components["schemas"]["MonitorAssertionDto"][] | null; }; SchedulableMonitorDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique monitor identifier + */ id?: string; - /** @enum {string} */ + /** + * @description Monitor protocol type + * @enum {string} + */ type?: "HTTP" | "DNS" | "MCP_SERVER" | "TCP" | "ICMP" | "HEARTBEAT"; config?: components["schemas"]["DnsMonitorConfig"] | components["schemas"]["HeartbeatMonitorConfig"] | components["schemas"]["HttpMonitorConfig"] | components["schemas"]["IcmpMonitorConfig"] | components["schemas"]["McpServerMonitorConfig"] | components["schemas"]["TcpMonitorConfig"]; - /** Format: int32 */ + /** + * Format: int32 + * @description Check frequency in seconds + */ frequencySeconds?: number; + /** @description Probe regions to execute checks from */ regions?: string[]; - /** Format: int32 */ + /** + * Format: int32 + * @description Organization this monitor belongs to + */ organizationId?: number; }; TableValueResultAdapterHealthDto: { @@ -4846,7 +5770,7 @@ export interface components { hasPrev?: boolean; }; SingleValueResponseListBillingPlanDto: { - data?: components["schemas"]["BillingPlanDto"][]; + data?: components["schemas"]["BillingPlanDto"][] | null; }; TableValueResultTransactionDto: { data?: components["schemas"]["TransactionDto"][]; @@ -4861,14 +5785,27 @@ export interface components { SingleValueResponseUpcomingChargeResponse: { data?: components["schemas"]["UpcomingChargeResponse"]; }; + /** @description Preview of upcoming subscription charge after a plan change */ UpcomingChargeResponse: { - /** @enum {string} */ + /** + * @description Type of subscription action being previewed + * @enum {string} + */ action?: "UPGRADE" | "DOWNGRADE" | "NOOP"; - /** Format: int32 */ + /** + * Format: int32 + * @description Amount due immediately (proration) in smallest currency unit + */ immediateAmount?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description Amount that will be charged on the next billing cycle + */ nextBillingAmount?: number; - /** Format: date-time */ + /** + * Format: date-time + * @description Date of the next billing cycle; null if cancelling + */ nextBillingDate?: string | null; }; /** @description A single resolved entitlement for the organization */ @@ -4925,9 +5862,14 @@ export interface components { /** Format: int32 */ size?: number; }; + /** @description Generic id/value pair for select options and autocomplete */ IdValuePair: { - /** Format: int32 */ + /** + * Format: int32 + * @description Numeric identifier + */ id?: number; + /** @description Display label or value */ value?: string; }; TableValueResultIdValuePair: { @@ -4935,13 +5877,24 @@ export interface components { hasNext?: boolean; hasPrev?: boolean; }; + /** @description Membership summary for an organization the user belongs to */ MyOrgItemDto: { - /** Format: int32 */ + /** + * Format: int32 + * @description Organization identifier + */ orgId?: number; + /** @description Organization name */ orgName?: string; - /** @enum {string} */ + /** + * @description Member role within this organization + * @enum {string} + */ orgRole?: "OWNER" | "ADMIN" | "MEMBER"; - /** @enum {string} */ + /** + * @description Membership status + * @enum {string} + */ status?: "INVITED" | "ACTIVE" | "SUSPENDED" | "LEFT" | "REMOVED" | "DECLINED"; }; TableValueResultMyOrgItemDto: { @@ -5029,6 +5982,7 @@ export interface components { configured?: boolean; maskedSecret?: string | null; }; + /** @description List of all available webhook event types */ WebhookEventCatalogEntry: { /** @description Dot-notation event type identifier, e.g. "monitor.created" */ type?: string; @@ -5038,6 +5992,7 @@ export interface components { description?: string; }; WebhookEventCatalogResponse: { + /** @description List of all available webhook event types */ data?: components["schemas"]["WebhookEventCatalogEntry"][]; }; /** @description Cursor-paginated response for time-series and append-only data */ @@ -5074,39 +6029,75 @@ export interface components { activeIncidentCount?: number; dataCompleteness?: string; }; + /** @description A component affected by a scheduled maintenance window */ MaintenanceComponentRef: { - /** Format: uuid */ + /** + * Format: uuid + * @description Component identifier + */ id?: string; + /** @description Component name */ name?: string; + /** @description Component status at the time of the maintenance update */ status?: string; }; /** @description A status update within a scheduled maintenance lifecycle */ MaintenanceUpdateDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique update identifier + */ id?: string; + /** @description Status at the time of this update */ status?: string; + /** @description Update message from the vendor */ body?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when this update was posted + */ displayAt?: string | null; }; /** @description A scheduled maintenance window from a vendor status page */ ScheduledMaintenanceDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique maintenance record identifier + */ id?: string; + /** @description Vendor-assigned maintenance identifier */ externalId?: string; + /** @description Maintenance title as reported by the vendor */ title?: string; + /** @description Current maintenance status (scheduled, in_progress, completed) */ status?: string; + /** @description Reported impact level */ impact?: string | null; + /** @description Vendor-provided short URL to the maintenance page */ shortlink?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the maintenance is scheduled to begin + */ scheduledFor?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the maintenance is scheduled to end + */ scheduledUntil?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the maintenance actually started + */ startedAt?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the maintenance was completed + */ completedAt?: string | null; + /** @description Components affected by this maintenance */ affectedComponents?: components["schemas"]["MaintenanceComponentRef"][]; + /** @description Status updates posted during the maintenance lifecycle */ updates?: components["schemas"]["MaintenanceUpdateDto"][]; }; ServiceDetailDto: { @@ -5256,16 +6247,29 @@ export interface components { }; /** @description Daily uptime data for a component */ ComponentUptimeDayDto: { - /** Format: date-time */ + /** + * Format: date-time + * @description Date of the daily bucket (ISO 8601) + */ date?: string; - /** Format: int32 */ + /** + * Format: int32 + * @description Seconds of partial outage observed on this day + */ partialOutageSeconds?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description Seconds of major outage observed on this day + */ majorOutageSeconds?: number; - /** Format: double */ + /** + * Format: double + * @description Computed uptime percentage for the day + */ uptimePercentage?: number; /** @description Incident event references for this day as raw JSON */ eventsJson?: string | null; + /** @description Data source: vendor_reported or incident_derived */ source?: string; }; TableValueResultComponentUptimeDayDto: { @@ -5273,21 +6277,44 @@ export interface components { hasNext?: boolean; hasPrev?: boolean; }; + /** @description Global status summary across all subscribed vendor services */ GlobalStatusSummaryDto: { - /** Format: int32 */ + /** + * Format: int32 + * @description Total number of services in the catalog + */ totalServices?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description Number of services currently fully operational + */ operationalCount?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description Number of services with degraded status + */ degradedCount?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description Number of services with partial outage + */ partialOutageCount?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description Number of services with major outage + */ majorOutageCount?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description Number of services currently under maintenance + */ maintenanceCount?: number; - /** Format: int64 */ + /** + * Format: int64 + * @description Total number of active incidents across all services + */ activeIncidentCount?: number; + /** @description Services that are not fully operational */ servicesWithIssues?: components["schemas"]["ServiceCatalogDto"][]; }; SingleValueResponseGlobalStatusSummaryDto: { @@ -5311,16 +6338,29 @@ export interface components { SingleValueResponseResourceGroupHealthDto: { data?: components["schemas"]["ResourceGroupHealthDto"]; }; + /** @description In-app notification for the current user */ NotificationDto: { - /** Format: int64 */ + /** + * Format: int64 + * @description Unique notification identifier + */ id?: number; + /** @description Notification category (e.g. incident, monitor, team) */ type?: string; + /** @description Short notification title */ title?: string; + /** @description Full notification body; null for title-only notifications */ body?: string | null; + /** @description Type of the resource this notification is about */ resourceType?: string | null; + /** @description ID of the resource this notification is about */ resourceId?: string | null; + /** @description Whether the notification has been read */ read?: boolean; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the notification was created + */ createdAt?: string; }; TableValueResultNotificationDto: { @@ -5330,7 +6370,7 @@ export interface components { }; SingleValueResponseLong: { /** Format: int64 */ - data?: number; + data?: number | null; }; TableValueResultNotificationPolicyDto: { data?: components["schemas"]["NotificationPolicyDto"][]; @@ -5347,20 +6387,40 @@ export interface components { hasNext?: boolean; hasPrev?: boolean; }; + /** @description A point-in-time version snapshot of a monitor configuration */ MonitorVersionDto: { - /** Format: uuid */ + /** + * Format: uuid + * @description Unique version record identifier + */ id?: string; - /** Format: uuid */ + /** + * Format: uuid + * @description Monitor this version belongs to + */ monitorId?: string; - /** Format: int32 */ + /** + * Format: int32 + * @description Monotonically increasing version number + */ version?: number; snapshot?: components["schemas"]["MonitorDto"]; - /** Format: int32 */ + /** + * Format: int32 + * @description User ID who made the change; null for automated changes + */ changedById?: number | null; - /** @enum {string} */ + /** + * @description Change source (DASHBOARD, CLI, API) + * @enum {string} + */ changedVia?: "API" | "DASHBOARD" | "CLI" | "TERRAFORM"; + /** @description Human-readable description of what changed */ changeSummary?: string | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when this version was recorded + */ createdAt?: string; }; TableValueResultMonitorVersionDto: { @@ -5841,10 +6901,12 @@ export interface components { hasNext?: boolean; hasPrev?: boolean; }; + /** @description Combined dashboard overview for monitors and incidents */ DashboardOverviewDto: { monitors?: components["schemas"]["MonitorsSummaryDto"]; incidents?: components["schemas"]["IncidentsSummaryDto"]; }; + /** @description Incident summary counters */ IncidentsSummaryDto: { /** Format: int64 */ active?: number; @@ -5853,28 +6915,55 @@ export interface components { /** Format: double */ mttr30d?: number | null; }; + /** @description Dashboard summary counters for monitors */ MonitorsSummaryDto: { - /** Format: int64 */ + /** + * Format: int64 + * @description Total number of monitors in the organization + */ total?: number; - /** Format: int64 */ + /** + * Format: int64 + * @description Number of monitors currently passing + */ up?: number; - /** Format: int64 */ + /** + * Format: int64 + * @description Number of monitors currently failing (DOWN severity) + */ down?: number; - /** Format: int64 */ + /** + * Format: int64 + * @description Number of monitors with degraded status + */ degraded?: number; - /** Format: int64 */ + /** + * Format: int64 + * @description Number of disabled monitors + */ paused?: number; - /** Format: double */ + /** + * Format: double + * @description Average uptime percentage across all monitors over last 24h + */ avgUptime24h?: number | null; - /** Format: double */ + /** + * Format: double + * @description Average uptime percentage across all monitors over last 30 days + */ avgUptime30d?: number | null; }; SingleValueResponseDashboardOverviewDto: { data?: components["schemas"]["DashboardOverviewDto"]; }; + /** @description Service category with its count of catalog entries */ CategoryDto: { + /** @description Category name (e.g. CI/CD, Cloud, Payments) */ category?: string; - /** Format: int64 */ + /** + * Format: int64 + * @description Number of services in this category + */ serviceCount?: number; }; TableValueResultCategoryDto: { @@ -5882,20 +6971,123 @@ export interface components { hasNext?: boolean; hasPrev?: boolean; }; + /** @description Identity, organization, plan, and rate-limit info for the authenticated API key */ + AuthMeResponse: { + key?: components["schemas"]["KeyInfo"]; + organization?: components["schemas"]["OrgInfo"]; + plan?: components["schemas"]["PlanInfo"]; + rateLimits?: components["schemas"]["RateLimitInfo"]; + }; + /** @description API key metadata */ + KeyInfo: { + /** + * Format: int32 + * @description Key ID + */ + id?: number; + /** @description Human-readable key name */ + name?: string; + /** + * Format: date-time + * @description When the key was created + */ + createdAt?: string; + /** + * Format: date-time + * @description When the key expires (null = never) + */ + expiresAt?: string | null; + /** + * Format: date-time + * @description Last time the key was used + */ + lastUsedAt?: string | null; + }; + /** @description Organization the key belongs to */ + OrgInfo: { + /** + * Format: int32 + * @description Organization ID + */ + id?: number; + /** @description Organization name */ + name?: string; + }; + /** @description Billing plan and entitlement state */ + PlanInfo: { + /** + * @description Resolved plan tier + * @enum {string} + */ + tier?: "FREE" | "STARTER" | "PRO" | "TEAM" | "BUSINESS" | "ENTERPRISE"; + /** @description Subscription status (null if no subscription) */ + subscriptionStatus?: string | null; + /** @description Whether the org is on a trial */ + trialActive?: boolean; + /** + * Format: date-time + * @description Trial expiry (null if not trialing) + */ + trialExpiresAt?: string | null; + /** @description Entitlement limits keyed by entitlement name */ + entitlements?: { + [key: string]: components["schemas"]["EntitlementDto"]; + }; + /** @description Current usage counters keyed by entitlement name */ + usage?: { + [key: string]: number; + }; + }; + /** @description Rate-limit quota for the current sliding window */ + RateLimitInfo: { + /** + * Format: int64 + * @description Maximum requests allowed per window + */ + requestsPerMinute?: number; + /** + * Format: int64 + * @description Requests remaining in the current window + */ + remaining?: number; + /** + * Format: int64 + * @description Sliding window size in milliseconds + */ + windowMs?: number; + }; + SingleValueResponseAuthMeResponse: { + data?: components["schemas"]["AuthMeResponse"]; + }; AuditEventDto: { - /** Format: int64 */ + /** + * Format: int64 + * @description Unique audit event identifier + */ id?: number; - /** Format: int32 */ + /** + * Format: int32 + * @description User ID who performed the action; null for system actions + */ actorId?: number | null; + /** @description Email of the actor; null for system actions */ actorEmail?: string | null; + /** @description Audit action type (e.g. monitor.created, api_key.revoked) */ action?: string; + /** @description Type of resource affected (e.g. monitor, api_key) */ resourceType?: string | null; + /** @description ID of the affected resource */ resourceId?: string | null; + /** @description Human-readable name of the affected resource */ resourceName?: string | null; + /** @description Additional context about the action */ metadata?: { [key: string]: Record | null; } | null; - /** Format: date-time */ + /** + * Format: date-time + * @description Timestamp when the action was performed + */ createdAt?: string; }; PageResultAuditEventDto: { @@ -5997,9 +7189,7 @@ export type $defs = Record; export interface operations { updateSubscription: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { orgId: number; @@ -6026,9 +7216,7 @@ export interface operations { }; updateOrgDetails: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { orgId: number; @@ -6054,9 +7242,7 @@ export interface operations { }; advanceStage: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -6080,9 +7266,7 @@ export interface operations { }; me: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -6102,9 +7286,7 @@ export interface operations { }; updateProfile: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -6128,9 +7310,7 @@ export interface operations { }; getNotificationPreferences: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -6150,9 +7330,7 @@ export interface operations { }; updateNotificationPreferences: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -6176,9 +7354,7 @@ export interface operations { }; getWorkspace: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { workspaceId: number; @@ -6200,9 +7376,7 @@ export interface operations { }; updateWorkspace: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { workspaceId: number; @@ -6228,9 +7402,7 @@ export interface operations { }; deleteWorkspace: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { workspaceId: number; @@ -6250,9 +7422,7 @@ export interface operations { }; updateUser: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { userId: number; @@ -6278,9 +7448,7 @@ export interface operations { }; updateOrganization: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { orgId: number; @@ -6305,10 +7473,8 @@ export interface operations { }; }; updateMemberRole: { - parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + parameters: { + query?: never; header?: never; path: { orgId: number; @@ -6333,9 +7499,7 @@ export interface operations { }; get: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { workspaceId: number; @@ -6357,9 +7521,7 @@ export interface operations { }; update: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { workspaceId: number; @@ -6385,9 +7547,7 @@ export interface operations { }; delete: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { workspaceId: number; @@ -6407,9 +7567,7 @@ export interface operations { }; get_1: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -6431,9 +7589,7 @@ export interface operations { }; update_1: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -6459,9 +7615,7 @@ export interface operations { }; delete_1: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -6479,11 +7633,31 @@ export interface operations { }; }; }; - update_2: { + getById: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; + query?: never; + header?: never; + path: { + id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["SingleValueResponseTagDto"]; + }; }; + }; + }; + update_2: { + parameters: { + query?: never; header?: never; path: { id: string; @@ -6509,9 +7683,7 @@ export interface operations { }; delete_2: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -6531,9 +7703,7 @@ export interface operations { }; update_3: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { key: string; @@ -6559,9 +7729,7 @@ export interface operations { }; delete_3: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { key: string; @@ -6581,8 +7749,7 @@ export interface operations { }; get_2: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; + query?: { includeMetrics?: boolean; }; header?: never; @@ -6606,9 +7773,7 @@ export interface operations { }; update_4: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -6634,9 +7799,7 @@ export interface operations { }; delete_4: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -6656,9 +7819,7 @@ export interface operations { }; get_3: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -6678,9 +7839,7 @@ export interface operations { }; update_5: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -6704,9 +7863,7 @@ export interface operations { }; markRead: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: number; @@ -6726,9 +7883,7 @@ export interface operations { }; markAllRead: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -6744,11 +7899,9 @@ export interface operations { }; }; }; - getById: { + getById_1: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -6770,9 +7923,7 @@ export interface operations { }; update_6: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -6798,9 +7949,7 @@ export interface operations { }; delete_5: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -6820,9 +7969,7 @@ export interface operations { }; get_4: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { /** @description Monitor UUID */ @@ -6854,9 +8001,7 @@ export interface operations { }; update_7: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { /** @description Monitor UUID */ @@ -6901,9 +8046,7 @@ export interface operations { }; update_8: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { monitorId: string; @@ -6929,9 +8072,7 @@ export interface operations { }; set: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { monitorId: string; @@ -6957,9 +8098,7 @@ export interface operations { }; remove: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { monitorId: string; @@ -6979,9 +8118,7 @@ export interface operations { }; update_9: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { monitorId: string; @@ -7008,9 +8145,7 @@ export interface operations { }; remove_1: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { monitorId: string; @@ -7031,9 +8166,7 @@ export interface operations { }; setChannels: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { monitorId: string; @@ -7059,9 +8192,7 @@ export interface operations { }; get_5: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -7083,9 +8214,7 @@ export interface operations { }; update_10: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -7111,9 +8240,7 @@ export interface operations { }; delete_6: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -7133,9 +8260,7 @@ export interface operations { }; changeStatus: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { userId: number; @@ -7159,9 +8284,7 @@ export interface operations { }; changeRole: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { userId: number; @@ -7183,11 +8306,9 @@ export interface operations { }; }; }; - getById_1: { + getById_2: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -7209,9 +8330,7 @@ export interface operations { }; update_11: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -7237,9 +8356,7 @@ export interface operations { }; delete_7: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -7259,9 +8376,7 @@ export interface operations { }; get_6: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { slug: string; @@ -7283,9 +8398,7 @@ export interface operations { }; update_12: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { slug: string; @@ -7311,9 +8424,7 @@ export interface operations { }; delete_8: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { slug: string; @@ -7333,9 +8444,7 @@ export interface operations { }; update_13: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -7361,9 +8470,7 @@ export interface operations { }; delete_9: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -7657,8 +8764,7 @@ export interface operations { }; create_1: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; + query?: { ifNotExists?: boolean; }; header?: never; @@ -7684,8 +8790,7 @@ export interface operations { }; list: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; + query?: { limit?: number; }; header?: never; @@ -7709,9 +8814,7 @@ export interface operations { }; createSubscriptionTransaction: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { orgId: number; @@ -7737,9 +8840,7 @@ export interface operations { }; quickMonitor: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -7763,9 +8864,7 @@ export interface operations { }; completeSetup: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -7789,9 +8888,7 @@ export interface operations { }; analyzeUrl: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -7815,9 +8912,7 @@ export interface operations { }; accept: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -7866,7 +8961,6 @@ export interface operations { listWorkspaces: { parameters: { query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; pageable: components["schemas"]["Pageable"]; }; header?: never; @@ -7890,9 +8984,7 @@ export interface operations { }; createWorkspace: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { orgId: number; @@ -7918,9 +9010,7 @@ export interface operations { }; listMembers: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { orgId: number; @@ -7942,9 +9032,7 @@ export interface operations { }; addMember: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { orgId: number; @@ -7970,9 +9058,7 @@ export interface operations { }; reEnableAdapter: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { serviceId: string; @@ -7995,7 +9081,6 @@ export interface operations { list_1: { parameters: { query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; pageable: components["schemas"]["Pageable"]; }; header?: never; @@ -8017,9 +9102,7 @@ export interface operations { }; create_2: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8044,7 +9127,6 @@ export interface operations { list_2: { parameters: { query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; pageable: components["schemas"]["Pageable"]; }; header?: never; @@ -8066,9 +9148,7 @@ export interface operations { }; create_3: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8092,9 +9172,7 @@ export interface operations { }; test: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -8120,9 +9198,7 @@ export interface operations { }; rotateSigningSecret: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8142,9 +9218,7 @@ export interface operations { }; rotateDek: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8165,7 +9239,6 @@ export interface operations { list_3: { parameters: { query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; pageable: components["schemas"]["Pageable"]; }; header?: never; @@ -8187,9 +9260,7 @@ export interface operations { }; create_4: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8213,9 +9284,7 @@ export interface operations { }; subscribe: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { slug: string; @@ -8241,9 +9310,7 @@ export interface operations { }; list_4: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8263,9 +9330,7 @@ export interface operations { }; create_5: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8289,9 +9354,7 @@ export interface operations { }; list_5: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8311,9 +9374,7 @@ export interface operations { }; create_6: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8337,9 +9398,7 @@ export interface operations { }; addMember_1: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -8365,9 +9424,7 @@ export interface operations { }; list_6: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8387,9 +9444,7 @@ export interface operations { }; create_7: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8413,9 +9468,7 @@ export interface operations { }; test_1: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -8441,9 +9494,7 @@ export interface operations { }; acknowledge: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -8466,7 +9517,6 @@ export interface operations { list_7: { parameters: { query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; /** @description Filter by enabled state */ enabled?: boolean; /** @description Filter by monitor type */ @@ -8500,9 +9550,7 @@ export interface operations { }; create_8: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8526,9 +9574,7 @@ export interface operations { }; add: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { monitorId: string; @@ -8554,9 +9600,7 @@ export interface operations { }; testExisting: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -8578,9 +9622,7 @@ export interface operations { }; getMonitorTags: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -8602,9 +9644,7 @@ export interface operations { }; addMonitorTags: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -8630,9 +9670,7 @@ export interface operations { }; removeMonitorTags: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -8656,9 +9694,7 @@ export interface operations { }; rotateToken: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -8680,9 +9716,7 @@ export interface operations { }; resume: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -8703,10 +9737,8 @@ export interface operations { }; }; pause: { - parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + parameters: { + query?: never; header?: never; path: { id: string; @@ -8728,9 +9760,7 @@ export interface operations { }; testAdHoc: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8754,9 +9784,7 @@ export interface operations { }; bulkAction: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8780,8 +9808,7 @@ export interface operations { }; list_8: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; + query?: { /** @description Filter by monitor UUID */ monitorId?: string; /** @description Filter by status: 'active' or 'upcoming' */ @@ -8806,9 +9833,7 @@ export interface operations { }; create_9: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8832,9 +9857,7 @@ export interface operations { }; list_9: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8854,9 +9877,7 @@ export interface operations { }; create_10: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8880,9 +9901,7 @@ export interface operations { }; revoke: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { inviteId: number; @@ -8902,9 +9921,7 @@ export interface operations { }; resend: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { inviteId: number; @@ -8927,7 +9944,6 @@ export interface operations { list_10: { parameters: { query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; params: components["schemas"]["IncidentFilterParams"]; }; header?: never; @@ -8949,9 +9965,7 @@ export interface operations { }; create_11: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -8975,9 +9989,7 @@ export interface operations { }; addUpdate: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -9003,9 +10015,7 @@ export interface operations { }; resolve: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -9087,9 +10097,7 @@ export interface operations { }; list_11: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -9109,9 +10117,7 @@ export interface operations { }; create_12: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -9133,11 +10139,58 @@ export interface operations { }; }; }; - list_12: { + current: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; + query?: never; + header?: { + "x-phelm-workspace-id"?: number; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["SingleValueResponseDeployLockDto"]; + }; + }; + }; + }; + acquire: { + parameters: { + query?: never; + header?: { + /** @description Target workspace ID (defaults to 1) */ + "x-phelm-workspace-id"?: number; + }; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AcquireDeployLockRequest"]; + }; + }; + responses: { + /** @description Created */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["SingleValueResponseDeployLockDto"]; + }; }; + }; + }; + list_12: { + parameters: { + query?: never; header?: never; path?: never; cookie?: never; @@ -9157,9 +10210,7 @@ export interface operations { }; create_13: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -9183,9 +10234,7 @@ export interface operations { }; revoke_1: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: number; @@ -9207,9 +10256,7 @@ export interface operations { }; regenerate: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: number; @@ -9231,9 +10278,7 @@ export interface operations { }; retry: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -9256,7 +10301,6 @@ export interface operations { list_13: { parameters: { query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; pageable: components["schemas"]["Pageable"]; }; header?: never; @@ -9278,9 +10322,7 @@ export interface operations { }; create_14: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -9304,9 +10346,7 @@ export interface operations { }; test_2: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -9328,9 +10368,7 @@ export interface operations { }; testConfig: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -9381,9 +10419,7 @@ export interface operations { }; updateAlertSensitivity: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -9409,9 +10445,7 @@ export interface operations { }; delete_10: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: number; @@ -9431,9 +10465,7 @@ export interface operations { }; update_14: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: number; @@ -9653,9 +10685,7 @@ export interface operations { }; listActive: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { orgId: number; @@ -9677,9 +10707,7 @@ export interface operations { }; cancel: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { orgId: number; @@ -9700,7 +10728,6 @@ export interface operations { getUpcomingCharge: { parameters: { query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; priceId: number; }; header?: never; @@ -9724,9 +10751,7 @@ export interface operations { }; getManagementUrls: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { orgId: number; @@ -9748,9 +10773,7 @@ export interface operations { }; getCustomerAuthToken: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { orgId: number; @@ -9772,9 +10795,7 @@ export interface operations { }; getEntitlements: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { orgId: number; @@ -9797,7 +10818,6 @@ export interface operations { searchOrganizations: { parameters: { query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; query: string; paginationParams: components["schemas"]["PaginationParams"]; }; @@ -9820,9 +10840,7 @@ export interface operations { }; myOrgs: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -9842,9 +10860,7 @@ export interface operations { }; stream: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -9865,7 +10881,6 @@ export interface operations { listUsers: { parameters: { query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; pageable: components["schemas"]["Pageable"]; }; header?: never; @@ -9887,9 +10902,7 @@ export interface operations { }; getStats: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -9910,7 +10923,6 @@ export interface operations { listOrgs: { parameters: { query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; pageable: components["schemas"]["Pageable"]; }; header?: never; @@ -9932,9 +10944,7 @@ export interface operations { }; getAdapterHealth: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -9954,8 +10964,7 @@ export interface operations { }; listDeliveries: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; + query?: { limit?: number; }; header?: never; @@ -9979,9 +10988,7 @@ export interface operations { }; getSigningSecretInfo: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -10271,9 +11278,7 @@ export interface operations { }; list_14: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -10293,9 +11298,7 @@ export interface operations { }; get_8: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -10317,9 +11320,7 @@ export interface operations { }; unsubscribe: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -10339,9 +11340,7 @@ export interface operations { }; getHealth: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -10363,8 +11362,7 @@ export interface operations { }; list_15: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; + query?: { unreadOnly?: boolean; page?: number; size?: number; @@ -10388,9 +11386,7 @@ export interface operations { }; unreadCount: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -10410,9 +11406,7 @@ export interface operations { }; listDispatches: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -10435,7 +11429,6 @@ export interface operations { listByIncident: { parameters: { query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; /** @description UUID of the incident to inspect */ incident_id: string; }; @@ -10456,11 +11449,9 @@ export interface operations { }; }; }; - getById_2: { + getById_3: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -10483,7 +11474,6 @@ export interface operations { listVersions: { parameters: { query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; pageable: components["schemas"]["Pageable"]; }; header?: never; @@ -10507,9 +11497,7 @@ export interface operations { }; getVersion: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -10532,8 +11520,7 @@ export interface operations { }; getUptime: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; + query?: { /** @description Time window for uptime calculation */ window?: "24h" | "7d" | "30d" | "90d"; }; @@ -10585,8 +11572,7 @@ export interface operations { }; getResults: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; + query?: { /** @description Start of time range (ISO 8601, inclusive); defaults to 24 hours ago */ from?: string; /** @description End of time range (ISO 8601, inclusive); defaults to now */ @@ -10651,8 +11637,7 @@ export interface operations { }; getSummary: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; + query?: { /** @description Chart window: 24h returns hourly buckets, 7d/30d/90d return daily buckets */ chartWindow?: "24h" | "7d" | "30d" | "90d"; }; @@ -10705,7 +11690,6 @@ export interface operations { list_16: { parameters: { query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; pageable: components["schemas"]["Pageable"]; }; header?: never; @@ -10747,9 +11731,7 @@ export interface operations { }; get_9: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -10771,9 +11753,7 @@ export interface operations { }; overview: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -10811,10 +11791,29 @@ export interface operations { }; }; }; + me_1: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "*/*": components["schemas"]["SingleValueResponseAuthMeResponse"]; + }; + }; + }; + }; list_18: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; + query?: { action?: string; actorId?: number; resourceType?: string; @@ -10842,9 +11841,7 @@ export interface operations { }; listAttempts: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -10866,9 +11863,7 @@ export interface operations { }; listDeliveries_1: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -10890,9 +11885,7 @@ export interface operations { }; delete_11: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { orgId: number; @@ -10912,9 +11905,7 @@ export interface operations { }; removeMember: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { orgId: number; @@ -10935,9 +11926,7 @@ export interface operations { }; removeMember_1: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { id: string; @@ -10958,9 +11947,7 @@ export interface operations { }; remove_2: { parameters: { - query: { - actor: components["schemas"]["ApiKey"] | components["schemas"]["Internal"] | components["schemas"]["UI"]; - }; + query?: never; header?: never; path: { userId: number; @@ -10978,4 +11965,46 @@ export interface operations { }; }; }; + release: { + parameters: { + query?: never; + header?: { + "x-phelm-workspace-id"?: number; + }; + path: { + lockId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + forceRelease: { + parameters: { + query?: never; + header?: { + "x-phelm-workspace-id"?: number; + }; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; } diff --git a/src/lib/base-command.ts b/src/lib/base-command.ts index 3d850b5..ca9fa6a 100644 --- a/src/lib/base-command.ts +++ b/src/lib/base-command.ts @@ -29,11 +29,14 @@ export function buildClient(flags: { return createApiClient({baseUrl, token, verbose: flags.verbose}) } +const VALID_FORMATS = new Set(['table', 'json', 'yaml']) + export function display( command: Command, data: unknown, format: string, columns?: ColumnDef[], ): void { + if (!VALID_FORMATS.has(format)) format = 'table' command.log(formatOutput(data, format as OutputFormat, columns)) } diff --git a/src/lib/crud-commands.ts b/src/lib/crud-commands.ts index 8ff48d9..1254087 100644 --- a/src/lib/crud-commands.ts +++ b/src/lib/crud-commands.ts @@ -1,23 +1,26 @@ import {Command, Args} from '@oclif/core' import {globalFlags, buildClient, display} from './base-command.js' -import {checkedFetch} from './api-client.js' -import {ColumnDef} from './output.js' +import {fetchPaginated} from './typed-api.js' +import {apiGet, apiPost, apiPut, apiDelete} from './api-client.js' +import type {ColumnDef} from './output.js' +// oclif flag types are structurally complex; this alias keeps ResourceConfig readable. // eslint-disable-next-line @typescript-eslint/no-explicit-any -type AnyFlag = any +type OclifFlag = any -export interface ResourceConfig { +export interface ResourceConfig { name: string plural: string apiPath: string idField?: string columns: ColumnDef[] - createFlags?: Record - updateFlags?: Record - bodyBuilder?: (flags: Record) => Record + createFlags?: Record + updateFlags?: Record + bodyBuilder?: (flags: Record) => object + updateBodyBuilder?: (flags: Record) => object } -export function createListCommand(config: ResourceConfig) { +export function createListCommand(config: ResourceConfig) { class ListCmd extends Command { static description = `List all ${config.plural}` static examples = [`<%= config.bin %> ${config.plural} list`] @@ -26,10 +29,7 @@ export function createListCommand(config: ResourceConfig) { async run() { const {flags} = await this.parse(ListCmd) const client = buildClient(flags) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.GET(config.apiPath as any, {} as any)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const items = (resp as any)?.data ?? resp + const items = await fetchPaginated(client, config.apiPath) display(this, items, flags.output, config.columns) } } @@ -37,7 +37,7 @@ export function createListCommand(config: ResourceConfig) { return ListCmd } -export function createGetCommand(config: ResourceConfig) { +export function createGetCommand(config: ResourceConfig) { const idLabel = config.idField ?? 'id' class GetCmd extends Command { static description = `Get a ${config.name} by ${idLabel}` @@ -49,18 +49,15 @@ export function createGetCommand(config: ResourceConfig) { const {args, flags} = await this.parse(GetCmd) const client = buildClient(flags) const id = args[idLabel] - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.GET(`${config.apiPath}/${id}` as any, {} as any)) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const item = (resp as any)?.data ?? resp - display(this, item, flags.output) + const resp = await apiGet<{data?: T}>(client, `${config.apiPath}/${id}`) + display(this, resp.data ?? resp, flags.output) } } return GetCmd } -export function createCreateCommand(config: ResourceConfig) { +export function createCreateCommand(config: ResourceConfig) { const resourceFlags = config.createFlags ?? {} class CreateCmd extends Command { static description = `Create a new ${config.name}` @@ -72,18 +69,15 @@ export function createCreateCommand(config: ResourceConfig) { const client = buildClient(flags) const raw = extractResourceFlags(flags, Object.keys(resourceFlags)) const body = config.bodyBuilder ? config.bodyBuilder(raw) : raw - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.POST(config.apiPath as any, {body: body as any})) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const item = (resp as any)?.data ?? resp - display(this, item, flags.output) + const resp = await apiPost<{data?: T}>(client, config.apiPath, body) + display(this, resp.data ?? resp, flags.output) } } return CreateCmd } -export function createUpdateCommand(config: ResourceConfig) { +export function createUpdateCommand(config: ResourceConfig) { const idLabel = config.idField ?? 'id' const resourceFlags = config.updateFlags ?? config.createFlags ?? {} class UpdateCmd extends Command { @@ -97,19 +91,17 @@ export function createUpdateCommand(config: ResourceConfig) { const client = buildClient(flags) const id = args[idLabel] const raw = extractResourceFlags(flags, Object.keys(resourceFlags)) - const body = config.bodyBuilder ? config.bodyBuilder(raw) : raw - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const resp = await checkedFetch(client.PUT(`${config.apiPath}/${id}` as any, {body: body as any})) - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const item = (resp as any)?.data ?? resp - display(this, item, flags.output) + const builder = config.updateBodyBuilder ?? config.bodyBuilder + const body = builder ? builder(raw) : raw + const resp = await apiPut<{data?: T}>(client, `${config.apiPath}/${id}`, body) + display(this, resp.data ?? resp, flags.output) } } return UpdateCmd } -export function createDeleteCommand(config: ResourceConfig) { +export function createDeleteCommand(config: ResourceConfig) { const idLabel = config.idField ?? 'id' class DeleteCmd extends Command { static description = `Delete a ${config.name}` @@ -121,8 +113,7 @@ export function createDeleteCommand(config: ResourceConfig) { const {args, flags} = await this.parse(DeleteCmd) const client = buildClient(flags) const id = args[idLabel] - // eslint-disable-next-line @typescript-eslint/no-explicit-any - await checkedFetch(client.DELETE(`${config.apiPath}/${id}` as any, {} as any)) + await apiDelete(client, `${config.apiPath}/${id}`) this.log(`${config.name} '${id}' deleted.`) } } diff --git a/src/lib/descriptions.generated.ts b/src/lib/descriptions.generated.ts index 7b5d48d..8a702f1 100644 --- a/src/lib/descriptions.generated.ts +++ b/src/lib/descriptions.generated.ts @@ -45,10 +45,10 @@ export const fieldDescriptions: Record> = "priority": "Evaluation priority; higher value = evaluated first (default 0)" }, "UpdateNotificationPolicyRequest": { - "name": "Human-readable name for this policy", + "name": "Human-readable name for this policy; null preserves current", "matchRules": "Match rules to evaluate (all must pass; omit or empty for catch-all)", - "enabled": "Whether this policy is enabled", - "priority": "Evaluation priority; higher value = evaluated first" + "enabled": "Whether this policy is enabled; null preserves current", + "priority": "Evaluation priority; higher value = evaluated first; null preserves current" }, "CreateEnvironmentRequest": { "name": "Human-readable environment name", @@ -129,6 +129,10 @@ export const fieldDescriptions: Record> = "type": "Monitor protocol type to test", "assertions": "Optional assertions to evaluate against the test result" }, + "AcquireDeployLockRequest": { + "lockedBy": "Identity of the lock requester (e.g. hostname, CI job ID)", + "ttlMinutes": "Lock TTL in minutes (default: 30, max: 60)" + }, "HttpMonitorConfig": { "url": "Target URL to send requests to", "method": "HTTP method: GET, POST, PUT, PATCH, DELETE, or HEAD", diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 7750587..deb66ac 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -1,5 +1,3 @@ -import {ApiRequestError} from './api-client.js' - export const EXIT_CODES = { SUCCESS: 0, GENERAL: 1, @@ -7,6 +5,8 @@ export const EXIT_CODES = { API: 3, VALIDATION: 4, NOT_FOUND: 5, + CHANGES_PENDING: 10, + PARTIAL_FAILURE: 11, } as const export class DevhelmError extends Error { @@ -39,19 +39,3 @@ export class NotFoundError extends DevhelmError { this.name = 'NotFoundError' } } - -export function handleApiError(error: unknown): never { - if (error instanceof ApiRequestError) { - if (error.status === 401 || error.status === 403) { - throw new AuthError(`Authentication failed: ${error.message}`) - } - - if (error.status === 404) { - throw new DevhelmError(error.message, EXIT_CODES.NOT_FOUND) - } - - throw new DevhelmError(error.message, EXIT_CODES.API) - } - - throw error -} diff --git a/src/lib/output.ts b/src/lib/output.ts index 55dddb6..6c0fee9 100644 --- a/src/lib/output.ts +++ b/src/lib/output.ts @@ -3,6 +3,7 @@ import {stringify as yamlStringify} from 'yaml' export type OutputFormat = 'table' | 'json' | 'yaml' +// eslint-disable-next-line @typescript-eslint/no-explicit-any -- contravariant T makes unknown impractical here export interface ColumnDef { header: string get: (row: T) => string diff --git a/src/lib/resource-types.ts b/src/lib/resource-types.ts new file mode 100644 index 0000000..7914dd6 --- /dev/null +++ b/src/lib/resource-types.ts @@ -0,0 +1,94 @@ +/** + * Central registry mapping each resource key to its generated OpenAPI types. + * + * This single definition drives type safety across CRUD commands, the YAML + * engine, and the diff/apply layer. All DTO, create-request, and + * update-request types flow from here — no manual duplication. + */ +import type {components} from './api.generated.js' + +type Schemas = components['schemas'] + +export type ResourceKey = + | 'monitor' + | 'incident' + | 'alertChannel' + | 'notificationPolicy' + | 'environment' + | 'secret' + | 'tag' + | 'resourceGroup' + | 'webhook' + | 'apiKey' + | 'serviceSubscription' + +export interface ResourceTypeEntry< + TDto, + TCreate = never, + TUpdate = never, +> { + dto: TDto + create: TCreate + update: TUpdate +} + +export interface ResourceTypeMap { + monitor: ResourceTypeEntry< + Schemas['MonitorDto'], + Schemas['CreateMonitorRequest'], + Schemas['UpdateMonitorRequest'] + > + incident: ResourceTypeEntry< + Schemas['IncidentDto'], + Schemas['CreateManualIncidentRequest'] + > + alertChannel: ResourceTypeEntry< + Schemas['AlertChannelDto'], + Schemas['CreateAlertChannelRequest'], + Schemas['UpdateAlertChannelRequest'] + > + notificationPolicy: ResourceTypeEntry< + Schemas['NotificationPolicyDto'], + Schemas['CreateNotificationPolicyRequest'], + Schemas['UpdateNotificationPolicyRequest'] + > + environment: ResourceTypeEntry< + Schemas['EnvironmentDto'], + Schemas['CreateEnvironmentRequest'], + Schemas['UpdateEnvironmentRequest'] + > + secret: ResourceTypeEntry< + Schemas['SecretDto'], + Schemas['CreateSecretRequest'], + Schemas['UpdateSecretRequest'] + > + tag: ResourceTypeEntry< + Schemas['TagDto'], + Schemas['CreateTagRequest'], + Schemas['UpdateTagRequest'] + > + resourceGroup: ResourceTypeEntry< + Schemas['ResourceGroupDto'], + Schemas['CreateResourceGroupRequest'], + Schemas['UpdateResourceGroupRequest'] + > + webhook: ResourceTypeEntry< + Schemas['WebhookEndpointDto'], + Schemas['CreateWebhookEndpointRequest'], + Schemas['UpdateWebhookEndpointRequest'] + > + apiKey: ResourceTypeEntry< + Schemas['ApiKeyDto'], + Schemas['CreateApiKeyRequest'] + > + serviceSubscription: ResourceTypeEntry< + Schemas['ServiceSubscriptionDto'] + > +} + +/** Convenience: extract the DTO type for a given resource key. */ +export type DtoOf = ResourceTypeMap[K]['dto'] +/** Convenience: extract the create request type for a given resource key. */ +export type CreateOf = ResourceTypeMap[K]['create'] +/** Convenience: extract the update request type for a given resource key. */ +export type UpdateOf = ResourceTypeMap[K]['update'] diff --git a/src/lib/resources.ts b/src/lib/resources.ts index 9334c55..deb18ce 100644 --- a/src/lib/resources.ts +++ b/src/lib/resources.ts @@ -31,6 +31,7 @@ type CreateMonitorRequest = Schemas['CreateMonitorRequest'] type CreateManualIncidentRequest = Schemas['CreateManualIncidentRequest'] type CreateAlertChannelRequest = Schemas['CreateAlertChannelRequest'] type CreateNotificationPolicyRequest = Schemas['CreateNotificationPolicyRequest'] +type UpdateNotificationPolicyRequest = Schemas['UpdateNotificationPolicyRequest'] type CreateApiKeyRequest = Schemas['CreateApiKeyRequest'] const MONITOR_TYPES: MonitorType[] = ['HTTP', 'DNS', 'TCP', 'ICMP', 'HEARTBEAT', 'MCP_SERVER'] @@ -39,13 +40,13 @@ const INCIDENT_SEVERITIES: IncidentSeverity[] = ['DOWN', 'DEGRADED', 'MAINTENANC const CHANNEL_TYPES = ['SLACK', 'EMAIL', 'PAGERDUTY', 'OPSGENIE', 'DISCORD', 'TEAMS', 'WEBHOOK'] as const const CHANNEL_TYPE_MAP: Record = { - SLACK: 'slack', - EMAIL: 'email', - PAGERDUTY: 'pagerduty', - OPSGENIE: 'opsgenie', - DISCORD: 'discord', - TEAMS: 'teams', - WEBHOOK: 'webhook', + SLACK: 'SlackChannelConfig', + EMAIL: 'EmailChannelConfig', + PAGERDUTY: 'PagerDutyChannelConfig', + OPSGENIE: 'OpsGenieChannelConfig', + DISCORD: 'DiscordChannelConfig', + TEAMS: 'TeamsChannelConfig', + WEBHOOK: 'WebhookChannelConfig', } // ── Resource definitions ─────────────────────────────────────────────── @@ -86,7 +87,7 @@ export const MONITORS: ResourceConfig = { method: Flags.string({description: desc('HttpMonitorConfig', 'method'), options: HTTP_METHODS}), port: Flags.string({description: desc('TcpMonitorConfig', 'port', 'TCP port to connect to')}), }, - bodyBuilder: (raw): CreateMonitorRequest | Record => { + bodyBuilder: (raw) => { const monitorType = raw.type as MonitorType | undefined if (monitorType) { const body: CreateMonitorRequest = { @@ -99,34 +100,40 @@ export const MONITORS: ResourceConfig = { if (raw.regions) { body.regions = String(raw.regions).split(',').map((s) => s.trim()).filter(Boolean) } - return body as unknown as Record + return body } const body: Record = {} if (raw.name !== undefined) body.name = raw.name if (raw.frequency) body.frequencySeconds = Number(raw.frequency) if (raw.url !== undefined || raw.method !== undefined) { - body.config = {monitorType: 'HTTP', url: raw.url, method: (raw.method as HttpMethod) || 'GET'} + body.config = {url: raw.url, method: (raw.method as HttpMethod) || 'GET'} } return body }, } +/** + * Generated config types extend `Record` (OAS generator artifact for + * abstract base class MonitorConfig), which prevents direct object literal assignment. + * The single cast at the end is the narrowest workaround. + */ function buildMonitorConfig(type: MonitorType, raw: Record): CreateMonitorRequest['config'] { + const method: HttpMethod = (raw.method as HttpMethod) || 'GET' switch (type) { case 'HTTP': - return {url: String(raw.url ?? ''), method: (raw.method as HttpMethod) || 'GET'} as unknown as Schemas['HttpMonitorConfig'] + return {url: String(raw.url ?? ''), method} as CreateMonitorRequest['config'] case 'TCP': - return {host: String(raw.url ?? ''), port: raw.port ? Number(raw.port) : 443} as unknown as Schemas['TcpMonitorConfig'] + return {host: String(raw.url ?? ''), port: raw.port ? Number(raw.port) : 443} as CreateMonitorRequest['config'] case 'DNS': - return {hostname: String(raw.url ?? '')} as unknown as Schemas['DnsMonitorConfig'] + return {hostname: String(raw.url ?? '')} as CreateMonitorRequest['config'] case 'ICMP': - return {host: String(raw.url ?? '')} as unknown as Schemas['IcmpMonitorConfig'] + return {host: String(raw.url ?? '')} as CreateMonitorRequest['config'] case 'HEARTBEAT': - return {expectedInterval: 60, gracePeriod: 60} as unknown as Schemas['HeartbeatMonitorConfig'] + return {expectedInterval: 60, gracePeriod: 60} as CreateMonitorRequest['config'] case 'MCP_SERVER': - return {command: String(raw.url ?? '')} as unknown as Schemas['McpServerMonitorConfig'] + return {command: String(raw.url ?? '')} as CreateMonitorRequest['config'] default: - return {url: String(raw.url ?? ''), method: 'GET'} as unknown as Schemas['HttpMonitorConfig'] + return {url: String(raw.url ?? ''), method: 'GET'} as CreateMonitorRequest['config'] } } @@ -152,14 +159,14 @@ export const INCIDENTS: ResourceConfig = { 'monitor-id': Flags.string({description: desc('CreateManualIncidentRequest', 'monitorId')}), body: Flags.string({description: desc('CreateManualIncidentRequest', 'body')}), }, - bodyBuilder: (raw): Record => { + bodyBuilder: (raw) => { const body: CreateManualIncidentRequest = { title: String(raw.title), severity: raw.severity as IncidentSeverity, } if (raw['monitor-id'] !== undefined) body.monitorId = String(raw['monitor-id']) if (raw.body !== undefined) body.body = String(raw.body) - return body as unknown as Record + return body }, } @@ -190,7 +197,7 @@ export const ALERT_CHANNELS: ResourceConfig = { config: Flags.string({description: 'Channel-specific configuration as JSON'}), 'webhook-url': Flags.string({description: desc('SlackChannelConfig', 'webhookUrl', 'Slack/Discord/Teams webhook URL')}), }, - bodyBuilder: (raw): Record => { + bodyBuilder: (raw) => { let config: CreateAlertChannelRequest['config'] | undefined if (raw.config) { config = JSON.parse(String(raw.config)) as CreateAlertChannelRequest['config'] @@ -203,8 +210,8 @@ export const ALERT_CHANNELS: ResourceConfig = { config = {channelType} as CreateAlertChannelRequest['config'] } } - const body: Record = {} - if (raw.name !== undefined) body.name = raw.name + const body: Partial = {} + if (raw.name !== undefined) body.name = String(raw.name) if (config !== undefined) body.config = config return body }, @@ -230,7 +237,7 @@ export const NOTIFICATION_POLICIES: ResourceConfig = { 'channel-ids': Flags.string({description: desc('EscalationStep', 'channelIds', 'Comma-separated alert channel IDs')}), enabled: Flags.boolean({description: desc('UpdateNotificationPolicyRequest', 'enabled'), allowNo: true}), }, - bodyBuilder: (raw): Record => { + bodyBuilder: (raw) => { const channelIds = raw['channel-ids'] ? String(raw['channel-ids']).split(',').map((s) => s.trim()).filter(Boolean) : [] @@ -240,7 +247,17 @@ export const NOTIFICATION_POLICIES: ResourceConfig = { enabled: (raw.enabled as boolean) ?? true, priority: 0, } - return body as unknown as Record + return body + }, + updateBodyBuilder: (raw) => { + const body: Partial = {} + if (raw.name !== undefined) body.name = String(raw.name) + if (raw.enabled !== undefined) body.enabled = raw.enabled as boolean + if (raw['channel-ids'] !== undefined) { + const channelIds = String(raw['channel-ids']).split(',').map((s) => s.trim()).filter(Boolean) + body.escalation = {steps: [{channelIds, delayMinutes: 0}]} + } + return body }, } @@ -258,11 +275,9 @@ export const ENVIRONMENTS: ResourceConfig = { createFlags: { name: Flags.string({description: desc('CreateEnvironmentRequest', 'name'), required: true}), slug: Flags.string({description: desc('CreateEnvironmentRequest', 'slug'), required: true}), - color: Flags.string({description: desc('CreateTagRequest', 'color', 'Color hex code')}), }, updateFlags: { name: Flags.string({description: desc('UpdateEnvironmentRequest', 'name')}), - color: Flags.string({description: 'New color hex code'}), }, } @@ -345,13 +360,13 @@ export const WEBHOOKS: ResourceConfig = { events: Flags.string({description: desc('UpdateWebhookEndpointRequest', 'subscribedEvents')}), description: Flags.string({description: desc('UpdateWebhookEndpointRequest', 'description')}), }, - bodyBuilder: (raw): Record => { - const body: Record = {} - if (raw.url !== undefined) body.url = raw.url + bodyBuilder: (raw) => { + const body: Partial = {} + if (raw.url !== undefined) body.url = String(raw.url) if (raw.events !== undefined) { body.subscribedEvents = String(raw.events).split(',').map((s) => s.trim()).filter(Boolean) } - if (raw.description !== undefined) body.description = raw.description + if (raw.description !== undefined) body.description = String(raw.description) return body }, } @@ -371,10 +386,10 @@ export const API_KEYS: ResourceConfig = { name: Flags.string({description: desc('CreateApiKeyRequest', 'name'), required: true}), 'expires-at': Flags.string({description: desc('CreateApiKeyRequest', 'expiresAt')}), }, - bodyBuilder: (raw): Record => { + bodyBuilder: (raw) => { const body: CreateApiKeyRequest = {name: String(raw.name)} if (raw['expires-at'] !== undefined) body.expiresAt = String(raw['expires-at']) - return body as unknown as Record + return body }, } @@ -382,6 +397,7 @@ export const DEPENDENCIES: ResourceConfig = { name: 'dependency', plural: 'dependencies', apiPath: '/api/v1/service-subscriptions', + idField: 'subscriptionId', columns: [ {header: 'ID', get: (r) => r.subscriptionId ?? ''}, {header: 'SERVICE', get: (r) => r.name ?? ''}, diff --git a/src/lib/typed-api.ts b/src/lib/typed-api.ts new file mode 100644 index 0000000..f640482 --- /dev/null +++ b/src/lib/typed-api.ts @@ -0,0 +1,32 @@ +/** + * Pagination helper for Spring Boot Pageable endpoints. + * + * Uses `apiGet` from api-client (which centralizes the dynamic-path cast) + * to iterate through pages until `hasNext` is false. + */ +import type {ApiClient} from './api-client.js' +import {apiGet} from './api-client.js' + +interface PaginatedResponse { + data?: T[] + hasNext?: boolean +} + +const API_PAGE_SIZE = 200 + +export async function fetchPaginated( + client: ApiClient, + path: string, +): Promise { + const results: TItem[] = [] + let page = 0 + + while (true) { + const resp = await apiGet>(client, path, {query: {page, size: API_PAGE_SIZE}}) + results.push(...(resp.data ?? [])) + if (!resp.hasNext) break + page++ + } + + return results +} diff --git a/src/lib/yaml/applier.ts b/src/lib/yaml/applier.ts new file mode 100644 index 0000000..76ac97b --- /dev/null +++ b/src/lib/yaml/applier.ts @@ -0,0 +1,166 @@ +/** + * Executes a changeset against the API in dependency order. + * + * Delegates all per-resource-type create/update/delete operations to typed + * handlers in handlers.ts — no switch/case or `as YamlFoo` casts here. + */ +import type {ApiClient} from '../api-client.js' +import {checkedFetch, apiDelete} from '../api-client.js' +import {HANDLER_MAP} from './handlers.js' +import type {Changeset, Change, HandledResourceType} from './types.js' +import type {ResolvedRefs} from './resolver.js' +import type {StateEntry} from './state.js' + +export interface ApplyResult { + succeeded: AppliedChange[] + failed: FailedChange[] + stateEntries: StateEntry[] +} + +export interface AppliedChange { + action: string + resourceType: string + refKey: string + id?: string +} + +export interface FailedChange { + action: string + resourceType: string + refKey: string + error: string +} + +/** + * Apply the changeset to the API. Returns results with successes/failures. + * Updates refs in-place as new resources are created (for downstream refs). + */ +export async function apply( + changeset: Changeset, + refs: ResolvedRefs, + client: ApiClient, +): Promise { + const succeeded: AppliedChange[] = [] + const failed: FailedChange[] = [] + const stateEntries: StateEntry[] = [] + + for (const change of changeset.creates) { + try { + const handler = lookupHandler(change.resourceType, 'create') + const id = await handler.applyCreate(change.desired, refs, client) + if (id) { + refs.set(handler.refType, change.refKey, { + id, refKey: change.refKey, raw: change.desired as Record, + }) + stateEntries.push({ + resourceType: change.resourceType, + refKey: change.refKey, + id, + createdAt: new Date().toISOString(), + }) + succeeded.push({action: 'create', resourceType: change.resourceType, refKey: change.refKey, id}) + } else { + failed.push({ + action: 'create', resourceType: change.resourceType, + refKey: change.refKey, error: 'Create succeeded but API returned no resource ID', + }) + } + } catch (err) { + failed.push({ + action: 'create', resourceType: change.resourceType, + refKey: change.refKey, error: errorMessage(err), + }) + } + } + + for (const change of changeset.updates) { + try { + const handler = lookupHandler(change.resourceType, 'update') + await handler.applyUpdate(change.desired, change.existingId!, refs, client) + succeeded.push({action: 'update', resourceType: change.resourceType, refKey: change.refKey, id: change.existingId}) + if (change.existingId) { + stateEntries.push({ + resourceType: change.resourceType, + refKey: change.refKey, + id: change.existingId, + createdAt: new Date().toISOString(), + }) + } + } catch (err) { + failed.push({ + action: 'update', resourceType: change.resourceType, + refKey: change.refKey, error: errorMessage(err), + }) + } + } + + for (const change of changeset.deletes) { + try { + const handler = lookupHandler(change.resourceType, 'delete') + await apiDelete(client, handler.deletePath(change.existingId!, change.refKey)) + succeeded.push({action: 'delete', resourceType: change.resourceType, refKey: change.refKey}) + } catch (err) { + failed.push({ + action: 'delete', resourceType: change.resourceType, + refKey: change.refKey, error: errorMessage(err), + }) + } + } + + for (const change of changeset.memberships) { + try { + await applyMembership(change, refs, client) + const icon = change.action === 'delete' ? 'remove' : 'add' + succeeded.push({action: icon, resourceType: 'groupMembership', refKey: change.refKey}) + } catch (err) { + failed.push({ + action: change.action, resourceType: 'groupMembership', + refKey: change.refKey, error: errorMessage(err), + }) + } + } + + return {succeeded, failed, stateEntries} +} + +interface MembershipCreatePayload { + groupName: string + memberType: string + memberRef: string +} + +interface MembershipDeletePayload { + groupId: string + memberId: string +} + +async function applyMembership(change: Change, refs: ResolvedRefs, client: ApiClient): Promise { + if (change.action === 'delete') { + const payload = change.desired as MembershipDeletePayload + await apiDelete(client, `/api/v1/resource-groups/${payload.groupId}/members/${payload.memberId}`) + return + } + + const desired = change.desired as MembershipCreatePayload + const groupId = refs.require('resourceGroups', desired.groupName) + const memberType = desired.memberType + + let memberId: string + if (memberType === 'monitor') { + memberId = refs.require('monitors', desired.memberRef) + } else { + memberId = refs.require('dependencies', desired.memberRef) + } + + await checkedFetch(client.POST('/api/v1/resource-groups/{id}/members', {params: {path: {id: groupId}}, body: {memberType, memberId}})) +} + +function lookupHandler(resourceType: string, action: string) { + const handler = HANDLER_MAP[resourceType as HandledResourceType] + if (!handler) throw new Error(`Unknown resource type for ${action}: ${resourceType}`) + return handler +} + +function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err) +} diff --git a/src/lib/yaml/differ.ts b/src/lib/yaml/differ.ts new file mode 100644 index 0000000..181849c --- /dev/null +++ b/src/lib/yaml/differ.ts @@ -0,0 +1,236 @@ +/** + * Diff engine: compares desired state (YAML) against current state (API) + * and produces an ordered changeset. + * + * Delegates all per-resource-type semantic comparison to typed handlers + * in handlers.ts — no Record anywhere in this file. + */ +import type {components} from '../api.generated.js' +import type {DevhelmConfig} from './schema.js' +import type {ResolvedRefs} from './resolver.js' +import {allHandlers, type ResourceHandler} from './handlers.js' +import type {Change, Changeset, DiffOptions} from './types.js' +import {RESOURCE_ORDER} from './types.js' + +type ResourceGroupDto = components['schemas']['ResourceGroupDto'] +type ResourceGroupMemberDto = components['schemas']['ResourceGroupMemberDto'] + +// Re-export types so existing consumers don't need to change imports +export type {ChangeAction, ResourceType, Change, DiffOptions, Changeset} from './types.js' + +// ── Main diff function ───────────────────────────────────────────────── + +export function diff(config: DevhelmConfig, refs: ResolvedRefs, options: DiffOptions = {}): Changeset { + const creates: Change[] = [] + const updates: Change[] = [] + const deletes: Change[] = [] + const memberships: Change[] = [] + + for (const handler of allHandlers()) { + diffSection(handler, config[handler.configKey], refs, creates, updates, deletes, options) + } + + diffMemberships(config, refs, memberships, options) + + creates.sort((a, b) => RESOURCE_ORDER.indexOf(a.resourceType) - RESOURCE_ORDER.indexOf(b.resourceType)) + updates.sort((a, b) => RESOURCE_ORDER.indexOf(a.resourceType) - RESOURCE_ORDER.indexOf(b.resourceType)) + deletes.sort((a, b) => RESOURCE_ORDER.indexOf(b.resourceType) - RESOURCE_ORDER.indexOf(a.resourceType)) + + return {creates, updates, deletes, memberships} +} + +// ── Generic diff section ──────────────────────────────────────────────── + +function diffSection( + handler: ResourceHandler, + items: unknown[] | undefined, + refs: ResolvedRefs, + creates: Change[], + updates: Change[], + deletes: Change[], + options: DiffOptions, +): void { + const desired = new Set() + + for (const item of items ?? []) { + const refKey = handler.getRefKey(item) + desired.add(refKey) + const existing = refs.get(handler.refType, refKey) + + if (existing) { + if (handler.hasChanged(item, existing.raw, refs)) { + updates.push({ + action: 'update', + resourceType: handler.resourceType, + refKey, + existingId: existing.id, + desired: item, + current: existing.raw, + }) + } + } else { + creates.push({ + action: 'create', + resourceType: handler.resourceType, + refKey, + desired: item, + }) + } + } + + if ((options.prune || options.pruneAll) && items !== undefined) { + for (const entry of refs.allEntries(handler.refType)) { + if (!desired.has(entry.refKey)) { + if (handler.resourceType === 'monitor' && !options.pruneAll && entry.managedBy !== 'CLI') continue + deletes.push({ + action: 'delete', + resourceType: handler.resourceType, + refKey: entry.refKey, + existingId: entry.id, + current: entry.raw, + }) + } + } + } +} + +// ── Membership diff ──────────────────────────────────────────────────── + +function memberKey(memberType: string, nameOrSlug: string): string { + return `${memberType}:${nameOrSlug}` +} + +function diffMemberships( + config: DevhelmConfig, + refs: ResolvedRefs, + memberships: Change[], + options: DiffOptions, +): void { + for (const group of config.resourceGroups ?? []) { + const groupEntry = refs.get('resourceGroups', group.name) + const currentMembers = new Map() + + if (groupEntry) { + const dto = groupEntry.raw as ResourceGroupDto + for (const m of dto.members ?? []) { + if (m.memberType === 'monitor' && m.name) { + currentMembers.set(memberKey('monitor', m.name), m) + } else if (m.memberType === 'service' && m.slug) { + currentMembers.set(memberKey('service', m.slug), m) + } + } + } + + const desired = new Set() + + for (const monitorName of group.monitors ?? []) { + const key = memberKey('monitor', monitorName) + desired.add(key) + if (!currentMembers.has(key)) { + memberships.push({ + action: 'create', + resourceType: 'groupMembership', + refKey: `${group.name} → ${monitorName}`, + desired: {groupName: group.name, memberType: 'monitor', memberRef: monitorName}, + }) + } + } + + for (const serviceSlug of group.services ?? []) { + const key = memberKey('service', serviceSlug) + desired.add(key) + if (!currentMembers.has(key)) { + memberships.push({ + action: 'create', + resourceType: 'groupMembership', + refKey: `${group.name} → ${serviceSlug}`, + desired: {groupName: group.name, memberType: 'service', memberRef: serviceSlug}, + }) + } + } + + if (options.prune && groupEntry) { + for (const [key, member] of currentMembers) { + if (!desired.has(key)) { + const label = member.name ?? member.slug ?? member.id ?? 'unknown' + memberships.push({ + action: 'delete', + resourceType: 'groupMembership', + refKey: `${group.name} → ${label}`, + existingId: member.id, + desired: {groupId: groupEntry.id, memberId: member.id}, + }) + } + } + } + } +} + +// ── JSON serialization ───────────────────────────────────────────────── + +export interface ChangesetJson { + format_version: string + creates: ChangeJson[] + updates: ChangeJson[] + deletes: ChangeJson[] + memberships: ChangeJson[] + summary: {creates: number; updates: number; deletes: number; memberships: number} +} + +interface ChangeJson { + action: string + resource_type: string + ref_key: string + existing_id?: string +} + +function changeToJson(c: Change): ChangeJson { + const out: ChangeJson = {action: c.action, resource_type: c.resourceType, ref_key: c.refKey} + if (c.existingId) out.existing_id = c.existingId + return out +} + +export function changesetToJson(changeset: Changeset): ChangesetJson { + return { + format_version: '1', + creates: changeset.creates.map(changeToJson), + updates: changeset.updates.map(changeToJson), + deletes: changeset.deletes.map(changeToJson), + memberships: changeset.memberships.map(changeToJson), + summary: { + creates: changeset.creates.length, + updates: changeset.updates.length, + deletes: changeset.deletes.length, + memberships: changeset.memberships.length, + }, + } +} + +// ── Plan formatting ──────────────────────────────────────────────────── + +export function formatPlan(changeset: Changeset): string { + const lines: string[] = [] + const totalChanges = changeset.creates.length + changeset.updates.length + changeset.deletes.length + changeset.memberships.length + + if (totalChanges === 0) { + return 'No changes. Infrastructure is up-to-date.' + } + + lines.push(`Plan: ${changeset.creates.length} to create, ${changeset.updates.length} to update, ${changeset.deletes.length} to delete, ${changeset.memberships.length} memberships\n`) + + for (const c of changeset.creates) { + lines.push(` + ${c.resourceType} "${c.refKey}"`) + } + for (const c of changeset.updates) { + lines.push(` ~ ${c.resourceType} "${c.refKey}"`) + } + for (const c of changeset.deletes) { + lines.push(` - ${c.resourceType} "${c.refKey}"`) + } + for (const c of changeset.memberships) { + const icon = c.action === 'delete' ? '- membership' : '→' + lines.push(` ${icon} ${c.refKey}`) + } + + return lines.join('\n') +} diff --git a/src/lib/yaml/entitlements.ts b/src/lib/yaml/entitlements.ts new file mode 100644 index 0000000..2e5232e --- /dev/null +++ b/src/lib/yaml/entitlements.ts @@ -0,0 +1,104 @@ +/** + * Pre-flight entitlement check: fetches /auth/me and compares + * planned resource creation against plan limits. + */ +import type {ApiClient} from '../api-client.js' +import {checkedFetch} from '../api-client.js' +import type {components} from '../api.generated.js' +import type {Changeset} from './types.js' + +type AuthMeResponse = components['schemas']['AuthMeResponse'] + +export interface EntitlementWarning { + resource: string + current: number + creating: number + limit: number +} + +export interface EntitlementCheck { + plan: string + warnings: EntitlementWarning[] + header: string +} + +const UNLIMITED = Number.MAX_SAFE_INTEGER + +const RESOURCE_ENTITLEMENT_MAP: Record = { + monitor: 'monitors', + alertChannel: 'alert_channels', + notificationPolicy: 'notification_policies', + webhook: 'webhooks', + resourceGroup: 'resource_groups', + environment: 'environments', + secret: 'secrets', +} + +/** + * Fetch plan entitlements and check if the changeset would exceed any limits. + * Returns null if /auth/me is unavailable (non-API-key tokens). + */ +export async function checkEntitlements( + client: ApiClient, + changeset: Changeset, +): Promise { + let data: AuthMeResponse + try { + const resp = await checkedFetch<{data?: AuthMeResponse}>(client.GET('/api/v1/auth/me')) + data = resp.data ?? {} + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + process.stderr.write(`Entitlement check skipped: ${msg}\n`) + return null + } + + const plan = data.plan + if (!plan?.entitlements || !plan.usage) return null + + const createCounts = new Map() + for (const create of changeset.creates) { + const entitlementKey = RESOURCE_ENTITLEMENT_MAP[create.resourceType] + if (entitlementKey) { + createCounts.set(entitlementKey, (createCounts.get(entitlementKey) ?? 0) + 1) + } + } + + const warnings: EntitlementWarning[] = [] + + for (const [entitlementKey, createsOfType] of createCounts) { + const entitlement = plan.entitlements[entitlementKey] + const limit = entitlement?.value + if (limit == null || limit >= UNLIMITED) continue + + const currentUsage = plan.usage[entitlementKey] ?? 0 + if (currentUsage + createsOfType > limit) { + warnings.push({ + resource: entitlementKey, + current: currentUsage, + creating: createsOfType, + limit, + }) + } + } + + const tier = plan.tier ?? 'unknown' + const org = data.organization?.name ?? '' + const usageParts: string[] = [] + for (const [key, used] of Object.entries(plan.usage)) { + const limit = plan.entitlements[key]?.value + if (limit != null && limit < UNLIMITED) { + usageParts.push(`${key.replace(/_/g, ' ')}: ${used}/${limit}`) + } + } + + const header = `Plan: ${tier}${org ? ` (${org})` : ''}${usageParts.length ? ' | ' + usageParts.join(', ') : ''}` + + return {plan: tier, warnings, header} +} + +export function formatEntitlementWarnings(warnings: EntitlementWarning[]): string { + const lines = warnings.map((w) => + ` ⚠ ${w.resource}: deploying ${w.creating} new but only ${w.limit - w.current} remaining (${w.current}/${w.limit} used)`, + ) + return lines.join('\n') +} diff --git a/src/lib/yaml/handlers.ts b/src/lib/yaml/handlers.ts new file mode 100644 index 0000000..08821e8 --- /dev/null +++ b/src/lib/yaml/handlers.ts @@ -0,0 +1,674 @@ +/** + * Typed resource handlers — the single source of truth for each resource type's + * identity, semantic comparison, API operations, and list fetching. + * + * Every handler is defined with FULL TypeScript generics over its YAML input type + * (what the user writes in devhelm.yml), its API DTO type (what the API returns), + * and a Snapshot type used for drift detection. + * + * Drift detection uses "snapshot comparison": + * - toDesiredSnapshot(yaml, api, refs) → TSnapshot (what we WANT) + * - toCurrentSnapshot(api) → TSnapshot (what we HAVE) + * - hasChanged = !isEqual(desired, current) + * + * Snapshot types are derived from the OpenAPI-generated Update*Request schemas + * (via Required). This guarantees that when the API + * contract changes — a field is added, removed, or renamed — the TypeScript + * compiler immediately errors in the snapshot functions, preventing silent drift. + * + * Three resources use custom snapshot types because their update semantics + * don't map 1:1 to an UpdateXRequest schema: + * - secret: write-only value, compared by SHA-256 hash + * - alertChannel: complex config union, compared by content-addressed hash + * - dependency: no single update endpoint (split across two API calls) + * + * Adding a new resource type requires: + * 1. Adding it to HandledResourceType in types.ts + * 2. Implementing a handler here (with snapshot functions) + * 3. Adding it to HANDLER_MAP (TypeScript errors if you forget) + */ +import {createHash} from 'node:crypto' +import isEqual from 'lodash-es/isEqual.js' +import type {components} from '../api.generated.js' +import type {ApiClient} from '../api-client.js' +import type {ResolvedRefs} from './resolver.js' +import type {HandledResourceType, RefType} from './types.js' +import type { + YamlTag, YamlEnvironment, YamlSecret, YamlAlertChannel, + YamlNotificationPolicy, YamlWebhook, YamlResourceGroup, + YamlMonitor, YamlDependency, +} from './schema.js' +import type {YamlSectionKey} from './schema.js' +import { + toCreateTagRequest, toCreateEnvironmentRequest, toCreateSecretRequest, + toCreateAlertChannelRequest, toCreateNotificationPolicyRequest, + toCreateWebhookRequest, toCreateResourceGroupRequest, + toCreateMonitorRequest, toUpdateMonitorRequest, toAuthConfig, + toCreateAssertionRequest, toIncidentPolicy, +} from './transform.js' +import {fetchPaginated} from '../typed-api.js' +import {checkedFetch, apiPatch} from '../api-client.js' + +type Schemas = components['schemas'] + +// ── Public interface ──────────────────────────────────────────────────── + +/** + * Generic handler for a YAML-managed resource type. + * + * TYaml = the type the user writes in devhelm.yml (e.g. YamlTag) + * TApiDto = the DTO the API returns (e.g. TagDto) + * + * The registry stores ResourceHandler (defaults → unknown) for heterogeneous + * storage. defineHandler verifies all field accesses at compile time, + * then type-erases to the default form. + */ +export interface ResourceHandler { + readonly resourceType: HandledResourceType + readonly refType: RefType + readonly configKey: YamlSectionKey + readonly listPath: string + + getRefKey(yaml: TYaml): string + getApiRefKey(api: TApiDto): string + getApiId(api: TApiDto): string + getManagedBy?(api: TApiDto): string | undefined + + hasChanged(yaml: TYaml, api: TApiDto, refs: ResolvedRefs): boolean + + fetchAll(client: ApiClient): Promise + applyCreate(yaml: TYaml, refs: ResolvedRefs, client: ApiClient): Promise + applyUpdate(yaml: TYaml, existingId: string, refs: ResolvedRefs, client: ApiClient): Promise + deletePath(id: string, refKey: string): string +} + +// ── Handler definition (snapshot-based) ───────────────────────────────── + +/** + * Input shape for defineHandler. Handlers provide two snapshot functions + * that both return TSnapshot. hasChanged is automatically derived from + * snapshot comparison — handlers never implement it manually. + */ +interface HandlerDef { + readonly resourceType: HandledResourceType + readonly refType: RefType + readonly configKey: YamlSectionKey + readonly listPath: string + + getRefKey(yaml: TYaml): string + getApiRefKey(api: TApiDto): string + getApiId(api: TApiDto): string + getManagedBy?: (api: TApiDto) => string | undefined + + toDesiredSnapshot(yaml: TYaml, api: TApiDto, refs: ResolvedRefs): TSnapshot + toCurrentSnapshot(api: TApiDto): TSnapshot + + fetchAll(client: ApiClient): Promise + applyCreate(yaml: TYaml, refs: ResolvedRefs, client: ApiClient): Promise + applyUpdate(yaml: TYaml, existingId: string, refs: ResolvedRefs, client: ApiClient): Promise + deletePath(id: string, refKey: string): string +} + +/** + * Type-checking bridge: takes a handler definition with full generic types, + * derives hasChanged from snapshot comparison, then type-erases to + * ResourceHandler (defaults) for registry storage. + */ +function defineHandler( + h: HandlerDef, +): ResourceHandler { + const handler: ResourceHandler = { + resourceType: h.resourceType, + refType: h.refType, + configKey: h.configKey, + listPath: h.listPath, + + getRefKey: h.getRefKey, + getApiRefKey: h.getApiRefKey, + getApiId: h.getApiId, + getManagedBy: h.getManagedBy, + + hasChanged(yaml: TYaml, api: TApiDto, refs: ResolvedRefs): boolean { + return !isEqual(h.toDesiredSnapshot(yaml, api, refs), h.toCurrentSnapshot(api)) + }, + + fetchAll: h.fetchAll, + applyCreate: h.applyCreate, + applyUpdate: h.applyUpdate, + deletePath: h.deletePath, + } + return handler as unknown as ResourceHandler +} + +// ── Shared helpers ────────────────────────────────────────────────────── + +function nonNullStrings(arr: (string | null)[] | null | undefined): string[] { + return (arr ?? []).filter((v): v is string => v !== null) +} + +function sortedIds(ids: string[]): string[] { + return [...ids].sort() +} + +export function sha256Hex(input: string): string { + return createHash('sha256').update(input, 'utf8').digest('hex') +} + +/** + * Deterministic JSON serialization with alphabetically sorted keys at every + * nesting level. Produces the same output regardless of JS engine key + * insertion order, matching the Java-side TreeMap-based canonical JSON. + */ +export function stableStringify(obj: unknown): string { + if (obj === null || obj === undefined) return 'null' + if (typeof obj !== 'object') return JSON.stringify(obj) + if (Array.isArray(obj)) return '[' + obj.map(stableStringify).join(',') + ']' + const record = obj as Record + const keys = Object.keys(record).sort() + return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableStringify(record[k])).join(',') + '}' +} + +// ── Tag ───────────────────────────────────────────────────────────────── + +type TagSnapshot = Required + +const tagHandler = defineHandler({ + resourceType: 'tag', + refType: 'tags', + configKey: 'tags', + listPath: '/api/v1/tags', + + getRefKey: (yaml) => yaml.name, + getApiRefKey: (api) => api.name ?? '', + getApiId: (api) => String(api.id ?? ''), + + toDesiredSnapshot: (yaml, api) => ({ + name: yaml.name, + color: yaml.color ?? api.color ?? null, + }), + toCurrentSnapshot: (api) => ({ + name: api.name ?? null, + color: api.color ?? null, + }), + + fetchAll: (client) => fetchPaginated(client, '/api/v1/tags'), + + async applyCreate(yaml, _refs, client) { + const resp = await checkedFetch(client.POST('/api/v1/tags', {body: toCreateTagRequest(yaml)})) + return resp.data?.id ?? undefined + }, + async applyUpdate(yaml, id, _refs, client) { + await checkedFetch(client.PUT('/api/v1/tags/{id}', {params: {path: {id}}, body: toCreateTagRequest(yaml)})) + }, + deletePath: (id) => `/api/v1/tags/${id}`, +}) + +// ── Environment ───────────────────────────────────────────────────────── + +type EnvironmentSnapshot = Required + +const environmentHandler = defineHandler({ + resourceType: 'environment', + refType: 'environments', + configKey: 'environments', + listPath: '/api/v1/environments', + + getRefKey: (yaml) => yaml.slug, + getApiRefKey: (api) => api.slug ?? '', + getApiId: (api) => String(api.id ?? ''), + + toDesiredSnapshot: (yaml, api) => ({ + name: yaml.name, + isDefault: yaml.isDefault ?? api.isDefault ?? null, + variables: yaml.variables ?? api.variables ?? null, + }), + toCurrentSnapshot: (api) => ({ + name: api.name ?? null, + isDefault: api.isDefault ?? null, + variables: api.variables ?? null, + }), + + fetchAll: (client) => fetchPaginated(client, '/api/v1/environments'), + + async applyCreate(yaml, _refs, client) { + const resp = await checkedFetch(client.POST('/api/v1/environments', {body: toCreateEnvironmentRequest(yaml)})) + return resp.data?.id ?? undefined + }, + async applyUpdate(yaml, _id, _refs, client) { + await checkedFetch(client.PUT('/api/v1/environments/{slug}', {params: {path: {slug: yaml.slug}}, body: { + name: yaml.name, variables: yaml.variables ?? null, isDefault: yaml.isDefault, + }})) + }, + deletePath: (_id, refKey) => `/api/v1/environments/${refKey}`, +}) + +// ── Secret ────────────────────────────────────────────────────────────── + +// Custom snapshot: the API never returns the plaintext value (write-only), +// so we compare by SHA-256 hash instead of using UpdateSecretRequest. +type SecretSnapshot = { key: string; valueHash: string } + +const secretHandler = defineHandler({ + resourceType: 'secret', + refType: 'secrets', + configKey: 'secrets', + listPath: '/api/v1/secrets', + + getRefKey: (yaml) => yaml.key, + getApiRefKey: (api) => api.key ?? '', + getApiId: (api) => String(api.id ?? ''), + + toDesiredSnapshot: (yaml) => ({ + key: yaml.key, + valueHash: sha256Hex(yaml.value), + }), + toCurrentSnapshot: (api) => ({ + key: api.key ?? '', + valueHash: api.valueHash ?? '', + }), + + fetchAll: (client) => fetchPaginated(client, '/api/v1/secrets'), + + async applyCreate(yaml, _refs, client) { + const resp = await checkedFetch(client.POST('/api/v1/secrets', {body: toCreateSecretRequest(yaml)})) + return resp.data?.id ?? undefined + }, + async applyUpdate(yaml, _id, _refs, client) { + await checkedFetch(client.PUT('/api/v1/secrets/{key}', {params: {path: {key: yaml.key}}, body: {value: yaml.value}})) + }, + deletePath: (id) => `/api/v1/secrets/${id}`, +}) + +// ── Alert Channel ─────────────────────────────────────────────────────── + +// Custom snapshot: config is a complex discriminated union, compared by +// content-addressed SHA-256 hash (matching the API's configHash field). +type AlertChannelSnapshot = { name: string; channelType: string; configHash: string } + +const alertChannelHandler = defineHandler({ + resourceType: 'alertChannel', + refType: 'alertChannels', + configKey: 'alertChannels', + listPath: '/api/v1/alert-channels', + + getRefKey: (yaml) => yaml.name, + getApiRefKey: (api) => api.name, + getApiId: (api) => api.id, + + toDesiredSnapshot: (yaml) => { + const req = toCreateAlertChannelRequest(yaml) + return { + name: req.name, + channelType: yaml.type, + configHash: sha256Hex(stableStringify(req.config)), + } + }, + toCurrentSnapshot: (api) => ({ + name: api.name, + channelType: api.channelType?.toLowerCase?.() ?? '', + configHash: api.configHash ?? '', + }), + + fetchAll: (client) => fetchPaginated(client, '/api/v1/alert-channels'), + + async applyCreate(yaml, _refs, client) { + const resp = await checkedFetch(client.POST('/api/v1/alert-channels', {body: toCreateAlertChannelRequest(yaml)})) + return resp.data?.id ?? undefined + }, + async applyUpdate(yaml, id, _refs, client) { + await checkedFetch(client.PUT('/api/v1/alert-channels/{id}', {params: {path: {id}}, body: toCreateAlertChannelRequest(yaml)})) + }, + deletePath: (id) => `/api/v1/alert-channels/${id}`, +}) + +// ── Notification Policy ───────────────────────────────────────────────── + +type NotificationPolicySnapshot = Required + +const notificationPolicyHandler = defineHandler({ + resourceType: 'notificationPolicy', + refType: 'notificationPolicies', + configKey: 'notificationPolicies', + listPath: '/api/v1/notification-policies', + + getRefKey: (yaml) => yaml.name, + getApiRefKey: (api) => api.name ?? '', + getApiId: (api) => String(api.id ?? ''), + + toDesiredSnapshot: (yaml, api, refs) => { + const req = toCreateNotificationPolicyRequest(yaml, refs) + return { + name: req.name, + enabled: req.enabled ?? api.enabled ?? true, + priority: req.priority ?? api.priority ?? 0, + matchRules: req.matchRules ?? api.matchRules ?? [], + escalation: req.escalation, + } + }, + toCurrentSnapshot: (api) => ({ + name: api.name ?? '', + enabled: api.enabled ?? true, + priority: api.priority ?? 0, + matchRules: api.matchRules ?? [], + escalation: api.escalation ?? {steps: [], onResolve: null, onReopen: null}, + }), + + fetchAll: (client) => fetchPaginated(client, '/api/v1/notification-policies'), + + async applyCreate(yaml, refs, client) { + const resp = await checkedFetch(client.POST('/api/v1/notification-policies', {body: toCreateNotificationPolicyRequest(yaml, refs)})) + return resp.data?.id != null ? String(resp.data.id) : undefined + }, + async applyUpdate(yaml, id, refs, client) { + await checkedFetch(client.PUT('/api/v1/notification-policies/{id}', {params: {path: {id}}, body: toCreateNotificationPolicyRequest(yaml, refs)})) + }, + deletePath: (id) => `/api/v1/notification-policies/${id}`, +}) + +// ── Webhook ───────────────────────────────────────────────────────────── + +type WebhookSnapshot = Required + +const webhookHandler = defineHandler({ + resourceType: 'webhook', + refType: 'webhooks', + configKey: 'webhooks', + listPath: '/api/v1/webhooks', + + getRefKey: (yaml) => yaml.url, + getApiRefKey: (api) => api.url ?? '', + getApiId: (api) => String(api.id ?? ''), + + toDesiredSnapshot: (yaml, api) => ({ + url: yaml.url, + description: yaml.description ?? api.description ?? null, + subscribedEvents: sortedIds(yaml.events), + enabled: yaml.enabled ?? api.enabled ?? true, + }), + toCurrentSnapshot: (api) => ({ + url: api.url ?? null, + description: api.description ?? null, + subscribedEvents: api.subscribedEvents ? sortedIds(api.subscribedEvents) : null, + enabled: api.enabled ?? null, + }), + + fetchAll: (client) => fetchPaginated(client, '/api/v1/webhooks'), + + async applyCreate(yaml, _refs, client) { + const resp = await checkedFetch(client.POST('/api/v1/webhooks', {body: toCreateWebhookRequest(yaml)})) + return resp.data?.id ?? undefined + }, + async applyUpdate(yaml, id, _refs, client) { + await checkedFetch(client.PUT('/api/v1/webhooks/{id}', {params: {path: {id}}, body: { + ...toCreateWebhookRequest(yaml), + enabled: yaml.enabled ?? null, + }})) + }, + deletePath: (id) => `/api/v1/webhooks/${id}`, +}) + +// ── Resource Group ────────────────────────────────────────────────────── + +// defaultRetryStrategy is optional (not nullable) in the Update schema, +// but a group can legitimately have none, so we add | null. +type ResourceGroupSnapshotBase = Required +type ResourceGroupSnapshot = Omit & { + defaultRetryStrategy: ResourceGroupSnapshotBase['defaultRetryStrategy'] | null +} + +const resourceGroupHandler = defineHandler({ + resourceType: 'resourceGroup', + refType: 'resourceGroups', + configKey: 'resourceGroups', + listPath: '/api/v1/resource-groups', + + getRefKey: (yaml) => yaml.name, + getApiRefKey: (api) => api.name ?? '', + getApiId: (api) => String(api.id ?? ''), + + toDesiredSnapshot: (yaml, api, refs) => ({ + name: yaml.name, + description: yaml.description ?? api.description ?? null, + alertPolicyId: yaml.alertPolicy !== undefined + ? (refs.resolve('notificationPolicies', yaml.alertPolicy) ?? null) + : (api.alertPolicyId ?? null), + defaultFrequency: yaml.defaultFrequency ?? api.defaultFrequency ?? null, + defaultRegions: yaml.defaultRegions !== undefined + ? sortedIds(yaml.defaultRegions) + : (api.defaultRegions ? sortedIds(nonNullStrings(api.defaultRegions)) : null), + defaultRetryStrategy: yaml.defaultRetryStrategy ?? api.defaultRetryStrategy ?? null, + defaultAlertChannels: yaml.defaultAlertChannels !== undefined + ? sortedIds(yaml.defaultAlertChannels.map((n) => refs.resolve('alertChannels', n) ?? n)) + : (api.defaultAlertChannels ? sortedIds(nonNullStrings(api.defaultAlertChannels)) : null), + defaultEnvironmentId: yaml.defaultEnvironment !== undefined + ? (refs.resolve('environments', yaml.defaultEnvironment) ?? null) + : (api.defaultEnvironmentId ?? null), + healthThresholdType: yaml.healthThresholdType ?? api.healthThresholdType ?? null, + healthThresholdValue: yaml.healthThresholdValue ?? api.healthThresholdValue ?? null, + suppressMemberAlerts: yaml.suppressMemberAlerts ?? api.suppressMemberAlerts ?? null, + confirmationDelaySeconds: yaml.confirmationDelaySeconds ?? api.confirmationDelaySeconds ?? null, + recoveryCooldownMinutes: yaml.recoveryCooldownMinutes ?? api.recoveryCooldownMinutes ?? null, + }), + toCurrentSnapshot: (api) => ({ + name: api.name ?? '', + description: api.description ?? null, + alertPolicyId: api.alertPolicyId ?? null, + defaultFrequency: api.defaultFrequency ?? null, + defaultRegions: api.defaultRegions ? sortedIds(nonNullStrings(api.defaultRegions)) : null, + defaultRetryStrategy: api.defaultRetryStrategy ?? null, + defaultAlertChannels: api.defaultAlertChannels ? sortedIds(nonNullStrings(api.defaultAlertChannels)) : null, + defaultEnvironmentId: api.defaultEnvironmentId ?? null, + healthThresholdType: api.healthThresholdType ?? null, + healthThresholdValue: api.healthThresholdValue ?? null, + suppressMemberAlerts: api.suppressMemberAlerts ?? null, + confirmationDelaySeconds: api.confirmationDelaySeconds ?? null, + recoveryCooldownMinutes: api.recoveryCooldownMinutes ?? null, + }), + + fetchAll: (client) => fetchPaginated(client, '/api/v1/resource-groups'), + + async applyCreate(yaml, refs, client) { + const resp = await checkedFetch(client.POST('/api/v1/resource-groups', {body: toCreateResourceGroupRequest(yaml, refs)})) + return resp.data?.id ?? undefined + }, + async applyUpdate(yaml, id, refs, client) { + await checkedFetch(client.PUT('/api/v1/resource-groups/{id}', {params: {path: {id}}, body: toCreateResourceGroupRequest(yaml, refs)})) + }, + deletePath: (id) => `/api/v1/resource-groups/${id}`, +}) + +// ── Monitor ───────────────────────────────────────────────────────────── + +// Derived from UpdateMonitorRequest minus control-only fields (clearAuth, +// clearEnvironmentId, managedBy) that are mutation signals, not state. +// auth and incidentPolicy need | null because monitors can lack them. +type MonitorSnapshotBase = Required> +type MonitorSnapshot = Omit & { + auth: MonitorSnapshotBase['auth'] | null + incidentPolicy: MonitorSnapshotBase['incidentPolicy'] | null +} + +const monitorHandler = defineHandler({ + resourceType: 'monitor', + refType: 'monitors', + configKey: 'monitors', + listPath: '/api/v1/monitors', + + getRefKey: (yaml) => yaml.name, + getApiRefKey: (api) => api.name ?? '', + getApiId: (api) => String(api.id ?? ''), + getManagedBy: (api) => api.managedBy, + + toDesiredSnapshot: (yaml, api, refs) => ({ + name: yaml.name, + config: yaml.config as MonitorSnapshot['config'], + frequencySeconds: yaml.frequency ?? api.frequencySeconds ?? null, + enabled: yaml.enabled ?? api.enabled ?? null, + regions: yaml.regions !== undefined + ? sortedIds(yaml.regions) + : (api.regions ? sortedIds(api.regions) : null), + environmentId: yaml.environment !== undefined + ? (refs.resolve('environments', yaml.environment) ?? null) + : (api.environment?.id ?? null), + assertions: yaml.assertions !== undefined + ? sortAssertions(yaml.assertions.map(toCreateAssertionRequest)) + : apiAssertionsToSnapshot(api.assertions), + auth: yaml.auth !== undefined + ? (toAuthConfig(yaml.auth, refs) ?? null) + : (api.auth ?? null), + incidentPolicy: yaml.incidentPolicy !== undefined + ? toIncidentPolicy(yaml.incidentPolicy) + : apiIncidentPolicyToSnapshot(api.incidentPolicy), + alertChannelIds: yaml.alertChannels !== undefined + ? sortedIds(yaml.alertChannels.map((n) => refs.resolve('alertChannels', n) ?? n)) + : (api.alertChannelIds ? sortedIds(nonNullStrings(api.alertChannelIds)) : null), + tags: yaml.tags !== undefined + ? { + tagIds: sortedIds(yaml.tags.map((n) => refs.resolve('tags', n)).filter((id): id is string => id !== undefined)), + newTags: yaml.tags.filter((n) => !refs.resolve('tags', n)).map((n) => ({name: n})), + } + : apiTagsToSnapshot(api), + }), + toCurrentSnapshot: (api) => ({ + name: api.name ?? null, + config: api.config as MonitorSnapshot['config'], + frequencySeconds: api.frequencySeconds ?? null, + enabled: api.enabled ?? null, + regions: api.regions ? sortedIds(api.regions) : null, + environmentId: api.environment?.id ?? null, + assertions: apiAssertionsToSnapshot(api.assertions), + auth: api.auth ?? null, + incidentPolicy: apiIncidentPolicyToSnapshot(api.incidentPolicy), + alertChannelIds: api.alertChannelIds ? sortedIds(nonNullStrings(api.alertChannelIds)) : null, + tags: apiTagsToSnapshot(api), + }), + + fetchAll: (client) => fetchPaginated(client, '/api/v1/monitors'), + + async applyCreate(yaml, refs, client) { + const resp = await checkedFetch(client.POST('/api/v1/monitors', {body: toCreateMonitorRequest(yaml, refs)})) + return resp.data?.id ?? undefined + }, + async applyUpdate(yaml, id, refs, client) { + await checkedFetch(client.PUT('/api/v1/monitors/{id}', {params: {path: {id}}, body: toUpdateMonitorRequest(yaml, refs)})) + }, + deletePath: (id) => `/api/v1/monitors/${id}`, +}) + +// ── Monitor snapshot helpers ───────────────────────────────────────────── + +function sortAssertions( + assertions: Schemas['CreateAssertionRequest'][], +): Schemas['CreateAssertionRequest'][] { + return [...assertions].sort((a, b) => { + const aType = (a.config as {type: string}).type + const bType = (b.config as {type: string}).type + return aType.localeCompare(bType) + }) +} + +function apiAssertionsToSnapshot( + assertions: Schemas['MonitorDto']['assertions'], +): Schemas['CreateAssertionRequest'][] | null { + if (!assertions) return null + return sortAssertions(assertions.map((a) => ({ + config: a.config as Schemas['CreateAssertionRequest']['config'], + severity: a.severity, + }))) +} + +function apiIncidentPolicyToSnapshot( + policy: Schemas['MonitorDto']['incidentPolicy'], +): Schemas['UpdateIncidentPolicyRequest'] | null { + if (!policy) return null + return { + triggerRules: policy.triggerRules ?? [], + confirmation: policy.confirmation ?? {type: 'multi_region'}, + recovery: policy.recovery ?? {consecutiveSuccesses: 1, minRegionsPassing: 1, cooldownMinutes: 0}, + } +} + +function apiTagsToSnapshot(api: Schemas['MonitorDto']): Schemas['AddMonitorTagsRequest'] { + if (!api.tags) return {tagIds: null, newTags: []} + return { + tagIds: sortedIds(api.tags.map((t) => String(t.id ?? '')).filter(Boolean)), + newTags: [], + } +} + +// ── Dependency ────────────────────────────────────────────────────────── + +// Custom snapshot: there is no single UpdateDependencyRequest — updates are +// split across UpdateAlertSensitivityRequest and a generic PATCH. +type DependencySnapshot = { alertSensitivity: string | null; component: string | null } + +const dependencyHandler = defineHandler({ + resourceType: 'dependency', + refType: 'dependencies', + configKey: 'dependencies', + listPath: '/api/v1/service-subscriptions', + + getRefKey: (yaml) => yaml.service, + getApiRefKey: (api) => api.slug ?? '', + getApiId: (api) => String(api.subscriptionId ?? ''), + + toDesiredSnapshot: (yaml, api) => ({ + alertSensitivity: yaml.alertSensitivity ?? api.alertSensitivity ?? null, + component: yaml.component ?? api.componentId ?? null, + }), + toCurrentSnapshot: (api) => ({ + alertSensitivity: api.alertSensitivity ?? null, + component: api.componentId ?? null, + }), + + fetchAll: (client) => fetchPaginated(client, '/api/v1/service-subscriptions'), + + async applyCreate(yaml, _refs, client) { + const resp = await checkedFetch(client.POST('/api/v1/service-subscriptions/{slug}', { + params: {path: {slug: yaml.service}}, + body: { + alertSensitivity: yaml.alertSensitivity ?? null, + componentId: yaml.component ?? null, + }, + })) + return resp.data?.subscriptionId ?? undefined + }, + async applyUpdate(yaml, id, _refs, client) { + if (yaml.alertSensitivity !== undefined) { + await checkedFetch(client.PATCH('/api/v1/service-subscriptions/{id}/alert-sensitivity', { + params: {path: {id}}, + body: {alertSensitivity: yaml.alertSensitivity}, + })) + } + if (yaml.component !== undefined) { + await apiPatch(client, `/api/v1/service-subscriptions/${id}`, {componentId: yaml.component}) + } + }, + deletePath: (id) => `/api/v1/service-subscriptions/${id}`, +}) + +// ── Handler registry ──────────────────────────────────────────────────── + +/** + * Compile-time complete map: TypeScript errors if any HandledResourceType is missing. + */ +export const HANDLER_MAP: Record = { + tag: tagHandler, + environment: environmentHandler, + secret: secretHandler, + alertChannel: alertChannelHandler, + notificationPolicy: notificationPolicyHandler, + webhook: webhookHandler, + resourceGroup: resourceGroupHandler, + monitor: monitorHandler, + dependency: dependencyHandler, +} + +/** @internal – used by tests to look up a handler by resource type */ +export function getHandler(type: HandledResourceType): ResourceHandler { + return HANDLER_MAP[type] +} + +export function allHandlers(): ResourceHandler[] { + return Object.values(HANDLER_MAP) +} diff --git a/src/lib/yaml/index.ts b/src/lib/yaml/index.ts new file mode 100644 index 0000000..4247a5a --- /dev/null +++ b/src/lib/yaml/index.ts @@ -0,0 +1,13 @@ +export type {DevhelmConfig, YamlMonitor, YamlAlertChannel, YamlSectionKey} from './schema.js' +export {YAML_SECTION_KEYS} from './schema.js' +export {parseConfigFile, loadConfig, ParseError} from './parser.js' +export {validate} from './validator.js' +export type {ValidationResult, ValidationError} from './validator.js' +export {interpolate, findMissingVariables, InterpolationError} from './interpolation.js' +export {fetchAllRefs, ResolvedRefs} from './resolver.js' +export {diff, formatPlan, changesetToJson} from './differ.js' +export type {Changeset, Change, ChangesetJson} from './differ.js' +export {apply} from './applier.js' +export type {ApplyResult} from './applier.js' +export {readState, writeState, buildState} from './state.js' +export * from './transform.js' diff --git a/src/lib/yaml/interpolation.ts b/src/lib/yaml/interpolation.ts new file mode 100644 index 0000000..4abca97 --- /dev/null +++ b/src/lib/yaml/interpolation.ts @@ -0,0 +1,86 @@ +/** + * Environment variable interpolation for YAML config values. + * + * Supports: + * ${VAR} — required, fails if unset + * ${VAR:-default} — with fallback value + * + * Interpolation runs on the raw YAML string before parsing, + * so it works in any value position (strings, URLs, etc.). + */ + +const ENV_VAR_PATTERN = /\$\{([^}]+)\}/g + +export class InterpolationError extends Error { + constructor( + public readonly variable: string, + message: string, + ) { + super(message) + this.name = 'InterpolationError' + } +} + +/** + * Interpolate all ${VAR} and ${VAR:-default} expressions in a string. + * Throws InterpolationError if a required variable is not set. + */ +export function interpolate(input: string, env: Record = process.env): string { + return input.replace(ENV_VAR_PATTERN, (_match, expr: string) => { + const separatorIdx = expr.indexOf(':-') + if (separatorIdx !== -1) { + const varName = expr.slice(0, separatorIdx) + const fallback = expr.slice(separatorIdx + 2) + const value = env[varName] + return value !== undefined && value !== '' ? value : fallback + } + + const varName = expr.trim() + const value = env[varName] + if (value === undefined || value === '') { + throw new InterpolationError( + varName, + `Environment variable \${${varName}} is required but not set. ` + + `Set it in your environment or use \${${varName}:-default} for a fallback.`, + ) + } + return value + }) +} + +/** + * Find all ${VAR} references in a string without resolving them. + * Returns variable names (without fallback info). + */ +export function findVariables(input: string): string[] { + const vars: string[] = [] + let match: RegExpExecArray | null + const re = new RegExp(ENV_VAR_PATTERN.source, 'g') + while ((match = re.exec(input)) !== null) { + const expr = match[1] + const separatorIdx = expr.indexOf(':-') + vars.push(separatorIdx !== -1 ? expr.slice(0, separatorIdx) : expr.trim()) + } + return vars +} + +/** + * Check which variables would fail during interpolation (no value, no default). + * Returns array of missing variable names. + */ +export function findMissingVariables(input: string, env: Record = process.env): string[] { + const missing: string[] = [] + const re = new RegExp(ENV_VAR_PATTERN.source, 'g') + let match: RegExpExecArray | null + while ((match = re.exec(input)) !== null) { + const expr = match[1] + const separatorIdx = expr.indexOf(':-') + if (separatorIdx === -1) { + const varName = expr.trim() + if (env[varName] === undefined || env[varName] === '') { + missing.push(varName) + } + } + } + return missing +} diff --git a/src/lib/yaml/parser.ts b/src/lib/yaml/parser.ts new file mode 100644 index 0000000..619e93a --- /dev/null +++ b/src/lib/yaml/parser.ts @@ -0,0 +1,156 @@ +/** + * Parse a YAML string (or multiple files) into a typed DevhelmConfig. + * Handles env var interpolation, multi-file merging, and defaults application. + */ +import {readFileSync, existsSync, statSync, readdirSync} from 'node:fs' +import {join, resolve} from 'node:path' +import {parse as parseYaml} from 'yaml' + +import type {DevhelmConfig, YamlMonitor, YamlMonitorDefaults} from './schema.js' +import {YAML_SECTION_KEYS} from './schema.js' +import {interpolate, findMissingVariables} from './interpolation.js' + +export class ParseError extends Error { + constructor(message: string, public readonly file?: string) { + super(file ? `${file}: ${message}` : message) + this.name = 'ParseError' + } +} + +/** + * Load and parse a single YAML file with env var interpolation. + */ +export function parseConfigFile(filePath: string, resolveEnv = true): DevhelmConfig { + const absPath = resolve(filePath) + if (!existsSync(absPath)) { + throw new ParseError(`File not found: ${filePath}`) + } + + const raw = readFileSync(absPath, 'utf8') + + let interpolated: string + if (resolveEnv) { + const missing = findMissingVariables(raw) + if (missing.length > 0) { + throw new ParseError( + `Missing required environment variables: ${missing.join(', ')}. ` + + 'Set them or use ${VAR:-default} syntax for fallbacks.', + filePath, + ) + } + interpolated = interpolate(raw) + } else { + interpolated = raw + } + + let parsed: unknown + try { + parsed = parseYaml(interpolated) + } catch (err) { + throw new ParseError(`Invalid YAML: ${err instanceof Error ? err.message : String(err)}`, filePath) + } + + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new ParseError('Config file is empty or not a YAML object', filePath) + } + + return parsed as DevhelmConfig +} + +/** + * Load config from one or more file paths (files or directories). + * Directories are scanned for *.yml and *.yaml files. + */ +export function loadConfig(paths: string[], resolveEnv = true): DevhelmConfig { + const files = expandPaths(paths) + if (files.length === 0) { + throw new ParseError('No YAML files found in the specified paths') + } + + if (files.length === 1) { + const config = parseConfigFile(files[0], resolveEnv) + return applyDefaults(config) + } + + const configs = files.map((f) => parseConfigFile(f, resolveEnv)) + const merged = mergeConfigs(configs) + return applyDefaults(merged) +} + +/** + * Expand file/directory paths into a flat list of .yml/.yaml files. + */ +function expandPaths(paths: string[]): string[] { + const files: string[] = [] + for (const p of paths) { + const absPath = resolve(p) + if (!existsSync(absPath)) { + throw new ParseError(`Path not found: ${p}`) + } + const stat = statSync(absPath) + if (stat.isDirectory()) { + const entries = readdirSync(absPath) + .filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')) + .sort() + .map((f) => join(absPath, f)) + files.push(...entries) + } else { + files.push(absPath) + } + } + return files +} + +/** + * Merge multiple configs. Arrays are concatenated. Duplicate ref keys are caught by the validator. + * Uses YAML_SECTION_KEYS so adding a new section is a compile-time addition, not a manual edit here. + */ +function mergeConfigs(configs: DevhelmConfig[]): DevhelmConfig { + const merged: DevhelmConfig = {} + + for (const cfg of configs) { + if (cfg.version !== undefined) merged.version = cfg.version + + if (cfg.defaults) { + merged.defaults = merged.defaults ?? {} + if (cfg.defaults.monitors) { + merged.defaults.monitors = {...merged.defaults.monitors, ...cfg.defaults.monitors} + } + } + + for (const key of YAML_SECTION_KEYS) { + const items = cfg[key] + if (items) { + const dest = (merged as Record) + dest[key] = [...(dest[key] ?? []), ...items] + } + } + } + + return merged +} + +/** + * Apply defaults.monitors to each monitor that doesn't override the field. + * Shallow merge: if monitor defines a field, it wins entirely (no deep merge). + */ +function applyDefaults(config: DevhelmConfig): DevhelmConfig { + const defaults = config.defaults?.monitors + if (!defaults || !config.monitors?.length) return config + + return { + ...config, + monitors: config.monitors.map((m) => applyMonitorDefaults(m, defaults)), + } +} + +function applyMonitorDefaults(monitor: YamlMonitor, defaults: YamlMonitorDefaults): YamlMonitor { + return { + ...monitor, + frequency: monitor.frequency ?? defaults.frequency, + enabled: monitor.enabled ?? defaults.enabled, + regions: monitor.regions ?? defaults.regions, + alertChannels: monitor.alertChannels ?? defaults.alertChannels, + incidentPolicy: monitor.incidentPolicy ?? defaults.incidentPolicy, + } +} diff --git a/src/lib/yaml/resolver.ts b/src/lib/yaml/resolver.ts new file mode 100644 index 0000000..386bed1 --- /dev/null +++ b/src/lib/yaml/resolver.ts @@ -0,0 +1,78 @@ +/** + * Reference resolver: fetches existing resources from the API via typed + * handler methods and builds name/slug → UUID maps for YAML reference resolution. + */ +import type {ApiClient} from '../api-client.js' +import type {RefType, RefTypeDtoMap} from './types.js' +import {allHandlers} from './handlers.js' + +export type {RefType} + +export interface RefEntry { + id: string + refKey: string + managedBy?: string + raw: K extends keyof RefTypeDtoMap ? RefTypeDtoMap[K] : unknown +} + +export class ResolvedRefs { + private maps = new Map>() + + get(type: K, refKey: string): RefEntry | undefined { + return this.maps.get(type)?.get(refKey) as RefEntry | undefined + } + + resolve(type: RefType, refKey: string): string | undefined { + return this.maps.get(type)?.get(refKey)?.id + } + + require(type: RefType, refKey: string): string { + const id = this.resolve(type, refKey) + if (!id) { + throw new Error(`Cannot resolve ${type} reference "${refKey}" — not found in YAML or API`) + } + return id + } + + set(type: K, refKey: string, entry: RefEntry): void { + if (!this.maps.has(type)) this.maps.set(type, new Map()) + this.maps.get(type)!.set(refKey, entry as RefEntry) + } + + all(type: K): Map> { + return (this.maps.get(type) ?? new Map()) as Map> + } + + allEntries(type: K): RefEntry[] { + return [...this.all(type).values()] + } +} + +/** + * Fetch all resources from the API via handler.fetchAll() and build + * reference maps using handler metadata (getApiRefKey, getApiId, etc.). + */ +export async function fetchAllRefs(client: ApiClient): Promise { + const refs = new ResolvedRefs() + const handlers = allHandlers() + + const results = await Promise.all(handlers.map((h) => h.fetchAll(client))) + + for (let i = 0; i < handlers.length; i++) { + const handler = handlers[i] + for (const item of results[i]) { + const refKey = handler.getApiRefKey(item) + // Handler.fetchAll() returns the correct DTO for its refType but TS + // can't narrow the correlation. The cast is safe — each handler's + // fetchAll returns exactly RefTypeDtoMap[handler.refType]. + refs.set(handler.refType, refKey, { + id: handler.getApiId(item), + refKey, + managedBy: handler.getManagedBy?.(item), + raw: item as RefEntry['raw'], + }) + } + } + + return refs +} diff --git a/src/lib/yaml/schema.ts b/src/lib/yaml/schema.ts new file mode 100644 index 0000000..1cb2ff9 --- /dev/null +++ b/src/lib/yaml/schema.ts @@ -0,0 +1,381 @@ +/** + * YAML configuration schema types — derived from OpenAPI-generated API types. + * + * These types define what users write in devhelm.yml. They mirror API request + * types closely, but replace UUIDs with name/slug references and use friendlier + * field names (e.g. `frequency` instead of `frequencySeconds`). + * + * The transform layer (transform.ts) maps these to API request types with + * compile-time type checking on both sides. + */ +import type {components} from '../api.generated.js' + +type Schemas = components['schemas'] + +// ── Re-export API types used directly in YAML (no transformation needed) ── + +export type MonitorType = Schemas['CreateMonitorRequest']['type'] +export type HttpMethod = Schemas['HttpMonitorConfig']['method'] +export type DnsRecordType = NonNullable[number]> +export type AssertionSeverity = NonNullable +export type TriggerRuleType = Schemas['TriggerRule']['type'] +export type TriggerRuleScope = Schemas['TriggerRule']['scope'] +export type TriggerRuleSeverity = Schemas['TriggerRule']['severity'] +export type TriggerAggregation = NonNullable +export type ComparisonOperator = Schemas['StatusCodeAssertion'] extends {type: string} & infer R + ? R extends {operator: infer O} ? O : never + : never + +// ── Enum constants for validation ────────────────────────────────────── + +export const MONITOR_TYPES: readonly MonitorType[] = ['HTTP', 'DNS', 'TCP', 'ICMP', 'HEARTBEAT', 'MCP_SERVER'] +export const HTTP_METHODS: readonly HttpMethod[] = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] +export const DNS_RECORD_TYPES: readonly string[] = ['A', 'AAAA', 'CNAME', 'MX', 'NS', 'TXT', 'SRV', 'SOA', 'CAA', 'PTR'] +export const ASSERTION_SEVERITIES: readonly AssertionSeverity[] = ['fail', 'warn'] +export const COMPARISON_OPERATORS: readonly string[] = ['equals', 'contains', 'less_than', 'greater_than', 'matches', 'range'] +export const TRIGGER_RULE_TYPES: readonly TriggerRuleType[] = ['consecutive_failures', 'failures_in_window', 'response_time'] +export const TRIGGER_SCOPES: readonly string[] = ['per_region', 'any_region'] +export const TRIGGER_SEVERITIES: readonly TriggerRuleSeverity[] = ['down', 'degraded'] +export const TRIGGER_AGGREGATIONS: readonly string[] = ['all_exceed', 'average', 'p95', 'max'] +export const CHANNEL_TYPES = ['slack', 'email', 'pagerduty', 'opsgenie', 'discord', 'teams', 'webhook'] as const +export type ChannelType = (typeof CHANNEL_TYPES)[number] +export const ALERT_SENSITIVITIES = ['ALL', 'INCIDENTS_ONLY', 'MAJOR_ONLY'] as const +export const HEALTH_THRESHOLD_TYPES = ['COUNT', 'PERCENTAGE'] as const + +export const MIN_FREQUENCY = 30 +export const MAX_FREQUENCY = 86400 + +// ── Assertion type names (discriminator values) ──────────────────────── + +export const ASSERTION_TYPES = [ + 'StatusCodeAssertion', 'ResponseTimeAssertion', 'ResponseTimeWarnAssertion', + 'BodyContainsAssertion', 'RegexBodyAssertion', 'HeaderValueAssertion', + 'JsonPathAssertion', 'SslExpiryAssertion', 'ResponseSizeAssertion', + 'RedirectCountAssertion', 'RedirectTargetAssertion', + 'DnsResolvesAssertion', 'DnsResponseTimeAssertion', 'DnsResponseTimeWarnAssertion', + 'DnsExpectedIpsAssertion', 'DnsExpectedCnameAssertion', + 'DnsRecordContainsAssertion', 'DnsRecordEqualsAssertion', + 'DnsTxtContainsAssertion', 'DnsMinAnswersAssertion', 'DnsMaxAnswersAssertion', + 'DnsTtlLowAssertion', 'DnsTtlHighAssertion', + 'TcpConnectsAssertion', 'TcpResponseTimeAssertion', 'TcpResponseTimeWarnAssertion', + 'IcmpReachableAssertion', 'IcmpResponseTimeAssertion', 'IcmpResponseTimeWarnAssertion', + 'IcmpPacketLossAssertion', + 'HeartbeatReceivedAssertion', 'HeartbeatMaxIntervalAssertion', + 'HeartbeatIntervalDriftAssertion', 'HeartbeatPayloadContainsAssertion', + 'McpConnectsAssertion', 'McpResponseTimeAssertion', 'McpResponseTimeWarnAssertion', + 'McpHasCapabilityAssertion', 'McpToolAvailableAssertion', + 'McpMinToolsAssertion', 'McpProtocolVersionAssertion', 'McpToolCountChangedAssertion', +] as const + +export type AssertionType = (typeof ASSERTION_TYPES)[number] + +// ── Monitor config types (YAML mirrors API directly) ─────────────────── + +export interface YamlHttpConfig { + url: string + method: HttpMethod + customHeaders?: Record + requestBody?: string + contentType?: string + verifyTls?: boolean +} + +export interface YamlDnsConfig { + hostname: string + recordTypes?: DnsRecordType[] + nameservers?: string[] + timeoutMs?: number + totalTimeoutMs?: number +} + +export interface YamlTcpConfig { + host: string + port?: number + timeoutMs?: number +} + +export interface YamlIcmpConfig { + host: string + packetCount?: number + timeoutMs?: number +} + +export interface YamlHeartbeatConfig { + expectedInterval: number + gracePeriod: number +} + +export interface YamlMcpServerConfig { + command: string + args?: string[] + env?: Record +} + +export type YamlMonitorConfig = + | YamlHttpConfig + | YamlDnsConfig + | YamlTcpConfig + | YamlIcmpConfig + | YamlHeartbeatConfig + | YamlMcpServerConfig + +// ── Assertion config (YAML mirrors API discriminated union) ──────────── + +export interface YamlAssertion { + type: AssertionType + config?: Record + severity?: AssertionSeverity +} + +// ── Auth config (with vault secret name reference) ───────────────────── + +export interface YamlBearerAuth { + type: 'BearerAuthConfig' + secret: string +} + +export interface YamlBasicAuth { + type: 'BasicAuthConfig' + secret: string +} + +export interface YamlApiKeyAuth { + type: 'ApiKeyAuthConfig' + headerName: string + secret: string +} + +export interface YamlHeaderAuth { + type: 'HeaderAuthConfig' + headerName: string + secret: string +} + +export type YamlAuth = YamlBearerAuth | YamlBasicAuth | YamlApiKeyAuth | YamlHeaderAuth + +// ── Incident policy (YAML mirrors API types directly) ────────────────── + +export interface YamlTriggerRule { + type: TriggerRuleType + count?: number + windowMinutes?: number + scope: TriggerRuleScope + thresholdMs?: number + severity: TriggerRuleSeverity + aggregationType?: TriggerAggregation +} + +export interface YamlConfirmationPolicy { + type: 'multi_region' + minRegionsFailing?: number + maxWaitSeconds?: number +} + +export interface YamlRecoveryPolicy { + consecutiveSuccesses?: number + minRegionsPassing?: number + cooldownMinutes?: number +} + +export interface YamlIncidentPolicy { + triggerRules: YamlTriggerRule[] + confirmation: YamlConfirmationPolicy + recovery: YamlRecoveryPolicy +} + +// ── Escalation (with channel name references) ────────────────────────── + +export interface YamlEscalationStep { + channels: string[] + delayMinutes?: number + requireAck?: boolean + repeatIntervalSeconds?: number +} + +export interface YamlEscalationChain { + steps: YamlEscalationStep[] + onResolve?: string + onReopen?: string +} + +// ── Match rules for notification policies ────────────────────────────── + +export interface YamlMatchRule { + type: string + value?: string + monitorNames?: string[] + regions?: string[] + values?: string[] +} + +// ── Channel configs (YAML uses lowercase type + flat config) ─────────── + +export interface YamlSlackConfig { + webhookUrl: string + mentionText?: string +} + +export interface YamlDiscordConfig { + webhookUrl: string + mentionRoleId?: string +} + +export interface YamlEmailConfig { + recipients: string[] +} + +export interface YamlWebhookConfig { + url: string + signingSecret?: string + customHeaders?: Record +} + +export interface YamlPagerDutyConfig { + routingKey: string + severityOverride?: string +} + +export interface YamlOpsGenieConfig { + apiKey: string + region?: string +} + +export interface YamlTeamsConfig { + webhookUrl: string +} + +export type YamlChannelConfig = + | YamlSlackConfig + | YamlDiscordConfig + | YamlEmailConfig + | YamlWebhookConfig + | YamlPagerDutyConfig + | YamlOpsGenieConfig + | YamlTeamsConfig + +// ── Retry strategy (for resource groups) ─────────────────────────────── + +export interface YamlRetryStrategy { + type: string + maxRetries?: number + interval?: number +} + +// ── Top-level YAML resource types ────────────────────────────────────── + +export interface YamlTag { + name: string + color?: string +} + +export interface YamlEnvironment { + name: string + slug: string + variables?: Record + isDefault?: boolean +} + +export interface YamlSecret { + key: string + value: string +} + +export interface YamlAlertChannel { + name: string + type: ChannelType + config: YamlChannelConfig +} + +export interface YamlNotificationPolicy { + name: string + enabled?: boolean + priority?: number + matchRules?: YamlMatchRule[] + escalation: YamlEscalationChain +} + +export interface YamlWebhook { + url: string + events: string[] + description?: string + enabled?: boolean +} + +export interface YamlResourceGroup { + name: string + description?: string + alertPolicy?: string + defaultFrequency?: number + defaultRegions?: string[] + defaultRetryStrategy?: YamlRetryStrategy + defaultAlertChannels?: string[] + defaultEnvironment?: string + healthThresholdType?: (typeof HEALTH_THRESHOLD_TYPES)[number] + healthThresholdValue?: number + suppressMemberAlerts?: boolean + confirmationDelaySeconds?: number + recoveryCooldownMinutes?: number + monitors?: string[] + services?: string[] +} + +export interface YamlMonitor { + name: string + type: MonitorType + config: YamlMonitorConfig + frequency?: number + enabled?: boolean + regions?: string[] + environment?: string + tags?: string[] + alertChannels?: string[] + assertions?: YamlAssertion[] + auth?: YamlAuth + incidentPolicy?: YamlIncidentPolicy +} + +export interface YamlDependency { + service: string + alertSensitivity?: (typeof ALERT_SENSITIVITIES)[number] + component?: string +} + +// ── Defaults section ─────────────────────────────────────────────────── + +export interface YamlMonitorDefaults { + frequency?: number + enabled?: boolean + regions?: string[] + alertChannels?: string[] + incidentPolicy?: YamlIncidentPolicy +} + +export interface YamlDefaults { + monitors?: YamlMonitorDefaults +} + +// ── Top-level config ─────────────────────────────────────────────────── + +export interface DevhelmConfig { + version?: string + defaults?: YamlDefaults + tags?: YamlTag[] + environments?: YamlEnvironment[] + secrets?: YamlSecret[] + alertChannels?: YamlAlertChannel[] + notificationPolicies?: YamlNotificationPolicy[] + webhooks?: YamlWebhook[] + resourceGroups?: YamlResourceGroup[] + monitors?: YamlMonitor[] + dependencies?: YamlDependency[] +} + +// ── Section keys (for parity enforcement) ────────────────────────────── + +export const YAML_SECTION_KEYS = [ + 'tags', 'environments', 'secrets', 'alertChannels', + 'notificationPolicies', 'webhooks', 'resourceGroups', + 'monitors', 'dependencies', +] as const + +export type YamlSectionKey = (typeof YAML_SECTION_KEYS)[number] diff --git a/src/lib/yaml/state.ts b/src/lib/yaml/state.ts new file mode 100644 index 0000000..5aa78bf --- /dev/null +++ b/src/lib/yaml/state.ts @@ -0,0 +1,58 @@ +/** + * Local state file for tracking resources managed by `devhelm deploy`. + * Reserved for future use (e.g. offline drift detection, state-aware pruning). + * + * State file: .devhelm/state.json (gitignored by convention) + */ +import {existsSync, readFileSync, writeFileSync, mkdirSync} from 'node:fs' +import {join, dirname} from 'node:path' + +export interface StateEntry { + resourceType: string + refKey: string + id: string + createdAt: string +} + +export interface DeployState { + version: string + lastDeployedAt: string + resources: StateEntry[] +} + +const STATE_DIR = '.devhelm' +const STATE_FILE = 'state.json' +export const STATE_VERSION = '1' + +function statePath(cwd: string): string { + return join(cwd, STATE_DIR, STATE_FILE) +} + +export function readState(cwd: string = process.cwd()): DeployState | undefined { + const path = statePath(cwd) + if (!existsSync(path)) return undefined + try { + return JSON.parse(readFileSync(path, 'utf8')) as DeployState + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + console.warn(`Warning: corrupt state file at ${path} (${msg}). Treating as fresh state.`) + return undefined + } +} + +export function writeState(state: DeployState, cwd: string = process.cwd()): void { + const path = statePath(cwd) + const dir = dirname(path) + if (!existsSync(dir)) { + mkdirSync(dir, {recursive: true}) + } + writeFileSync(path, JSON.stringify(state, null, 2)) +} + +export function buildState(entries: StateEntry[]): DeployState { + return { + version: STATE_VERSION, + lastDeployedAt: new Date().toISOString(), + resources: entries, + } +} diff --git a/src/lib/yaml/transform.ts b/src/lib/yaml/transform.ts new file mode 100644 index 0000000..849150f --- /dev/null +++ b/src/lib/yaml/transform.ts @@ -0,0 +1,232 @@ +/** + * Type-checked transforms: YAML config types → API request types. + * Every function here is compile-time verified against both sides. + */ +import type {components} from '../api.generated.js' +import type { + YamlMonitor, YamlAlertChannel, YamlNotificationPolicy, + YamlResourceGroup, YamlWebhook, YamlTag, YamlEnvironment, + YamlSecret, YamlAssertion, YamlAuth, + YamlIncidentPolicy, YamlEscalationStep, YamlMatchRule, + ChannelType, +} from './schema.js' +import type {ResolvedRefs} from './resolver.js' + +type Schemas = components['schemas'] + +// ── Channel type discriminator mapping ───────────────────────────────── + +const CHANNEL_TYPE_DISCRIMINATOR: Record = { + slack: 'SlackChannelConfig', + discord: 'DiscordChannelConfig', + email: 'EmailChannelConfig', + webhook: 'WebhookChannelConfig', + pagerduty: 'PagerDutyChannelConfig', + opsgenie: 'OpsGenieChannelConfig', + teams: 'TeamsChannelConfig', +} + +// ── Tag ──────────────────────────────────────────────────────────────── + +export function toCreateTagRequest(tag: YamlTag): Schemas['CreateTagRequest'] { + return { + name: tag.name, + color: tag.color ?? null, + } +} + +// ── Environment ──────────────────────────────────────────────────────── + +export function toCreateEnvironmentRequest(env: YamlEnvironment): Schemas['CreateEnvironmentRequest'] { + return { + name: env.name, + slug: env.slug, + variables: env.variables ?? null, + isDefault: env.isDefault, + } +} + +// ── Secret ───────────────────────────────────────────────────────────── + +export function toCreateSecretRequest(secret: YamlSecret): Schemas['CreateSecretRequest'] { + return {key: secret.key, value: secret.value} +} + +// ── Alert Channel ────────────────────────────────────────────────────── + +export function toCreateAlertChannelRequest(channel: YamlAlertChannel): Schemas['CreateAlertChannelRequest'] { + const channelType = CHANNEL_TYPE_DISCRIMINATOR[channel.type] + const config = {channelType, ...channel.config} as Schemas['CreateAlertChannelRequest']['config'] + return {name: channel.name, config} +} + +// ── Notification Policy ──────────────────────────────────────────────── + +export function toCreateNotificationPolicyRequest( + policy: YamlNotificationPolicy, + refs: ResolvedRefs, +): Schemas['CreateNotificationPolicyRequest'] { + return { + name: policy.name, + enabled: policy.enabled ?? true, + priority: policy.priority ?? 0, + matchRules: policy.matchRules?.map((r) => toMatchRule(r, refs)), + escalation: { + steps: policy.escalation.steps.map((s) => toEscalationStep(s, refs)), + onResolve: policy.escalation.onResolve ?? null, + onReopen: policy.escalation.onReopen ?? null, + }, + } +} + +function toEscalationStep(step: YamlEscalationStep, refs: ResolvedRefs): Schemas['EscalationStep'] { + return { + channelIds: step.channels.map((name) => refs.require('alertChannels', name)), + delayMinutes: step.delayMinutes ?? 0, + requireAck: step.requireAck ?? null, + repeatIntervalSeconds: step.repeatIntervalSeconds ?? null, + } +} + +function toMatchRule(rule: YamlMatchRule, refs: ResolvedRefs): Schemas['MatchRule'] { + return { + type: rule.type, + value: rule.value ?? null, + monitorIds: rule.monitorNames?.map((name) => refs.require('monitors', name)) ?? null, + regions: rule.regions ?? null, + values: rule.values ?? null, + } +} + +// ── Webhook ──────────────────────────────────────────────────────────── + +export function toCreateWebhookRequest(webhook: YamlWebhook): Schemas['CreateWebhookEndpointRequest'] { + return { + url: webhook.url, + subscribedEvents: webhook.events, + description: webhook.description, + } +} + +// ── Resource Group ───────────────────────────────────────────────────── + +export function toCreateResourceGroupRequest( + group: YamlResourceGroup, + refs: ResolvedRefs, +): Schemas['CreateResourceGroupRequest'] { + return { + name: group.name, + description: group.description ?? null, + alertPolicyId: group.alertPolicy ? refs.resolve('notificationPolicies', group.alertPolicy) ?? null : null, + defaultFrequency: group.defaultFrequency ?? null, + defaultRegions: group.defaultRegions ?? null, + defaultRetryStrategy: group.defaultRetryStrategy ? { + type: group.defaultRetryStrategy.type, + maxRetries: group.defaultRetryStrategy.maxRetries, + interval: group.defaultRetryStrategy.interval, + } : undefined, + defaultAlertChannels: group.defaultAlertChannels?.map((n) => refs.resolve('alertChannels', n) ?? n) ?? null, + defaultEnvironmentId: group.defaultEnvironment ? refs.resolve('environments', group.defaultEnvironment) ?? null : null, + healthThresholdType: group.healthThresholdType ?? null, + healthThresholdValue: group.healthThresholdValue ?? null, + suppressMemberAlerts: group.suppressMemberAlerts, + confirmationDelaySeconds: group.confirmationDelaySeconds ?? null, + recoveryCooldownMinutes: group.recoveryCooldownMinutes ?? null, + } +} + +// ── Monitor ──────────────────────────────────────────────────────────── + +export function toCreateMonitorRequest( + monitor: YamlMonitor, + refs: ResolvedRefs, +): Schemas['CreateMonitorRequest'] { + return { + name: monitor.name, + type: monitor.type, + config: monitor.config as Schemas['CreateMonitorRequest']['config'], + managedBy: 'CLI', + frequencySeconds: monitor.frequency, + enabled: monitor.enabled, + regions: monitor.regions ?? null, + environmentId: monitor.environment ? refs.resolve('environments', monitor.environment) ?? null : null, + assertions: monitor.assertions?.map(toCreateAssertionRequest) ?? null, + auth: monitor.auth ? toAuthConfig(monitor.auth, refs) : undefined, + incidentPolicy: monitor.incidentPolicy ? toIncidentPolicy(monitor.incidentPolicy) : undefined, + alertChannelIds: monitor.alertChannels?.map((n) => refs.require('alertChannels', n)) ?? null, + tags: monitor.tags ? { + tagIds: monitor.tags.map((n) => refs.resolve('tags', n)).filter((id): id is string => id !== undefined), + newTags: monitor.tags + .filter((n) => !refs.resolve('tags', n)) + .map((n) => ({name: n})), + } : undefined, + } +} + +export function toUpdateMonitorRequest( + monitor: YamlMonitor, + refs: ResolvedRefs, +): Schemas['UpdateMonitorRequest'] { + return { + name: monitor.name, + config: monitor.config as Schemas['UpdateMonitorRequest']['config'], + managedBy: 'CLI', + frequencySeconds: monitor.frequency, + enabled: monitor.enabled, + regions: monitor.regions ?? null, + environmentId: monitor.environment ? refs.resolve('environments', monitor.environment) ?? null : null, + assertions: monitor.assertions?.map(toCreateAssertionRequest) ?? null, + auth: monitor.auth ? toAuthConfig(monitor.auth, refs) : undefined, + incidentPolicy: monitor.incidentPolicy ? toIncidentPolicy(monitor.incidentPolicy) : undefined, + alertChannelIds: monitor.alertChannels?.map((n) => refs.require('alertChannels', n)) ?? null, + tags: monitor.tags ? { + tagIds: monitor.tags.map((n) => refs.resolve('tags', n)).filter((id): id is string => id !== undefined), + newTags: monitor.tags + .filter((n) => !refs.resolve('tags', n)) + .map((n) => ({name: n})), + } : undefined, + } +} + +export function toCreateAssertionRequest(a: YamlAssertion): Schemas['CreateAssertionRequest'] { + const config = {type: a.type, ...(a.config ?? {})} as Schemas['CreateAssertionRequest']['config'] + return {config, severity: a.severity} +} + +export function toAuthConfig(auth: YamlAuth, refs: ResolvedRefs): Schemas['CreateMonitorRequest']['auth'] { + const secretId = refs.resolve('secrets', auth.secret) ?? undefined + switch (auth.type) { + case 'BearerAuthConfig': + return {type: 'BearerAuthConfig', vaultSecretId: secretId ?? null} as Schemas['BearerAuthConfig'] + case 'BasicAuthConfig': + return {type: 'BasicAuthConfig', vaultSecretId: secretId ?? null} as Schemas['BasicAuthConfig'] + case 'ApiKeyAuthConfig': + return {type: 'ApiKeyAuthConfig', headerName: auth.headerName, vaultSecretId: secretId ?? null} as Schemas['ApiKeyAuthConfig'] + case 'HeaderAuthConfig': + return {type: 'HeaderAuthConfig', headerName: auth.headerName, vaultSecretId: secretId ?? null} as Schemas['HeaderAuthConfig'] + } +} + +export function toIncidentPolicy(policy: YamlIncidentPolicy): Schemas['UpdateIncidentPolicyRequest'] { + return { + triggerRules: policy.triggerRules.map((r) => ({ + type: r.type, + count: r.count ?? null, + windowMinutes: r.windowMinutes ?? null, + scope: r.scope, + thresholdMs: r.thresholdMs ?? null, + severity: r.severity, + aggregationType: r.aggregationType ?? null, + })), + confirmation: { + type: policy.confirmation.type, + minRegionsFailing: policy.confirmation.minRegionsFailing, + maxWaitSeconds: policy.confirmation.maxWaitSeconds, + }, + recovery: { + consecutiveSuccesses: policy.recovery.consecutiveSuccesses, + minRegionsPassing: policy.recovery.minRegionsPassing, + cooldownMinutes: policy.recovery.cooldownMinutes, + }, + } +} diff --git a/src/lib/yaml/types.ts b/src/lib/yaml/types.ts new file mode 100644 index 0000000..b0fea0b --- /dev/null +++ b/src/lib/yaml/types.ts @@ -0,0 +1,65 @@ +/** + * Shared type definitions for the YAML engine. + * + * Extracted into a standalone module to avoid circular dependencies + * between handlers, differ, resolver, and applier. + */ +import type {components} from '../api.generated.js' + +type Schemas = components['schemas'] + +export type ChangeAction = 'create' | 'update' | 'delete' + +export type ResourceType = + | 'tag' | 'environment' | 'secret' | 'alertChannel' + | 'notificationPolicy' | 'webhook' | 'resourceGroup' + | 'monitor' | 'dependency' | 'groupMembership' + +/** Resource types that have a full ResourceHandler implementation. */ +export type HandledResourceType = Exclude + +export type RefType = + | 'tags' | 'environments' | 'secrets' | 'alertChannels' + | 'notificationPolicies' | 'webhooks' | 'resourceGroups' + | 'monitors' | 'dependencies' + +/** Maps each RefType to the API DTO stored in RefEntry.raw. */ +export interface RefTypeDtoMap { + tags: Schemas['TagDto'] + environments: Schemas['EnvironmentDto'] + secrets: Schemas['SecretDto'] + alertChannels: Schemas['AlertChannelDto'] + notificationPolicies: Schemas['NotificationPolicyDto'] + webhooks: Schemas['WebhookEndpointDto'] + resourceGroups: Schemas['ResourceGroupDto'] + monitors: Schemas['MonitorDto'] + dependencies: Schemas['ServiceSubscriptionDto'] +} + +export interface Change { + action: ChangeAction + resourceType: ResourceType + refKey: string + existingId?: string + desired?: unknown + current?: unknown +} + +export interface DiffOptions { + prune?: boolean + pruneAll?: boolean +} + +export interface Changeset { + creates: Change[] + updates: Change[] + deletes: Change[] + memberships: Change[] +} + +/** Dependency order for topological sort (creates ascending, deletes descending). */ +export const RESOURCE_ORDER: ResourceType[] = [ + 'tag', 'environment', 'secret', 'alertChannel', + 'notificationPolicy', 'webhook', 'resourceGroup', + 'monitor', 'dependency', 'groupMembership', +] diff --git a/src/lib/yaml/validator.ts b/src/lib/yaml/validator.ts new file mode 100644 index 0000000..6746d7e --- /dev/null +++ b/src/lib/yaml/validator.ts @@ -0,0 +1,479 @@ +/** + * Deep offline validation of a DevhelmConfig against schema constraints. + * Checks types, required fields, enum values, frequency bounds, + * intra-YAML reference integrity, and duplicate ref keys. + */ +import type { + DevhelmConfig, YamlMonitor, YamlAlertChannel, YamlNotificationPolicy, + YamlResourceGroup, YamlWebhook, YamlTag, YamlEnvironment, YamlSecret, + YamlDependency, YamlAssertion, YamlAuth, YamlIncidentPolicy, + YamlEscalationStep, YamlMatchRule, + YamlChannelConfig, YamlMonitorConfig, +} from './schema.js' +import { + MONITOR_TYPES, HTTP_METHODS, DNS_RECORD_TYPES, + ASSERTION_TYPES, ASSERTION_SEVERITIES, COMPARISON_OPERATORS, + CHANNEL_TYPES, ALERT_SENSITIVITIES, HEALTH_THRESHOLD_TYPES, + TRIGGER_RULE_TYPES, TRIGGER_SCOPES, TRIGGER_SEVERITIES, TRIGGER_AGGREGATIONS, + MIN_FREQUENCY, MAX_FREQUENCY, +} from './schema.js' + +export interface ValidationError { + path: string + message: string +} + +export interface ValidationResult { + errors: ValidationError[] + warnings: ValidationError[] +} + +export function validate(config: DevhelmConfig): ValidationResult { + const ctx = new ValidationContext() + validateConfig(config, ctx) + return {errors: ctx.errors, warnings: ctx.warnings} +} + +class ValidationContext { + errors: ValidationError[] = [] + warnings: ValidationError[] = [] + + private declaredNames = new Map>() + + error(path: string, message: string): void { + this.errors.push({path, message}) + } + + warn(path: string, message: string): void { + this.warnings.push({path, message}) + } + + declareRef(type: string, name: string, path: string): void { + if (!this.declaredNames.has(type)) { + this.declaredNames.set(type, new Set()) + } + const set = this.declaredNames.get(type)! + if (set.has(name)) { + this.error(path, `Duplicate ${type} name "${name}" — names must be unique within each resource type`) + } + set.add(name) + } + + hasRef(type: string, name: string): boolean { + return this.declaredNames.get(type)?.has(name) ?? false + } + + checkRef(refType: string, name: string, path: string): void { + if (!this.hasRef(refType, name)) { + this.warn(path, `Reference "${name}" not found in YAML ${refType} definitions. It must exist in the API at deploy time.`) + } + } +} + +// ── Top-level config validation ──────────────────────────────────────── + +function validateConfig(config: DevhelmConfig, ctx: ValidationContext): void { + if (config.version !== undefined && config.version !== '1') { + ctx.warn('version', `Unknown config version "${config.version}". Supported: "1"`) + } + + const hasAnyResource = config.tags?.length || config.environments?.length || + config.secrets?.length || config.alertChannels?.length || + config.notificationPolicies?.length || config.webhooks?.length || + config.resourceGroups?.length || config.monitors?.length || + config.dependencies?.length + + if (!hasAnyResource) { + ctx.error('', 'Config has no resource definitions. Add at least one section (monitors, tags, etc.)') + } + + collectDeclarations(config, ctx) + + if (config.tags) validateArray(config.tags, 'tags', ctx, validateTag) + if (config.environments) validateArray(config.environments, 'environments', ctx, validateEnvironment) + if (config.secrets) validateArray(config.secrets, 'secrets', ctx, validateSecretDef) + if (config.alertChannels) validateArray(config.alertChannels, 'alertChannels', ctx, validateAlertChannel) + if (config.notificationPolicies) validateArray(config.notificationPolicies, 'notificationPolicies', ctx, validateNotificationPolicy) + if (config.webhooks) validateArray(config.webhooks, 'webhooks', ctx, validateWebhookDef) + if (config.resourceGroups) validateArray(config.resourceGroups, 'resourceGroups', ctx, validateResourceGroup) + if (config.monitors) validateArray(config.monitors, 'monitors', ctx, validateMonitor) + if (config.dependencies) validateArray(config.dependencies, 'dependencies', ctx, validateDependency) +} + +function collectDeclarations(config: DevhelmConfig, ctx: ValidationContext): void { + for (const t of config.tags ?? []) if (t.name) ctx.declareRef('tags', t.name, 'tags') + for (const e of config.environments ?? []) if (e.slug) ctx.declareRef('environments', e.slug, 'environments') + for (const s of config.secrets ?? []) if (s.key) ctx.declareRef('secrets', s.key, 'secrets') + for (const c of config.alertChannels ?? []) if (c.name) ctx.declareRef('alertChannels', c.name, 'alertChannels') + for (const p of config.notificationPolicies ?? []) if (p.name) ctx.declareRef('notificationPolicies', p.name, 'notificationPolicies') + for (const w of config.webhooks ?? []) if (w.url) ctx.declareRef('webhooks', w.url, 'webhooks') + for (const g of config.resourceGroups ?? []) if (g.name) ctx.declareRef('resourceGroups', g.name, 'resourceGroups') + for (const m of config.monitors ?? []) if (m.name) ctx.declareRef('monitors', m.name, 'monitors') + for (const d of config.dependencies ?? []) if (d.service) ctx.declareRef('dependencies', d.service, 'dependencies') +} + +// ── Generic array validator ──────────────────────────────────────────── + +function validateArray( + items: T[], + section: string, + ctx: ValidationContext, + itemValidator: (item: T, path: string, ctx: ValidationContext) => void, +): void { + if (!Array.isArray(items)) { + ctx.error(section, `"${section}" must be an array`) + return + } + for (let i = 0; i < items.length; i++) { + itemValidator(items[i], `${section}[${i}]`, ctx) + } +} + +// ── Individual resource validators ───────────────────────────────────── + +function validateTag(tag: YamlTag, path: string, ctx: ValidationContext): void { + requireString(tag, 'name', path, ctx) + if (tag.color !== undefined && typeof tag.color === 'string' && !/^#[0-9a-fA-F]{6}$/.test(tag.color)) { + ctx.warn(`${path}.color`, 'Color should be a hex code like #FF0000') + } +} + +function validateEnvironment(env: YamlEnvironment, path: string, ctx: ValidationContext): void { + requireString(env, 'name', path, ctx) + requireString(env, 'slug', path, ctx) + if (env.slug && !/^[a-z0-9_-]+$/.test(env.slug)) { + ctx.error(`${path}.slug`, 'Slug must be lowercase alphanumeric with hyphens and underscores') + } +} + +function validateSecretDef(secret: YamlSecret, path: string, ctx: ValidationContext): void { + requireString(secret, 'key', path, ctx) + requireString(secret, 'value', path, ctx) +} + +function validateAlertChannel(channel: YamlAlertChannel, path: string, ctx: ValidationContext): void { + requireString(channel, 'name', path, ctx) + if (!channel.type) { + ctx.error(`${path}.type`, '"type" is required') + } else if (!CHANNEL_TYPES.includes(channel.type)) { + ctx.error(`${path}.type`, `Invalid channel type "${channel.type}". Must be one of: ${CHANNEL_TYPES.join(', ')}`) + } + if (!channel.config || typeof channel.config !== 'object') { + ctx.error(`${path}.config`, '"config" is required and must be an object') + return + } + validateChannelConfig(channel.type, channel.config, `${path}.config`, ctx) +} + +function validateChannelConfig(type: string, config: YamlChannelConfig, path: string, ctx: ValidationContext): void { + switch (type) { + case 'slack': + case 'discord': + case 'teams': + if (!('webhookUrl' in config) || !config.webhookUrl) { + ctx.error(`${path}.webhookUrl`, `${type.charAt(0).toUpperCase() + type.slice(1)} channel requires "webhookUrl"`) + } + break + case 'email': + if (!('recipients' in config) || !Array.isArray(config.recipients) || config.recipients.length === 0) { + ctx.error(`${path}.recipients`, 'Email channel requires "recipients" array with at least one address') + } + break + case 'pagerduty': + if (!('routingKey' in config) || !config.routingKey) { + ctx.error(`${path}.routingKey`, 'PagerDuty channel requires "routingKey"') + } + break + case 'opsgenie': + if (!('apiKey' in config) || !config.apiKey) { + ctx.error(`${path}.apiKey`, 'OpsGenie channel requires "apiKey"') + } + break + case 'webhook': + if (!('url' in config) || !config.url) { + ctx.error(`${path}.url`, 'Webhook channel requires "url"') + } + break + } +} + +function validateNotificationPolicy(policy: YamlNotificationPolicy, path: string, ctx: ValidationContext): void { + requireString(policy, 'name', path, ctx) + + if (!policy.escalation) { + ctx.error(`${path}.escalation`, '"escalation" is required') + } else { + if (!policy.escalation.steps || !Array.isArray(policy.escalation.steps) || policy.escalation.steps.length === 0) { + ctx.error(`${path}.escalation.steps`, 'Escalation must have at least one step') + } else { + for (let i = 0; i < policy.escalation.steps.length; i++) { + validateEscalationStep(policy.escalation.steps[i], `${path}.escalation.steps[${i}]`, ctx) + } + } + } + + if (policy.matchRules) { + for (let i = 0; i < policy.matchRules.length; i++) { + validateMatchRule(policy.matchRules[i], `${path}.matchRules[${i}]`, ctx) + } + } + + if (policy.priority !== undefined && (typeof policy.priority !== 'number' || policy.priority < 0)) { + ctx.error(`${path}.priority`, 'Priority must be a non-negative number') + } +} + +function validateEscalationStep(step: YamlEscalationStep, path: string, ctx: ValidationContext): void { + if (!step.channels || !Array.isArray(step.channels) || step.channels.length === 0) { + ctx.error(`${path}.channels`, 'Each escalation step must have at least one channel') + } else { + for (const name of step.channels) { + ctx.checkRef('alertChannels', name, `${path}.channels`) + } + } + if (step.delayMinutes !== undefined && (typeof step.delayMinutes !== 'number' || step.delayMinutes < 0)) { + ctx.error(`${path}.delayMinutes`, 'delayMinutes must be a non-negative number') + } +} + +function validateMatchRule(rule: YamlMatchRule, path: string, ctx: ValidationContext): void { + if (!rule.type) { + ctx.error(`${path}.type`, 'Match rule requires "type"') + } + if (rule.monitorNames) { + for (const name of rule.monitorNames) { + ctx.checkRef('monitors', name, `${path}.monitorNames`) + } + } +} + +function validateWebhookDef(webhook: YamlWebhook, path: string, ctx: ValidationContext): void { + requireString(webhook, 'url', path, ctx) + if (!webhook.events || !Array.isArray(webhook.events) || webhook.events.length === 0) { + ctx.error(`${path}.events`, '"events" is required and must be a non-empty array') + } +} + +function validateResourceGroup(group: YamlResourceGroup, path: string, ctx: ValidationContext): void { + requireString(group, 'name', path, ctx) + + if (group.healthThresholdType && !HEALTH_THRESHOLD_TYPES.includes(group.healthThresholdType as typeof HEALTH_THRESHOLD_TYPES[number])) { + ctx.error(`${path}.healthThresholdType`, `Must be one of: ${HEALTH_THRESHOLD_TYPES.join(', ')}`) + } + + if (group.defaultFrequency !== undefined) { + validateFrequency(group.defaultFrequency, `${path}.defaultFrequency`, ctx) + } + + if (group.monitors) { + for (const name of group.monitors) { + ctx.checkRef('monitors', name, `${path}.monitors`) + } + } + if (group.services) { + for (const slug of group.services) { + ctx.checkRef('dependencies', slug, `${path}.services`) + } + } + if (group.defaultAlertChannels) { + for (const name of group.defaultAlertChannels) { + ctx.checkRef('alertChannels', name, `${path}.defaultAlertChannels`) + } + } + if (group.defaultEnvironment) { + ctx.checkRef('environments', group.defaultEnvironment, `${path}.defaultEnvironment`) + } + if (group.alertPolicy) { + ctx.checkRef('notificationPolicies', group.alertPolicy, `${path}.alertPolicy`) + } +} + +function validateMonitor(monitor: YamlMonitor, path: string, ctx: ValidationContext): void { + requireString(monitor, 'name', path, ctx) + + if (!monitor.type) { + ctx.error(`${path}.type`, '"type" is required') + } else if (!MONITOR_TYPES.includes(monitor.type)) { + ctx.error(`${path}.type`, `Invalid type "${monitor.type}". Must be one of: ${MONITOR_TYPES.join(', ')}`) + } + + if (!monitor.config || typeof monitor.config !== 'object') { + ctx.error(`${path}.config`, '"config" is required and must be an object') + } else { + validateMonitorConfig(monitor.type, monitor.config, `${path}.config`, ctx) + } + + if (monitor.frequency !== undefined) { + validateFrequency(monitor.frequency, `${path}.frequency`, ctx) + } + + if (monitor.regions && !Array.isArray(monitor.regions)) { + ctx.error(`${path}.regions`, '"regions" must be an array of strings') + } + + if (monitor.environment) { + ctx.checkRef('environments', monitor.environment, `${path}.environment`) + } + if (monitor.tags) { + for (const name of monitor.tags) { + ctx.checkRef('tags', name, `${path}.tags`) + } + } + if (monitor.alertChannels) { + for (const name of monitor.alertChannels) { + ctx.checkRef('alertChannels', name, `${path}.alertChannels`) + } + } + if (monitor.assertions) { + for (let i = 0; i < monitor.assertions.length; i++) { + validateAssertionDef(monitor.assertions[i], `${path}.assertions[${i}]`, ctx) + } + } + + if (monitor.auth) { + validateAuth(monitor.auth, `${path}.auth`, ctx) + } + + if (monitor.incidentPolicy) { + validateIncidentPolicy(monitor.incidentPolicy, `${path}.incidentPolicy`, ctx) + } +} + +function validateMonitorConfig(type: string, config: YamlMonitorConfig, path: string, ctx: ValidationContext): void { + switch (type) { + case 'HTTP': + if (!('url' in config) || !config.url) ctx.error(`${path}.url`, 'HTTP monitor requires "url"') + if ('method' in config && config.method && !HTTP_METHODS.includes(config.method as typeof HTTP_METHODS[number])) { + ctx.error(`${path}.method`, `Invalid HTTP method. Must be one of: ${HTTP_METHODS.join(', ')}`) + } + break + case 'DNS': + if (!('hostname' in config) || !config.hostname) ctx.error(`${path}.hostname`, 'DNS monitor requires "hostname"') + if ('recordTypes' in config && config.recordTypes && Array.isArray(config.recordTypes)) { + for (const rt of config.recordTypes) { + if (!DNS_RECORD_TYPES.includes(rt as string)) { + ctx.error(`${path}.recordTypes`, `Invalid DNS record type "${rt}". Must be one of: ${DNS_RECORD_TYPES.join(', ')}`) + } + } + } + break + case 'TCP': + if (!('host' in config) || !config.host) ctx.error(`${path}.host`, 'TCP monitor requires "host"') + if ('port' in config && config.port !== undefined && (typeof config.port !== 'number' || config.port < 1 || config.port > 65535)) { + ctx.error(`${path}.port`, 'TCP port must be between 1 and 65535') + } + break + case 'ICMP': + if (!('host' in config) || !config.host) ctx.error(`${path}.host`, 'ICMP monitor requires "host"') + break + case 'HEARTBEAT': + if (!('expectedInterval' in config) || typeof config.expectedInterval !== 'number' || config.expectedInterval <= 0) { + ctx.error(`${path}.expectedInterval`, 'Heartbeat monitor requires "expectedInterval" (positive number)') + } + if (!('gracePeriod' in config) || typeof config.gracePeriod !== 'number' || config.gracePeriod <= 0) { + ctx.error(`${path}.gracePeriod`, 'Heartbeat monitor requires "gracePeriod" (positive number)') + } + break + case 'MCP_SERVER': + if (!('command' in config) || !config.command) ctx.error(`${path}.command`, 'MCP_SERVER monitor requires "command"') + break + } +} + +function validateAssertionDef(assertion: YamlAssertion, path: string, ctx: ValidationContext): void { + if (!assertion.type) { + ctx.error(`${path}.type`, 'Assertion requires "type"') + } else if (!ASSERTION_TYPES.includes(assertion.type as typeof ASSERTION_TYPES[number])) { + ctx.error(`${path}.type`, `Unknown assertion type "${assertion.type}". See docs for valid assertion types.`) + } + + if (assertion.severity && !ASSERTION_SEVERITIES.includes(assertion.severity)) { + ctx.error(`${path}.severity`, `Assertion severity must be one of: ${ASSERTION_SEVERITIES.join(', ')}`) + } + + if (assertion.config && assertion.type) { + validateAssertionConfig(assertion.type, assertion.config, path, ctx) + } +} + +function validateAssertionConfig(type: string, config: Record, path: string, ctx: ValidationContext): void { + const needsOperator = ['StatusCodeAssertion', 'HeaderValueAssertion', 'JsonPathAssertion', 'RedirectTargetAssertion'] + if (needsOperator.includes(type)) { + if (config.operator && !COMPARISON_OPERATORS.includes(config.operator as string)) { + ctx.error(`${path}.config.operator`, `Invalid operator. Must be one of: ${COMPARISON_OPERATORS.join(', ')}`) + } + } +} + +function validateAuth(auth: YamlAuth, path: string, ctx: ValidationContext): void { + const validTypes = ['BearerAuthConfig', 'BasicAuthConfig', 'ApiKeyAuthConfig', 'HeaderAuthConfig'] + if (!auth.type || !validTypes.includes(auth.type)) { + ctx.error(`${path}.type`, `Auth type must be one of: ${validTypes.join(', ')}`) + } + if (!auth.secret) { + ctx.error(`${path}.secret`, 'Auth requires "secret" (vault secret key reference)') + } else { + ctx.checkRef('secrets', auth.secret, `${path}.secret`) + } + if ((auth.type === 'ApiKeyAuthConfig' || auth.type === 'HeaderAuthConfig') && !('headerName' in auth && auth.headerName)) { + ctx.error(`${path}.headerName`, `${auth.type} requires "headerName"`) + } +} + +function validateIncidentPolicy(policy: YamlIncidentPolicy, path: string, ctx: ValidationContext): void { + if (!policy.triggerRules || !Array.isArray(policy.triggerRules) || policy.triggerRules.length === 0) { + ctx.error(`${path}.triggerRules`, 'Incident policy requires at least one trigger rule') + return + } + + for (let i = 0; i < policy.triggerRules.length; i++) { + const rule = policy.triggerRules[i] + const rpath = `${path}.triggerRules[${i}]` + if (!TRIGGER_RULE_TYPES.includes(rule.type)) { + ctx.error(`${rpath}.type`, `Invalid trigger type. Must be one of: ${TRIGGER_RULE_TYPES.join(', ')}`) + } + if (rule.scope !== null && rule.scope !== undefined && !TRIGGER_SCOPES.includes(rule.scope)) { + ctx.error(`${rpath}.scope`, `Invalid scope. Must be one of: ${TRIGGER_SCOPES.join(', ')}`) + } + if (!TRIGGER_SEVERITIES.includes(rule.severity)) { + ctx.error(`${rpath}.severity`, `Must be one of: ${TRIGGER_SEVERITIES.join(', ')}`) + } + if (rule.aggregationType && !TRIGGER_AGGREGATIONS.includes(rule.aggregationType as string)) { + ctx.error(`${rpath}.aggregationType`, `Must be one of: ${TRIGGER_AGGREGATIONS.join(', ')}`) + } + } + + if (!policy.confirmation) { + ctx.error(`${path}.confirmation`, 'Incident policy requires "confirmation"') + } else if (policy.confirmation.type !== 'multi_region') { + ctx.error(`${path}.confirmation.type`, 'Confirmation type must be "multi_region"') + } + + if (!policy.recovery) { + ctx.error(`${path}.recovery`, 'Incident policy requires "recovery"') + } +} + +function validateFrequency(freq: number, path: string, ctx: ValidationContext): void { + if (typeof freq !== 'number') { + ctx.error(path, 'Frequency must be a number') + } else if (freq < MIN_FREQUENCY || freq > MAX_FREQUENCY) { + ctx.error(path, `Frequency must be between ${MIN_FREQUENCY} and ${MAX_FREQUENCY} seconds`) + } +} + +// ── Helpers ──────────────────────────────────────────────────────────── + +function validateDependency(dep: YamlDependency, path: string, ctx: ValidationContext): void { + requireString(dep, 'service', path, ctx) + if (dep.alertSensitivity && !ALERT_SENSITIVITIES.includes(dep.alertSensitivity as typeof ALERT_SENSITIVITIES[number])) { + ctx.error(`${path}.alertSensitivity`, `Must be one of: ${ALERT_SENSITIVITIES.join(', ')}`) + } +} + +function requireString(obj: object, field: string, path: string, ctx: ValidationContext): void { + const record = obj as Record + if (!record[field] || typeof record[field] !== 'string') { + ctx.error(`${path}.${field}`, `"${field}" is required`) + } +} diff --git a/test/fixtures/yaml/edge/all-channel-types.yml b/test/fixtures/yaml/edge/all-channel-types.yml new file mode 100644 index 0000000..64a76e3 --- /dev/null +++ b/test/fixtures/yaml/edge/all-channel-types.yml @@ -0,0 +1,42 @@ +alertChannels: + - name: slack-chan + type: slack + config: + webhookUrl: https://hooks.slack.com/services/T/B/x + mentionText: "@channel" + + - name: discord-chan + type: discord + config: + webhookUrl: https://discord.com/api/webhooks/123/abc + mentionRoleId: "12345" + + - name: email-chan + type: email + config: + recipients: [a@test.com, b@test.com] + + - name: pagerduty-chan + type: pagerduty + config: + routingKey: routing-key-123 + severityOverride: critical + + - name: opsgenie-chan + type: opsgenie + config: + apiKey: og-api-key-456 + region: us + + - name: teams-chan + type: teams + config: + webhookUrl: https://outlook.office.com/webhook/xxx + + - name: webhook-chan + type: webhook + config: + url: https://webhooks.example.com/alerts + signingSecret: secret-123 + customHeaders: + Authorization: Bearer token diff --git a/test/fixtures/yaml/edge/all-monitor-types.yml b/test/fixtures/yaml/edge/all-monitor-types.yml new file mode 100644 index 0000000..ac84074 --- /dev/null +++ b/test/fixtures/yaml/edge/all-monitor-types.yml @@ -0,0 +1,48 @@ +monitors: + - name: HTTP Monitor + type: HTTP + config: + url: https://example.com + method: POST + customHeaders: + X-Custom: value + requestBody: '{"health": true}' + contentType: application/json + verifyTls: true + + - name: DNS Monitor + type: DNS + config: + hostname: example.com + recordTypes: [A, AAAA, CNAME, MX, NS, TXT] + nameservers: ["8.8.8.8", "1.1.1.1"] + timeoutMs: 5000 + + - name: TCP Monitor + type: TCP + config: + host: db.example.com + port: 5432 + timeoutMs: 3000 + + - name: ICMP Monitor + type: ICMP + config: + host: gateway.example.com + packetCount: 5 + timeoutMs: 2000 + + - name: Heartbeat Monitor + type: HEARTBEAT + config: + expectedInterval: 60 + gracePeriod: 120 + + - name: MCP Monitor + type: MCP_SERVER + config: + command: node + args: ["server.js", "--port", "3000"] + env: + NODE_ENV: production + API_KEY: test-key diff --git a/test/fixtures/yaml/invalid/bad-channel-type.yml b/test/fixtures/yaml/invalid/bad-channel-type.yml new file mode 100644 index 0000000..52560d9 --- /dev/null +++ b/test/fixtures/yaml/invalid/bad-channel-type.yml @@ -0,0 +1,5 @@ +alertChannels: + - name: bad + type: sms + config: + phone: "+1234567890" diff --git a/test/fixtures/yaml/invalid/bad-escalation.yml b/test/fixtures/yaml/invalid/bad-escalation.yml new file mode 100644 index 0000000..1dc3407 --- /dev/null +++ b/test/fixtures/yaml/invalid/bad-escalation.yml @@ -0,0 +1,10 @@ +alertChannels: + - name: ops + type: slack + config: + webhookUrl: https://hooks.slack.com/test + +notificationPolicies: + - name: missing-steps + escalation: + steps: [] diff --git a/test/fixtures/yaml/invalid/bad-frequency.yml b/test/fixtures/yaml/invalid/bad-frequency.yml new file mode 100644 index 0000000..f38daf2 --- /dev/null +++ b/test/fixtures/yaml/invalid/bad-frequency.yml @@ -0,0 +1,7 @@ +monitors: + - name: Too Fast + type: HTTP + config: + url: https://example.com + method: GET + frequency: 5 diff --git a/test/fixtures/yaml/invalid/bad-type.yml b/test/fixtures/yaml/invalid/bad-type.yml new file mode 100644 index 0000000..188e89e --- /dev/null +++ b/test/fixtures/yaml/invalid/bad-type.yml @@ -0,0 +1,5 @@ +monitors: + - name: Bad Type + type: WEBSOCKET + config: + url: wss://example.com diff --git a/test/fixtures/yaml/invalid/duplicate-names.yml b/test/fixtures/yaml/invalid/duplicate-names.yml new file mode 100644 index 0000000..b614cd0 --- /dev/null +++ b/test/fixtures/yaml/invalid/duplicate-names.yml @@ -0,0 +1,11 @@ +monitors: + - name: Duplicate + type: HTTP + config: + url: https://a.example.com + method: GET + - name: Duplicate + type: HTTP + config: + url: https://b.example.com + method: GET diff --git a/test/fixtures/yaml/invalid/empty.yml b/test/fixtures/yaml/invalid/empty.yml new file mode 100644 index 0000000..b7f0ef8 --- /dev/null +++ b/test/fixtures/yaml/invalid/empty.yml @@ -0,0 +1,2 @@ +# Empty config — no resources defined +version: "1" diff --git a/test/fixtures/yaml/invalid/missing-env-var.yml b/test/fixtures/yaml/invalid/missing-env-var.yml new file mode 100644 index 0000000..563176d --- /dev/null +++ b/test/fixtures/yaml/invalid/missing-env-var.yml @@ -0,0 +1,10 @@ +monitors: + - name: Needs Token + type: HTTP + config: + url: https://example.com + method: GET + +secrets: + - key: api-key + value: ${MISSING_SECRET_VALUE} diff --git a/test/fixtures/yaml/invalid/missing-name.yml b/test/fixtures/yaml/invalid/missing-name.yml new file mode 100644 index 0000000..80d638b --- /dev/null +++ b/test/fixtures/yaml/invalid/missing-name.yml @@ -0,0 +1,5 @@ +monitors: + - type: HTTP + config: + url: https://example.com + method: GET diff --git a/test/fixtures/yaml/valid/defaults.yml b/test/fixtures/yaml/valid/defaults.yml new file mode 100644 index 0000000..bc31472 --- /dev/null +++ b/test/fixtures/yaml/valid/defaults.yml @@ -0,0 +1,20 @@ +defaults: + monitors: + frequency: 120 + enabled: true + regions: [us-east, eu-west] + +monitors: + - name: Monitor A + type: HTTP + config: + url: https://a.example.com + method: GET + + - name: Monitor B + type: HTTP + config: + url: https://b.example.com + method: GET + frequency: 30 + regions: [us-west] diff --git a/test/fixtures/yaml/valid/env-vars.yml b/test/fixtures/yaml/valid/env-vars.yml new file mode 100644 index 0000000..b235d02 --- /dev/null +++ b/test/fixtures/yaml/valid/env-vars.yml @@ -0,0 +1,12 @@ +monitors: + - name: Prod Monitor + type: HTTP + config: + url: ${APP_URL:-https://default.example.com} + method: GET + +alertChannels: + - name: slack-channel + type: slack + config: + webhookUrl: ${SLACK_WEBHOOK:-https://hooks.slack.com/default} diff --git a/test/fixtures/yaml/valid/full-stack.yml b/test/fixtures/yaml/valid/full-stack.yml new file mode 100644 index 0000000..782fb56 --- /dev/null +++ b/test/fixtures/yaml/valid/full-stack.yml @@ -0,0 +1,166 @@ +version: "1" + +defaults: + monitors: + frequency: 60 + enabled: true + regions: [us-east, eu-west] + +tags: + - name: production + color: "#EF4444" + - name: api + color: "#3B82F6" + +environments: + - name: Production + slug: production + isDefault: true + - name: Staging + slug: staging + +secrets: + - key: bearer-token + value: secret-value-123 + +alertChannels: + - name: ops-slack + type: slack + config: + webhookUrl: https://hooks.slack.com/services/T000/B000/xxx + - name: eng-email + type: email + config: + recipients: + - eng@company.com + - oncall@company.com + - name: pagerduty-critical + type: pagerduty + config: + routingKey: service-key-abc + +notificationPolicies: + - name: critical-escalation + enabled: true + priority: 1 + escalation: + steps: + - channels: [ops-slack] + delayMinutes: 0 + - channels: [pagerduty-critical] + delayMinutes: 5 + requireAck: true + +webhooks: + - url: https://hooks.company.com/devhelm + events: [monitor.down, monitor.recovered, incident.created] + description: Internal webhook for status page + +resourceGroups: + - name: API Services + description: Core API monitors + defaultFrequency: 30 + defaultRegions: [us-east, us-west, eu-west] + monitors: [API Health, DNS Check] + defaultAlertChannels: [ops-slack] + defaultEnvironment: production + healthThresholdType: PERCENTAGE + healthThresholdValue: 80 + +monitors: + - name: Website + type: HTTP + config: + url: https://www.company.com + method: GET + verifyTls: true + tags: [production] + alertChannels: [ops-slack] + environment: production + assertions: + - type: StatusCodeAssertion + config: + expected: "200" + operator: equals + severity: fail + - type: ResponseTimeAssertion + config: + thresholdMs: 2000 + severity: warn + - type: SslExpiryAssertion + config: + minDaysRemaining: 30 + severity: warn + + - name: API Health + type: HTTP + config: + url: https://api.company.com/health + method: GET + frequency: 30 + tags: [production, api] + alertChannels: [ops-slack, pagerduty-critical] + auth: + type: BearerAuthConfig + secret: bearer-token + incidentPolicy: + triggerRules: + - type: consecutive_failures + count: 3 + scope: per_region + severity: down + - type: response_time + thresholdMs: 5000 + scope: any_region + severity: degraded + aggregationType: p95 + confirmation: + type: multi_region + minRegionsFailing: 2 + maxWaitSeconds: 120 + recovery: + consecutiveSuccesses: 2 + minRegionsPassing: 2 + cooldownMinutes: 5 + + - name: DNS Check + type: DNS + config: + hostname: company.com + recordTypes: [A, AAAA, MX] + frequency: 300 + assertions: + - type: DnsResolvesAssertion + severity: fail + + - name: TCP Database + type: TCP + config: + host: db.internal.company.com + port: 5432 + frequency: 60 + + - name: ICMP Gateway + type: ICMP + config: + host: gateway.company.com + frequency: 60 + + - name: Worker Heartbeat + type: HEARTBEAT + config: + expectedInterval: 120 + gracePeriod: 300 + + - name: MCP Assistant + type: MCP_SERVER + config: + command: npx + args: ["-y", "@company/mcp-server"] + frequency: 300 + +dependencies: + - service: github + alertSensitivity: INCIDENTS_ONLY + - service: cloudflare + alertSensitivity: ALL diff --git a/test/fixtures/yaml/valid/minimal.yml b/test/fixtures/yaml/valid/minimal.yml new file mode 100644 index 0000000..cc7b7ea --- /dev/null +++ b/test/fixtures/yaml/valid/minimal.yml @@ -0,0 +1,6 @@ +monitors: + - name: Simple Health Check + type: HTTP + config: + url: https://example.com + method: GET diff --git a/test/fixtures/yaml/valid/multi-a.yml b/test/fixtures/yaml/valid/multi-a.yml new file mode 100644 index 0000000..85bd6ce --- /dev/null +++ b/test/fixtures/yaml/valid/multi-a.yml @@ -0,0 +1,11 @@ +tags: + - name: backend + color: "#6366F1" + +monitors: + - name: Backend API + type: HTTP + config: + url: https://api.example.com + method: GET + tags: [backend] diff --git a/test/fixtures/yaml/valid/multi-b.yml b/test/fixtures/yaml/valid/multi-b.yml new file mode 100644 index 0000000..87474b9 --- /dev/null +++ b/test/fixtures/yaml/valid/multi-b.yml @@ -0,0 +1,11 @@ +tags: + - name: frontend + color: "#10B981" + +monitors: + - name: Frontend App + type: HTTP + config: + url: https://app.example.com + method: GET + tags: [frontend] diff --git a/test/yaml/applier.test.ts b/test/yaml/applier.test.ts new file mode 100644 index 0000000..0d9bb17 --- /dev/null +++ b/test/yaml/applier.test.ts @@ -0,0 +1,673 @@ +import {describe, it, expect, vi, beforeEach} from 'vitest' +import {apply} from '../../src/lib/yaml/applier.js' +import {ResolvedRefs} from '../../src/lib/yaml/resolver.js' +import type {Changeset, Change} from '../../src/lib/yaml/differ.js' + +vi.mock('../../src/lib/api-client.js', () => ({ + checkedFetch: vi.fn(async (p: unknown) => p), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + apiDelete: vi.fn(async (client: any, path: string) => client.DELETE(path, {params: {path: {}}})), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + apiPatch: vi.fn(async (client: any, path: string, body: object) => client.PATCH(path, {body})), +})) + +function emptyChangeset(): Changeset { + return {creates: [], updates: [], deletes: [], memberships: []} +} + +function emptyRefs(): ResolvedRefs { + return new ResolvedRefs() +} + +function makeFakeClient() { + return { + GET: vi.fn(), + POST: vi.fn(), + PUT: vi.fn(), + PATCH: vi.fn(), + DELETE: vi.fn(), + } as Parameters[2] +} + +describe('applier', () => { + let fakeClient: ReturnType + let mockPost: ReturnType + let mockPut: ReturnType + let mockPatch: ReturnType + let mockDelete: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + fakeClient = makeFakeClient() + mockPost = fakeClient.POST as ReturnType + mockPut = fakeClient.PUT as ReturnType + mockPatch = fakeClient.PATCH as ReturnType + mockDelete = fakeClient.DELETE as ReturnType + }) + + describe('creates', () => { + it('creates a tag', async () => { + mockPost.mockResolvedValueOnce({data: {id: 'tag-new'}}) + const changeset: Changeset = { + ...emptyChangeset(), + creates: [{action: 'create', resourceType: 'tag', refKey: 'prod', desired: {name: 'prod', color: '#FF0000'}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(result.succeeded[0].id).toBe('tag-new') + expect(result.failed).toHaveLength(0) + expect(mockPost).toHaveBeenCalledWith('/api/v1/tags', {body: {name: 'prod', color: '#FF0000'}}) + }) + + it('creates an environment', async () => { + mockPost.mockResolvedValueOnce({data: {id: 'env-new'}}) + const changeset: Changeset = { + ...emptyChangeset(), + creates: [{action: 'create', resourceType: 'environment', refKey: 'staging', desired: {name: 'Staging', slug: 'staging'}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPost).toHaveBeenCalledWith('/api/v1/environments', {body: expect.objectContaining({name: 'Staging', slug: 'staging'})}) + }) + + it('creates a secret', async () => { + mockPost.mockResolvedValueOnce({data: {id: 'sec-new'}}) + const changeset: Changeset = { + ...emptyChangeset(), + creates: [{action: 'create', resourceType: 'secret', refKey: 'api-key', desired: {key: 'api-key', value: 'secret123'}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPost).toHaveBeenCalledWith('/api/v1/secrets', {body: {key: 'api-key', value: 'secret123'}}) + }) + + it('creates an alert channel', async () => { + mockPost.mockResolvedValueOnce({data: {id: 'ch-new'}}) + const changeset: Changeset = { + ...emptyChangeset(), + creates: [{action: 'create', resourceType: 'alertChannel', refKey: 'slack', desired: {name: 'slack', type: 'slack', config: {webhookUrl: 'url'}}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPost).toHaveBeenCalledWith('/api/v1/alert-channels', {body: expect.objectContaining({name: 'slack'})}) + }) + + it('creates a monitor', async () => { + mockPost.mockResolvedValueOnce({data: {id: 'mon-new'}}) + const changeset: Changeset = { + ...emptyChangeset(), + creates: [{action: 'create', resourceType: 'monitor', refKey: 'API', desired: { + name: 'API', type: 'HTTP', config: {url: 'https://x.com', method: 'GET'}, + }}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(result.succeeded[0].id).toBe('mon-new') + expect(result.stateEntries).toHaveLength(1) + expect(result.stateEntries[0].resourceType).toBe('monitor') + expect(result.stateEntries[0].id).toBe('mon-new') + }) + + it('injects ref after create for downstream resolution', async () => { + mockPost.mockResolvedValueOnce({data: {id: 'tag-99'}}) + const refs = emptyRefs() + const changeset: Changeset = { + ...emptyChangeset(), + creates: [{action: 'create', resourceType: 'tag', refKey: 'new-tag', desired: {name: 'new-tag'}}], + } + await apply(changeset, refs, fakeClient) + expect(refs.resolve('tags', 'new-tag')).toBe('tag-99') + }) + + it('creates a notification policy with channel refs resolved in escalation', async () => { + mockPost.mockResolvedValueOnce({data: {id: 'np-new'}}) + const refs = emptyRefs() + refs.set('alertChannels', 'slack', {id: 'ch-1', refKey: 'slack', raw: {}}) + const changeset: Changeset = { + ...emptyChangeset(), + creates: [{ + action: 'create', resourceType: 'notificationPolicy', refKey: 'default', + desired: { + name: 'default', + escalation: {steps: [{channels: ['slack'], delayMinutes: 0}]}, + }, + }], + } + const result = await apply(changeset, refs, fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPost).toHaveBeenCalledWith( + '/api/v1/notification-policies', + {body: expect.objectContaining({ + name: 'default', + escalation: expect.objectContaining({ + steps: [expect.objectContaining({channelIds: ['ch-1']})], + }), + })}, + ) + }) + + it('creates a webhook', async () => { + mockPost.mockResolvedValueOnce({data: {id: 'wh-new'}}) + const changeset: Changeset = { + ...emptyChangeset(), + creates: [{ + action: 'create', resourceType: 'webhook', refKey: 'hook1', + desired: {url: 'https://hook.com', events: ['monitor.down']}, + }], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPost).toHaveBeenCalledWith('/api/v1/webhooks', {body: { + url: 'https://hook.com', + subscribedEvents: ['monitor.down'], + description: undefined, + }}) + }) + + it('creates a resource group', async () => { + mockPost.mockResolvedValueOnce({data: {id: 'rg-new'}}) + const changeset: Changeset = { + ...emptyChangeset(), + creates: [{action: 'create', resourceType: 'resourceGroup', refKey: 'API', desired: {name: 'API Group'}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPost).toHaveBeenCalledWith( + '/api/v1/resource-groups', + {body: expect.objectContaining({name: 'API Group'})}, + ) + }) + + it('creates a dependency (service subscription)', async () => { + mockPost.mockResolvedValueOnce({data: {subscriptionId: 'sub-gh'}}) + const changeset: Changeset = { + ...emptyChangeset(), + creates: [{ + action: 'create', resourceType: 'dependency', refKey: 'github', + desired: {service: 'github', alertSensitivity: 'ALL'}, + }], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPost).toHaveBeenCalledWith('/api/v1/service-subscriptions/{slug}', { + params: {path: {slug: 'github'}}, + body: {alertSensitivity: 'ALL', componentId: null}, + }) + }) + }) + + describe('updates', () => { + it('updates a tag via PUT', async () => { + mockPut.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + updates: [{ + action: 'update', resourceType: 'tag', refKey: 'prod', existingId: 'tag-1', + desired: {name: 'prod', color: '#00FF00'}, current: {}, + }], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPut).toHaveBeenCalledWith('/api/v1/tags/{id}', {params: {path: {id: 'tag-1'}}, body: {name: 'prod', color: '#00FF00'}}) + }) + + it('updates a monitor via PUT', async () => { + mockPut.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + updates: [{ + action: 'update', resourceType: 'monitor', refKey: 'API', existingId: 'mon-1', + desired: {name: 'API', type: 'HTTP', config: {url: 'https://x.com', method: 'GET'}, frequency: 30}, + current: {}, + }], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(result.stateEntries).toHaveLength(1) + expect(result.stateEntries[0].id).toBe('mon-1') + }) + + it('updates a dependency via PATCH', async () => { + mockPatch.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + updates: [{ + action: 'update', resourceType: 'dependency', refKey: 'github', existingId: 'dep-1', + desired: {service: 'github', alertSensitivity: 'ALL'}, current: {}, + }], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPatch).toHaveBeenCalledWith( + '/api/v1/service-subscriptions/{id}/alert-sensitivity', + {params: {path: {id: 'dep-1'}}, body: {alertSensitivity: 'ALL'}}, + ) + }) + + it('updates an environment via PUT using environment id in path', async () => { + mockPut.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + updates: [{ + action: 'update', resourceType: 'environment', refKey: 'prod', existingId: 'env-42', + desired: {name: 'Prod', slug: 'prod', variables: {KEY: 'val'}}, current: {}, + }], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPut).toHaveBeenCalledWith('/api/v1/environments/{slug}', { + params: {path: {slug: 'prod'}}, + body: {name: 'Prod', variables: {KEY: 'val'}, isDefault: undefined}, + }) + }) + + it('updates a secret via PUT keyed by secret key', async () => { + mockPut.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + updates: [{ + action: 'update', resourceType: 'secret', refKey: 'k', existingId: 'sec-1', + desired: {key: 'k', value: 'newval'}, current: {}, + }], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPut).toHaveBeenCalledWith('/api/v1/secrets/{key}', {params: {path: {key: 'k'}}, body: {value: 'newval'}}) + }) + + it('updates an alert channel via PUT', async () => { + mockPut.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + updates: [{ + action: 'update', resourceType: 'alertChannel', refKey: 'slack', existingId: 'ch-1', + desired: {name: 'slack', type: 'slack', config: {webhookUrl: 'url'}}, current: {}, + }], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPut).toHaveBeenCalledWith( + '/api/v1/alert-channels/{id}', + {params: {path: {id: 'ch-1'}}, body: expect.objectContaining({ + name: 'slack', + config: expect.objectContaining({channelType: 'SlackChannelConfig', webhookUrl: 'url'}), + })}, + ) + }) + + it('updates a notification policy via PUT', async () => { + mockPut.mockResolvedValueOnce(undefined) + const refs = emptyRefs() + refs.set('alertChannels', 'slack', {id: 'ch-1', refKey: 'slack', raw: {}}) + const changeset: Changeset = { + ...emptyChangeset(), + updates: [{ + action: 'update', resourceType: 'notificationPolicy', refKey: 'pol', existingId: 'np-1', + desired: { + name: 'pol', + escalation: {steps: [{channels: ['slack']}]}, + }, + current: {}, + }], + } + const result = await apply(changeset, refs, fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPut).toHaveBeenCalledWith( + '/api/v1/notification-policies/{id}', + {params: {path: {id: 'np-1'}}, body: expect.objectContaining({ + name: 'pol', + escalation: expect.objectContaining({ + steps: [expect.objectContaining({channelIds: ['ch-1']})], + }), + })}, + ) + }) + + it('updates a webhook via PUT', async () => { + mockPut.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + updates: [{ + action: 'update', resourceType: 'webhook', refKey: 'w', existingId: 'wh-1', + desired: {url: 'https://hook.com', events: ['monitor.up']}, current: {}, + }], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPut).toHaveBeenCalledWith('/api/v1/webhooks/{id}', { + params: {path: {id: 'wh-1'}}, + body: {url: 'https://hook.com', subscribedEvents: ['monitor.up'], description: undefined, enabled: null}, + }) + }) + + it('updates a resource group via PUT', async () => { + mockPut.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + updates: [{ + action: 'update', resourceType: 'resourceGroup', refKey: 'G', existingId: 'rg-9', + desired: {name: 'Renamed'}, current: {}, + }], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPut).toHaveBeenCalledWith( + '/api/v1/resource-groups/{id}', + {params: {path: {id: 'rg-9'}}, body: expect.objectContaining({name: 'Renamed'})}, + ) + }) + + it('updates dependency component via PATCH to subscription', async () => { + mockPatch.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + updates: [{ + action: 'update', resourceType: 'dependency', refKey: 'gh', existingId: 'dep-2', + desired: {service: 'gh', component: 'api'}, current: {}, + }], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPatch).toHaveBeenCalledTimes(1) + expect(mockPatch).toHaveBeenCalledWith('/api/v1/service-subscriptions/dep-2', {body: {componentId: 'api'}}) + }) + + it('does not PATCH alert-sensitivity when dependency update omits alertSensitivity', async () => { + const changeset: Changeset = { + ...emptyChangeset(), + updates: [{ + action: 'update', resourceType: 'dependency', refKey: 'gh', existingId: 'dep-3', + desired: {service: 'gh'}, current: {}, + }], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPatch).not.toHaveBeenCalled() + }) + }) + + describe('deletes', () => { + it('deletes a tag', async () => { + mockDelete.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + deletes: [{action: 'delete', resourceType: 'tag', refKey: 'old', existingId: 'tag-1', current: {}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockDelete).toHaveBeenCalledWith('/api/v1/tags/tag-1', expect.anything()) + }) + + it('deletes a monitor', async () => { + mockDelete.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + deletes: [{action: 'delete', resourceType: 'monitor', refKey: 'old', existingId: 'mon-1', current: {}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockDelete).toHaveBeenCalledWith('/api/v1/monitors/mon-1', expect.anything()) + }) + + it('deletes an environment', async () => { + mockDelete.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + deletes: [{action: 'delete', resourceType: 'environment', refKey: 'stg', existingId: 'env-7', current: {}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockDelete).toHaveBeenCalledWith('/api/v1/environments/stg', expect.anything()) + }) + + it('deletes a secret', async () => { + mockDelete.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + deletes: [{action: 'delete', resourceType: 'secret', refKey: 'k', existingId: 'sec-x', current: {}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockDelete).toHaveBeenCalledWith('/api/v1/secrets/sec-x', expect.anything()) + }) + + it('deletes an alert channel', async () => { + mockDelete.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + deletes: [{action: 'delete', resourceType: 'alertChannel', refKey: 's', existingId: 'ch-9', current: {}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockDelete).toHaveBeenCalledWith('/api/v1/alert-channels/ch-9', expect.anything()) + }) + + it('deletes a notification policy', async () => { + mockDelete.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + deletes: [{action: 'delete', resourceType: 'notificationPolicy', refKey: 'p', existingId: 'np-2', current: {}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockDelete).toHaveBeenCalledWith('/api/v1/notification-policies/np-2', expect.anything()) + }) + + it('deletes a webhook', async () => { + mockDelete.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + deletes: [{action: 'delete', resourceType: 'webhook', refKey: 'w', existingId: 'wh-3', current: {}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockDelete).toHaveBeenCalledWith('/api/v1/webhooks/wh-3', expect.anything()) + }) + + it('deletes a resource group', async () => { + mockDelete.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + deletes: [{action: 'delete', resourceType: 'resourceGroup', refKey: 'G', existingId: 'rg-4', current: {}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockDelete).toHaveBeenCalledWith('/api/v1/resource-groups/rg-4', expect.anything()) + }) + + it('deletes a dependency', async () => { + mockDelete.mockResolvedValueOnce(undefined) + const changeset: Changeset = { + ...emptyChangeset(), + deletes: [{action: 'delete', resourceType: 'dependency', refKey: 'gh', existingId: 'dep-z', current: {}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockDelete).toHaveBeenCalledWith('/api/v1/service-subscriptions/dep-z', expect.anything()) + }) + }) + + describe('memberships', () => { + it('creates group membership', async () => { + mockPost.mockResolvedValueOnce(undefined) + const refs = new ResolvedRefs() + refs.set('resourceGroups', 'API', {id: 'rg-1', refKey: 'API', raw: {}}) + refs.set('monitors', 'Health', {id: 'mon-1', refKey: 'Health', raw: {}}) + const changeset: Changeset = { + ...emptyChangeset(), + memberships: [{ + action: 'create', resourceType: 'groupMembership', refKey: 'API → Health', + desired: {groupName: 'API', memberType: 'monitor', memberRef: 'Health'}, + }], + } + const result = await apply(changeset, refs, fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPost).toHaveBeenCalledWith('/api/v1/resource-groups/{id}/members', { + params: {path: {id: 'rg-1'}}, + body: {memberType: 'monitor', memberId: 'mon-1'}, + }) + }) + + it('creates service membership', async () => { + mockPost.mockResolvedValueOnce(undefined) + const refs = new ResolvedRefs() + refs.set('resourceGroups', 'G', {id: 'rg-1', refKey: 'G', raw: {}}) + refs.set('dependencies', 'github', {id: 'dep-1', refKey: 'github', raw: {}}) + const changeset: Changeset = { + ...emptyChangeset(), + memberships: [{ + action: 'create', resourceType: 'groupMembership', refKey: 'G → github', + desired: {groupName: 'G', memberType: 'service', memberRef: 'github'}, + }], + } + const result = await apply(changeset, refs, fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(mockPost).toHaveBeenCalledWith('/api/v1/resource-groups/{id}/members', { + params: {path: {id: 'rg-1'}}, + body: {memberType: 'service', memberId: 'dep-1'}, + }) + }) + }) + + describe('error handling', () => { + it('continues after create failure', async () => { + mockPost + .mockRejectedValueOnce(new Error('API error on first')) + .mockResolvedValueOnce({data: {id: 'tag-2'}}) + const changeset: Changeset = { + ...emptyChangeset(), + creates: [ + {action: 'create', resourceType: 'tag', refKey: 'fail', desired: {name: 'fail'}}, + {action: 'create', resourceType: 'tag', refKey: 'ok', desired: {name: 'ok'}}, + ], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(1) + expect(result.failed).toHaveLength(1) + expect(result.failed[0].refKey).toBe('fail') + expect(result.failed[0].error).toContain('API error on first') + expect(result.succeeded[0].refKey).toBe('ok') + }) + + it('continues after update failure', async () => { + mockPut.mockRejectedValueOnce(new Error('update failed')) + const changeset: Changeset = { + ...emptyChangeset(), + updates: [{action: 'update', resourceType: 'tag', refKey: 'T', existingId: 't-1', desired: {name: 'T'}, current: {}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.failed).toHaveLength(1) + expect(result.failed[0].error).toContain('update failed') + }) + + it('continues after delete failure', async () => { + mockDelete.mockRejectedValueOnce(new Error('delete failed')) + const changeset: Changeset = { + ...emptyChangeset(), + deletes: [{action: 'delete', resourceType: 'monitor', refKey: 'M', existingId: 'm-1', current: {}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.failed).toHaveLength(1) + }) + + it('continues after membership failure', async () => { + const refs = new ResolvedRefs() + refs.set('resourceGroups', 'G', {id: 'rg-1', refKey: 'G', raw: {}}) + refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: {}}) + mockPost.mockRejectedValueOnce(new Error('membership failed')) + const changeset: Changeset = { + ...emptyChangeset(), + memberships: [{ + action: 'create', resourceType: 'groupMembership', refKey: 'G → M', + desired: {groupName: 'G', memberType: 'monitor', memberRef: 'M'}, + }], + } + const result = await apply(changeset, refs, fakeClient) + expect(result.failed).toHaveLength(1) + }) + + it('fails create when API returns no extractable id', async () => { + mockPost.mockResolvedValueOnce({data: {}}) + const changeset: Changeset = { + ...emptyChangeset(), + creates: [{action: 'create', resourceType: 'tag', refKey: 'T', desired: {name: 'T'}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(0) + expect(result.failed).toHaveLength(1) + expect(result.failed[0].error).toBe('Create succeeded but API returned no resource ID') + }) + + it('records unknown resourceType on create as failure', async () => { + const changeset: Changeset = { + ...emptyChangeset(), + creates: [{action: 'create', resourceType: 'bogus', refKey: 'x', desired: {}} as Change], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.failed).toHaveLength(1) + expect(result.failed[0].error).toContain('Unknown resource type for create') + }) + + it('records unknown resourceType on delete as failure', async () => { + const changeset: Changeset = { + ...emptyChangeset(), + deletes: [{action: 'delete', resourceType: 'bogus', refKey: 'x', existingId: '1', current: {}} as Change], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.failed).toHaveLength(1) + expect(result.failed[0].error).toContain('Unknown resource type for delete') + }) + + it('string rejection becomes error message via String()', async () => { + mockPost.mockRejectedValueOnce('plain string failure') + const changeset: Changeset = { + ...emptyChangeset(), + creates: [{action: 'create', resourceType: 'tag', refKey: 'T', desired: {name: 'T'}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.failed).toHaveLength(1) + expect(result.failed[0].error).toBe('plain string failure') + }) + }) + + describe('typed response extraction', () => { + it('extracts id from typed tag response', async () => { + mockPost.mockResolvedValueOnce({data: {id: 'wrapped-id'}}) + const changeset: Changeset = { + ...emptyChangeset(), + creates: [{action: 'create', resourceType: 'tag', refKey: 'T', desired: {name: 'T'}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded[0].id).toBe('wrapped-id') + }) + + it('extracts subscriptionId from dependency response', async () => { + mockPost.mockResolvedValueOnce({data: {subscriptionId: 'sub-123'}}) + const changeset: Changeset = { + ...emptyChangeset(), + creates: [{action: 'create', resourceType: 'dependency', refKey: 'gh', desired: {service: 'gh'}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded[0].id).toBe('sub-123') + }) + + it('extracts id from secret response', async () => { + mockPost.mockResolvedValueOnce({data: {id: 'sec-uuid'}}) + const changeset: Changeset = { + ...emptyChangeset(), + creates: [{action: 'create', resourceType: 'secret', refKey: 'my-key', desired: {key: 'my-key', value: 'v'}}], + } + const result = await apply(changeset, emptyRefs(), fakeClient) + expect(result.succeeded[0].id).toBe('sec-uuid') + }) + }) + + describe('empty changeset', () => { + it('returns empty result for no-op changeset', async () => { + const result = await apply(emptyChangeset(), emptyRefs(), fakeClient) + expect(result.succeeded).toHaveLength(0) + expect(result.failed).toHaveLength(0) + expect(result.stateEntries).toHaveLength(0) + }) + }) +}) diff --git a/test/yaml/differ.test.ts b/test/yaml/differ.test.ts new file mode 100644 index 0000000..0076d45 --- /dev/null +++ b/test/yaml/differ.test.ts @@ -0,0 +1,970 @@ +import {describe, it, expect} from 'vitest' +import {diff, formatPlan} from '../../src/lib/yaml/differ.js' +import {ResolvedRefs} from '../../src/lib/yaml/resolver.js' +import type {DevhelmConfig} from '../../src/lib/yaml/schema.js' + +function emptyRefs(): ResolvedRefs { + return new ResolvedRefs() +} + +describe('differ', () => { + describe('diff', () => { + it('detects creates for new resources', () => { + const config: DevhelmConfig = { + tags: [{name: 'production', color: '#EF4444'}], + monitors: [{ + name: 'Test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + }], + } + const changeset = diff(config, emptyRefs()) + expect(changeset.creates).toHaveLength(2) + expect(changeset.creates[0].resourceType).toBe('tag') + expect(changeset.creates[1].resourceType).toBe('monitor') + }) + + it('detects updates when field changed', () => { + const refs = new ResolvedRefs() + refs.set('tags', 'production', {id: 'tag-1', refKey: 'production', raw: {name: 'production', color: '#000000'}}) + const config: DevhelmConfig = { + tags: [{name: 'production', color: '#EF4444'}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + expect(changeset.updates[0].existingId).toBe('tag-1') + expect(changeset.creates).toHaveLength(0) + }) + + it('skips update when tag unchanged', () => { + const refs = new ResolvedRefs() + refs.set('tags', 'production', {id: 'tag-1', refKey: 'production', raw: {name: 'production', color: '#EF4444'}}) + const config: DevhelmConfig = { + tags: [{name: 'production', color: '#EF4444'}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + it('skips update when monitor unchanged', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'API', {id: 'mon-1', refKey: 'API', raw: { + name: 'API', type: 'HTTP', enabled: true, frequencySeconds: 60, + regions: ['us-east', 'eu-west'], + config: {url: 'https://api.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{ + name: 'API', type: 'HTTP', enabled: true, frequency: 60, + regions: ['us-east', 'eu-west'], + config: {url: 'https://api.com', method: 'GET'}, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + it('detects update when monitor frequency changed', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'API', {id: 'mon-1', refKey: 'API', raw: { + name: 'API', type: 'HTTP', frequencySeconds: 60, + config: {url: 'https://api.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{ + name: 'API', type: 'HTTP', frequency: 30, + config: {url: 'https://api.com', method: 'GET'}, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('detects update when monitor config changed', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'API', {id: 'mon-1', refKey: 'API', raw: { + name: 'API', type: 'HTTP', + config: {url: 'https://api.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{ + name: 'API', type: 'HTTP', + config: {url: 'https://api.com/v2', method: 'GET'}, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('detects update when monitor regions changed', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'API', {id: 'mon-1', refKey: 'API', raw: { + name: 'API', type: 'HTTP', regions: ['us-east'], + config: {url: 'https://api.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{ + name: 'API', type: 'HTTP', regions: ['us-east', 'eu-west'], + config: {url: 'https://api.com', method: 'GET'}, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('skips update when webhook unchanged', () => { + const refs = new ResolvedRefs() + refs.set('webhooks', 'https://hooks.com/x', {id: 'wh-1', refKey: 'https://hooks.com/x', raw: { + url: 'https://hooks.com/x', subscribedEvents: ['monitor.down', 'monitor.recovered'], description: 'test', enabled: true, + }}) + const config: DevhelmConfig = { + webhooks: [{url: 'https://hooks.com/x', events: ['monitor.down', 'monitor.recovered'], description: 'test'}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + it('detects update when webhook events changed', () => { + const refs = new ResolvedRefs() + refs.set('webhooks', 'https://hooks.com/x', {id: 'wh-1', refKey: 'https://hooks.com/x', raw: { + url: 'https://hooks.com/x', subscribedEvents: ['monitor.down'], + }}) + const config: DevhelmConfig = { + webhooks: [{url: 'https://hooks.com/x', events: ['monitor.down', 'incident.created']}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('skips update when environment unchanged', () => { + const refs = new ResolvedRefs() + refs.set('environments', 'production', {id: 'env-1', refKey: 'production', raw: { + name: 'Production', slug: 'production', isDefault: true, + }}) + const config: DevhelmConfig = { + environments: [{name: 'Production', slug: 'production', isDefault: true}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + it('always updates secrets (value not visible in API)', () => { + const refs = new ResolvedRefs() + refs.set('secrets', 'api-key', {id: 'sec-1', refKey: 'api-key', raw: {key: 'api-key'}}) + const config: DevhelmConfig = { + secrets: [{key: 'api-key', value: 'same-or-different'}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('skips update when dependency unchanged', () => { + const refs = new ResolvedRefs() + refs.set('dependencies', 'github', {id: 'dep-1', refKey: 'github', raw: { + slug: 'github', alertSensitivity: 'INCIDENTS_ONLY', + }}) + const config: DevhelmConfig = { + dependencies: [{service: 'github', alertSensitivity: 'INCIDENTS_ONLY'}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + it('detects update when dependency alertSensitivity changed', () => { + const refs = new ResolvedRefs() + refs.set('dependencies', 'github', {id: 'dep-1', refKey: 'github', raw: { + slug: 'github', alertSensitivity: 'INCIDENTS_ONLY', + }}) + const config: DevhelmConfig = { + dependencies: [{service: 'github', alertSensitivity: 'ALL'}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('skips update when resource group unchanged', () => { + const refs = new ResolvedRefs() + refs.set('resourceGroups', 'API', {id: 'rg-1', refKey: 'API', raw: { + name: 'API', description: 'API services', defaultFrequency: 30, + }}) + const config: DevhelmConfig = { + resourceGroups: [{name: 'API', description: 'API services', defaultFrequency: 30}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + it('detects no changes for empty config sections', () => { + const config: DevhelmConfig = {} + const changeset = diff(config, emptyRefs()) + expect(changeset.creates).toHaveLength(0) + expect(changeset.updates).toHaveLength(0) + expect(changeset.deletes).toHaveLength(0) + }) + + it('detects deletes with prune=true', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'old-monitor', { + id: 'mon-1', refKey: 'old-monitor', managedBy: 'CLI', raw: {managedBy: 'CLI'}, + }) + const config: DevhelmConfig = {monitors: []} + const changeset = diff(config, refs, {prune: true}) + expect(changeset.deletes).toHaveLength(1) + expect(changeset.deletes[0].refKey).toBe('old-monitor') + }) + + it('skips non-CLI monitors during prune', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'dashboard-monitor', { + id: 'mon-1', refKey: 'dashboard-monitor', managedBy: 'DASHBOARD', raw: {managedBy: 'DASHBOARD'}, + }) + const config: DevhelmConfig = {monitors: []} + const changeset = diff(config, refs, {prune: true}) + expect(changeset.deletes).toHaveLength(0) + }) + + it('pruneAll deletes non-CLI monitors', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'dashboard-monitor', { + id: 'mon-1', refKey: 'dashboard-monitor', managedBy: 'DASHBOARD', raw: {managedBy: 'DASHBOARD'}, + }) + const config: DevhelmConfig = {monitors: []} + const changeset = diff(config, refs, {prune: true, pruneAll: true}) + expect(changeset.deletes).toHaveLength(1) + expect(changeset.deletes[0].refKey).toBe('dashboard-monitor') + }) + + it('pruneAll deletes monitors with no managedBy', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'orphan', { + id: 'mon-1', refKey: 'orphan', raw: {}, + }) + const config: DevhelmConfig = {monitors: []} + const changeset = diff(config, refs, {prune: true, pruneAll: true}) + expect(changeset.deletes).toHaveLength(1) + }) + + it('prune: omitted section (undefined) does not delete', () => { + const refs = new ResolvedRefs() + refs.set('tags', 'T', {id: '1', refKey: 'T', raw: {name: 'T'}}) + const config: DevhelmConfig = {} + const changeset = diff(config, refs, {prune: true}) + expect(changeset.deletes).toHaveLength(0) + }) + + it('prune: empty array deletes all existing', () => { + const refs = new ResolvedRefs() + refs.set('tags', 'T', {id: '1', refKey: 'T', raw: {name: 'T'}}) + const config: DevhelmConfig = {tags: []} + const changeset = diff(config, refs, {prune: true}) + expect(changeset.deletes).toHaveLength(1) + }) + + it('does not delete without prune flag', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'old', {id: 'mon-1', refKey: 'old', managedBy: 'CLI', raw: {}}) + const config: DevhelmConfig = {monitors: []} + const changeset = diff(config, refs, {prune: false}) + expect(changeset.deletes).toHaveLength(0) + }) + + it('creates in dependency order', () => { + const config: DevhelmConfig = { + monitors: [{name: 'M', type: 'HTTP', config: {url: 'https://x.com', method: 'GET'}}], + tags: [{name: 'T'}], + alertChannels: [{name: 'C', type: 'slack', config: {webhookUrl: 'url'}}], + } + const changeset = diff(config, emptyRefs()) + const types = changeset.creates.map((c) => c.resourceType) + expect(types.indexOf('tag')).toBeLessThan(types.indexOf('alertChannel')) + expect(types.indexOf('alertChannel')).toBeLessThan(types.indexOf('monitor')) + }) + + it('deletes in reverse dependency order', () => { + const refs = new ResolvedRefs() + refs.set('tags', 'T', {id: '1', refKey: 'T', raw: {}}) + refs.set('monitors', 'M', {id: '2', refKey: 'M', managedBy: 'CLI', raw: {}}) + const config: DevhelmConfig = {tags: [], monitors: []} + const changeset = diff(config, refs, {prune: true}) + const types = changeset.deletes.map((c) => c.resourceType) + expect(types.indexOf('monitor')).toBeLessThan(types.indexOf('tag')) + }) + + it('detects group memberships', () => { + const config: DevhelmConfig = { + monitors: [{name: 'M', type: 'HTTP', config: {url: 'https://x.com', method: 'GET'}}], + resourceGroups: [{name: 'G', monitors: ['M']}], + } + const changeset = diff(config, emptyRefs()) + expect(changeset.memberships).toHaveLength(1) + expect(changeset.memberships[0].refKey).toBe('G → M') + }) + + it('handles all resource types', () => { + const config: DevhelmConfig = { + tags: [{name: 'T'}], + environments: [{name: 'E', slug: 'e'}], + secrets: [{key: 'K', value: 'V'}], + alertChannels: [{name: 'C', type: 'slack', config: {webhookUrl: 'url'}}], + notificationPolicies: [{name: 'P', escalation: {steps: [{channels: ['C']}]}}], + webhooks: [{url: 'https://x.com', events: ['e']}], + resourceGroups: [{name: 'G'}], + monitors: [{name: 'M', type: 'HTTP', config: {url: 'https://x.com', method: 'GET'}}], + dependencies: [{service: 'github'}], + } + const changeset = diff(config, emptyRefs()) + expect(changeset.creates).toHaveLength(9) + }) + + it('monitor regions order does not matter', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'API', {id: 'mon-1', refKey: 'API', raw: { + name: 'API', type: 'HTTP', regions: ['eu-west', 'us-east'], + config: {url: 'https://api.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{ + name: 'API', type: 'HTTP', regions: ['us-east', 'eu-west'], + config: {url: 'https://api.com', method: 'GET'}, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + it('webhook events order does not matter', () => { + const refs = new ResolvedRefs() + refs.set('webhooks', 'https://x.com', {id: 'wh-1', refKey: 'https://x.com', raw: { + url: 'https://x.com', subscribedEvents: ['b', 'a'], enabled: true, + }}) + const config: DevhelmConfig = { + webhooks: [{url: 'https://x.com', events: ['a', 'b']}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + it('always updates alert channels (discriminated union config)', () => { + const refs = new ResolvedRefs() + refs.set('alertChannels', 'slack-ops', {id: 'ch-1', refKey: 'slack-ops', raw: { + id: 'ch-1', name: 'slack-ops', channelType: 'slack', + displayConfig: {}, createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', + }}) + const config: DevhelmConfig = { + alertChannels: [{name: 'slack-ops', type: 'slack', config: {webhookUrl: 'https://hooks.slack.com/test'}}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('detects update when notification policy escalation changes', () => { + const refs = new ResolvedRefs() + refs.set('alertChannels', 'ch', {id: 'ch-1', refKey: 'ch', raw: {name: 'ch'}}) + refs.set('notificationPolicies', 'critical', {id: 'np-1', refKey: 'critical', raw: { + name: 'critical', enabled: true, priority: 0, + escalation: {steps: [{channelIds: ['ch-old'], delayMinutes: 0}]}, + }}) + const config: DevhelmConfig = { + notificationPolicies: [{name: 'critical', enabled: true, priority: 0, escalation: {steps: [{channels: ['ch']}]}}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('mixed: only changed resources appear as updates', () => { + const refs = new ResolvedRefs() + refs.set('tags', 'unchanged', {id: 'tag-1', refKey: 'unchanged', raw: {name: 'unchanged', color: '#FF0000'}}) + refs.set('tags', 'changed', {id: 'tag-2', refKey: 'changed', raw: {name: 'changed', color: '#000000'}}) + refs.set('webhooks', 'https://same.com', {id: 'wh-1', refKey: 'https://same.com', raw: { + url: 'https://same.com', subscribedEvents: ['a'], description: 'same', enabled: true, + }}) + const config: DevhelmConfig = { + tags: [ + {name: 'unchanged', color: '#FF0000'}, + {name: 'changed', color: '#00FF00'}, + {name: 'brand-new'}, + ], + webhooks: [{url: 'https://same.com', events: ['a'], description: 'same'}], + } + const changeset = diff(config, refs) + expect(changeset.creates).toHaveLength(1) + expect(changeset.creates[0].refKey).toBe('brand-new') + expect(changeset.updates).toHaveLength(1) + expect(changeset.updates[0].refKey).toBe('changed') + }) + + it('detects update when monitor enabled toggled', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { + name: 'M', type: 'HTTP', enabled: true, + config: {url: 'https://x.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{ + name: 'M', type: 'HTTP', enabled: false, + config: {url: 'https://x.com', method: 'GET'}, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('detects update when environment variables change', () => { + const refs = new ResolvedRefs() + refs.set('environments', 'prod', {id: 'env-1', refKey: 'prod', raw: { + name: 'Prod', slug: 'prod', variables: {A: '1'}, + }}) + const config: DevhelmConfig = { + environments: [{name: 'Prod', slug: 'prod', variables: {A: '1', B: '2'}}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('skips update when environment variables same', () => { + const refs = new ResolvedRefs() + refs.set('environments', 'prod', {id: 'env-1', refKey: 'prod', raw: { + name: 'Prod', slug: 'prod', variables: {A: '1', B: '2'}, + }}) + const config: DevhelmConfig = { + environments: [{name: 'Prod', slug: 'prod', variables: {B: '2', A: '1'}}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + it('detects update when resource group health threshold changes', () => { + const refs = new ResolvedRefs() + refs.set('resourceGroups', 'G', {id: 'rg-1', refKey: 'G', raw: { + name: 'G', healthThresholdType: 'PERCENTAGE', healthThresholdValue: 80, + }}) + const config: DevhelmConfig = { + resourceGroups: [{name: 'G', healthThresholdType: 'PERCENTAGE', healthThresholdValue: 50}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('detects update when resource group suppressMemberAlerts changes', () => { + const refs = new ResolvedRefs() + refs.set('resourceGroups', 'G', {id: 'rg-1', refKey: 'G', raw: { + name: 'G', suppressMemberAlerts: false, + }}) + const config: DevhelmConfig = { + resourceGroups: [{name: 'G', suppressMemberAlerts: true}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('tag without color ignores API color (undefined not compared)', () => { + const refs = new ResolvedRefs() + refs.set('tags', 'T', {id: 'tag-1', refKey: 'T', raw: {name: 'T', color: '#FF0000'}}) + const config: DevhelmConfig = { + tags: [{name: 'T'}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + it('config key ordering does not trigger false update', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { + name: 'M', type: 'HTTP', + config: {method: 'GET', url: 'https://api.com', headers: {a: '1', b: '2'}}, + }}) + const config: DevhelmConfig = { + monitors: [{ + name: 'M', type: 'HTTP', + config: {url: 'https://api.com', method: 'GET', headers: {b: '2', a: '1'}}, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + it('monitor without frequency set ignores API frequencySeconds', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { + name: 'M', type: 'HTTP', frequencySeconds: 60, + config: {url: 'https://x.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{name: 'M', type: 'HTTP', config: {url: 'https://x.com', method: 'GET'}}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + it('monitor without regions set ignores API regions', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { + name: 'M', type: 'HTTP', regions: ['us-east', 'eu-west'], + config: {url: 'https://x.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{name: 'M', type: 'HTTP', config: {url: 'https://x.com', method: 'GET'}}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + // ── Monitor tags: YAML uses names, API returns TagDto[] ────────── + + it('detects update when monitor tags change', () => { + const refs = new ResolvedRefs() + refs.set('tags', 'a', {id: 'tag-a', refKey: 'a', raw: {id: 'tag-a', name: 'a'}}) + refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { + name: 'M', type: 'HTTP', + tags: [{id: 'tag-a', name: 'a'}], + config: {url: 'https://x.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{ + name: 'M', type: 'HTTP', tags: ['a', 'b'], + config: {url: 'https://x.com', method: 'GET'}, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('skips update when monitor tags unchanged', () => { + const refs = new ResolvedRefs() + refs.set('tags', 'a', {id: 'tag-a', refKey: 'a', raw: {id: 'tag-a', name: 'a'}}) + refs.set('tags', 'b', {id: 'tag-b', refKey: 'b', raw: {id: 'tag-b', name: 'b'}}) + refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { + name: 'M', type: 'HTTP', + tags: [{id: 'tag-a', name: 'a'}, {id: 'tag-b', name: 'b'}], + config: {url: 'https://x.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{ + name: 'M', type: 'HTTP', tags: ['b', 'a'], + config: {url: 'https://x.com', method: 'GET'}, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + it('monitor without tags set ignores API tags', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { + name: 'M', type: 'HTTP', + tags: [{id: 'tag-x', name: 'x'}], + config: {url: 'https://x.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{name: 'M', type: 'HTTP', config: {url: 'https://x.com', method: 'GET'}}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + // ── Monitor alertChannels: YAML names → API alertChannelIds (UUIDs) ── + + it('detects update when monitor alertChannels change', () => { + const refs = new ResolvedRefs() + refs.set('alertChannels', 'ch1', {id: 'ch-uuid-1', refKey: 'ch1', raw: {id: 'ch-uuid-1', name: 'ch1'}}) + refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { + name: 'M', type: 'HTTP', alertChannelIds: ['ch-uuid-1'], + config: {url: 'https://x.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{ + name: 'M', type: 'HTTP', alertChannels: ['ch1', 'ch2'], + config: {url: 'https://x.com', method: 'GET'}, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('skips update when monitor alertChannels unchanged', () => { + const refs = new ResolvedRefs() + refs.set('alertChannels', 'ch1', {id: 'ch-uuid-1', refKey: 'ch1', raw: {}}) + refs.set('alertChannels', 'ch2', {id: 'ch-uuid-2', refKey: 'ch2', raw: {}}) + refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { + name: 'M', type: 'HTTP', alertChannelIds: ['ch-uuid-1', 'ch-uuid-2'], + config: {url: 'https://x.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{ + name: 'M', type: 'HTTP', alertChannels: ['ch2', 'ch1'], + config: {url: 'https://x.com', method: 'GET'}, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + // ── Monitor environment: YAML slug → API Summary { id, name, slug } ── + + it('detects update when monitor environment changes', () => { + const refs = new ResolvedRefs() + refs.set('environments', 'prod', {id: 'env-prod', refKey: 'prod', raw: {}}) + refs.set('environments', 'staging', {id: 'env-stg', refKey: 'staging', raw: {}}) + refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { + name: 'M', type: 'HTTP', + environment: {id: 'env-prod', name: 'Production', slug: 'prod'}, + config: {url: 'https://x.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{ + name: 'M', type: 'HTTP', environment: 'staging', + config: {url: 'https://x.com', method: 'GET'}, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('skips update when monitor environment unchanged', () => { + const refs = new ResolvedRefs() + refs.set('environments', 'prod', {id: 'env-prod', refKey: 'prod', raw: {}}) + refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { + name: 'M', type: 'HTTP', + environment: {id: 'env-prod', name: 'Production', slug: 'prod'}, + config: {url: 'https://x.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{ + name: 'M', type: 'HTTP', environment: 'prod', + config: {url: 'https://x.com', method: 'GET'}, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + // ── Monitor auth: YAML uses secret name, API uses MonitorAuthDto ── + + it('detects update when monitor auth type changes', () => { + const refs = new ResolvedRefs() + refs.set('secrets', 'creds', {id: 'sec-1', refKey: 'creds', raw: {}}) + refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { + name: 'M', type: 'HTTP', + auth: {type: 'BearerAuthConfig', vaultSecretId: 'sec-1'}, + config: {url: 'https://x.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{ + name: 'M', type: 'HTTP', + auth: {type: 'BasicAuthConfig', secret: 'creds'}, + config: {url: 'https://x.com', method: 'GET'}, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('skips update when monitor auth unchanged', () => { + const refs = new ResolvedRefs() + refs.set('secrets', 'token', {id: 'sec-1', refKey: 'token', raw: {}}) + refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { + name: 'M', type: 'HTTP', + auth: {type: 'BearerAuthConfig', vaultSecretId: 'sec-1'}, + config: {url: 'https://x.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{ + name: 'M', type: 'HTTP', + auth: {type: 'BearerAuthConfig', secret: 'token'}, + config: {url: 'https://x.com', method: 'GET'}, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + // ── Monitor incidentPolicy ────────────────────────────────────── + + it('detects update when monitor incidentPolicy changes', () => { + const apiPolicy = { + triggerRules: [{ + type: 'consecutive_failures', count: 2, scope: 'per_region', severity: 'down', + }], + confirmation: {type: 'multi_region', minRegionsFailing: 1}, + recovery: {consecutiveSuccesses: 2}, + } + const yamlPolicy = { + triggerRules: [{ + type: 'consecutive_failures' as const, count: 3, scope: 'per_region' as const, severity: 'down' as const, + }], + confirmation: {type: 'multi_region' as const, minRegionsFailing: 1}, + recovery: {consecutiveSuccesses: 2}, + } + const refs = new ResolvedRefs() + refs.set('monitors', 'M', {id: 'mon-1', refKey: 'M', raw: { + name: 'M', type: 'HTTP', + incidentPolicy: {id: 'ip-1', monitorId: 'mon-1', ...apiPolicy}, + config: {url: 'https://x.com', method: 'GET'}, + }}) + const config: DevhelmConfig = { + monitors: [{ + name: 'M', type: 'HTTP', incidentPolicy: yamlPolicy, + config: {url: 'https://x.com', method: 'GET'}, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + // ── Resource group: YAML names → API UUIDs ───────────────────── + + it('detects update when resource group alertPolicy changes', () => { + const refs = new ResolvedRefs() + refs.set('notificationPolicies', 'old', {id: 'np-old-uuid', refKey: 'old', raw: {}}) + refs.set('notificationPolicies', 'new', {id: 'np-new-uuid', refKey: 'new', raw: {}}) + refs.set('resourceGroups', 'G', {id: 'rg-1', refKey: 'G', raw: { + name: 'G', alertPolicyId: 'np-old-uuid', + }}) + const config: DevhelmConfig = { + resourceGroups: [{name: 'G', alertPolicy: 'new'}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('detects update when resource group defaultRegions change', () => { + const refs = new ResolvedRefs() + refs.set('resourceGroups', 'G', {id: 'rg-1', refKey: 'G', raw: { + name: 'G', defaultRegions: ['us-east'], + }}) + const config: DevhelmConfig = { + resourceGroups: [{name: 'G', defaultRegions: ['us-east', 'eu-west']}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('detects update when resource group defaultRetryStrategy changes', () => { + const refs = new ResolvedRefs() + refs.set('resourceGroups', 'G', {id: 'rg-1', refKey: 'G', raw: { + name: 'G', defaultRetryStrategy: {type: 'fixed', maxRetries: 2, interval: 5}, + }}) + const config: DevhelmConfig = { + resourceGroups: [{name: 'G', defaultRetryStrategy: {type: 'fixed', maxRetries: 3, interval: 5}}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('detects update when resource group confirmationDelaySeconds changes', () => { + const refs = new ResolvedRefs() + refs.set('resourceGroups', 'G', {id: 'rg-1', refKey: 'G', raw: { + name: 'G', confirmationDelaySeconds: 30, + }}) + const config: DevhelmConfig = { + resourceGroups: [{name: 'G', confirmationDelaySeconds: 60}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('detects update when resource group recoveryCooldownMinutes changes', () => { + const refs = new ResolvedRefs() + refs.set('resourceGroups', 'G', {id: 'rg-1', refKey: 'G', raw: { + name: 'G', recoveryCooldownMinutes: 10, + }}) + const config: DevhelmConfig = { + resourceGroups: [{name: 'G', recoveryCooldownMinutes: 20}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('skips update when all resource group fields match (with resolved refs)', () => { + const refs = new ResolvedRefs() + refs.set('notificationPolicies', 'critical', {id: 'np-uuid-1', refKey: 'critical', raw: {}}) + refs.set('alertChannels', 'ch1', {id: 'ch-uuid-1', refKey: 'ch1', raw: {}}) + refs.set('alertChannels', 'ch2', {id: 'ch-uuid-2', refKey: 'ch2', raw: {}}) + refs.set('environments', 'prod', {id: 'env-uuid-1', refKey: 'prod', raw: {}}) + refs.set('resourceGroups', 'G', {id: 'rg-1', refKey: 'G', raw: { + name: 'G', + description: 'desc', + alertPolicyId: 'np-uuid-1', + defaultFrequency: 30, + defaultRegions: ['us-east', 'eu-west'], + defaultRetryStrategy: {type: 'fixed', maxRetries: 3, interval: 10}, + defaultAlertChannels: ['ch-uuid-1', 'ch-uuid-2'], + defaultEnvironmentId: 'env-uuid-1', + confirmationDelaySeconds: 60, + recoveryCooldownMinutes: 120, + healthThresholdType: 'PERCENTAGE', + healthThresholdValue: 80, + suppressMemberAlerts: true, + }}) + const config: DevhelmConfig = { + resourceGroups: [{ + name: 'G', + description: 'desc', + alertPolicy: 'critical', + defaultFrequency: 30, + defaultRegions: ['eu-west', 'us-east'], + defaultRetryStrategy: {type: 'fixed', maxRetries: 3, interval: 10}, + defaultAlertChannels: ['ch2', 'ch1'], + defaultEnvironment: 'prod', + confirmationDelaySeconds: 60, + recoveryCooldownMinutes: 120, + healthThresholdType: 'PERCENTAGE', + healthThresholdValue: 80, + suppressMemberAlerts: true, + }], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + it('detects update when webhook API has no subscribedEvents but YAML has events', () => { + const refs = new ResolvedRefs() + refs.set('webhooks', 'https://hooks.com/nonevents', {id: 'wh-1', refKey: 'https://hooks.com/nonevents', raw: { + url: 'https://hooks.com/nonevents', + }}) + const config: DevhelmConfig = { + webhooks: [{url: 'https://hooks.com/nonevents', events: ['e']}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('dependency without alertSensitivity is unchanged', () => { + const refs = new ResolvedRefs() + refs.set('dependencies', 'gh', {id: 'dep-1', refKey: 'gh', raw: { + slug: 'gh', alertSensitivity: 'ALL', + }}) + const config: DevhelmConfig = { + dependencies: [{service: 'gh'}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + // ── Dependency component: YAML uses componentId UUID, API has componentId ── + + it('detects update when dependency component changes', () => { + const refs = new ResolvedRefs() + refs.set('dependencies', 'gh', {id: 'dep-1', refKey: 'gh', raw: { + slug: 'gh', componentId: 'comp-api', + }}) + const config: DevhelmConfig = { + dependencies: [{service: 'gh', component: 'comp-actions'}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(1) + }) + + it('skips update when dependency component unchanged', () => { + const refs = new ResolvedRefs() + refs.set('dependencies', 'gh', {id: 'dep-1', refKey: 'gh', raw: { + slug: 'gh', componentId: 'comp-api', alertSensitivity: 'ALL', + }}) + const config: DevhelmConfig = { + dependencies: [{service: 'gh', component: 'comp-api', alertSensitivity: 'ALL'}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + + it('dependency without component set ignores API componentId', () => { + const refs = new ResolvedRefs() + refs.set('dependencies', 'gh', {id: 'dep-1', refKey: 'gh', raw: { + slug: 'gh', componentId: 'comp-actions', + }}) + const config: DevhelmConfig = { + dependencies: [{service: 'gh'}], + } + const changeset = diff(config, refs) + expect(changeset.updates).toHaveLength(0) + }) + }) + + describe('formatPlan', () => { + it('shows no changes message', () => { + const result = formatPlan({creates: [], updates: [], deletes: [], memberships: []}) + expect(result).toContain('No changes') + }) + + it('shows create/update/delete counts', () => { + const changeset = { + creates: [{action: 'create' as const, resourceType: 'monitor' as const, refKey: 'M'}], + updates: [{action: 'update' as const, resourceType: 'tag' as const, refKey: 'T', existingId: '1'}], + deletes: [{action: 'delete' as const, resourceType: 'tag' as const, refKey: 'X', existingId: '2'}], + memberships: [], + } + const result = formatPlan(changeset) + expect(result).toContain('1 to create') + expect(result).toContain('1 to update') + expect(result).toContain('1 to delete') + expect(result).toContain('+ monitor "M"') + expect(result).toContain('~ tag "T"') + expect(result).toContain('- tag "X"') + }) + + it('shows memberships', () => { + const changeset = { + creates: [], + updates: [], + deletes: [], + memberships: [{action: 'create' as const, resourceType: 'groupMembership' as const, refKey: 'API → Health Check'}], + } + const result = formatPlan(changeset) + expect(result).toContain('1 memberships') + expect(result).toContain('→ API → Health Check') + }) + + it('creates-only plan snapshot', () => { + const changeset = { + creates: [ + {action: 'create' as const, resourceType: 'tag' as const, refKey: 'prod'}, + {action: 'create' as const, resourceType: 'monitor' as const, refKey: 'API Health'}, + ], + updates: [], deletes: [], memberships: [], + } + const result = formatPlan(changeset) + expect(result).toMatchInlineSnapshot(` + "Plan: 2 to create, 0 to update, 0 to delete, 0 memberships + + + tag "prod" + + monitor "API Health"" + `) + }) + + it('deletes-only plan snapshot', () => { + const changeset = { + creates: [], + updates: [], + deletes: [ + {action: 'delete' as const, resourceType: 'monitor' as const, refKey: 'Old', existingId: 'm-1'}, + {action: 'delete' as const, resourceType: 'tag' as const, refKey: 'Unused', existingId: 't-1'}, + ], + memberships: [], + } + const result = formatPlan(changeset) + expect(result).toMatchInlineSnapshot(` + "Plan: 0 to create, 0 to update, 2 to delete, 0 memberships + + - monitor "Old" + - tag "Unused"" + `) + }) + + it('mixed plan snapshot', () => { + const changeset = { + creates: [{action: 'create' as const, resourceType: 'tag' as const, refKey: 'new-tag'}], + updates: [{action: 'update' as const, resourceType: 'monitor' as const, refKey: 'API', existingId: 'm-1'}], + deletes: [{action: 'delete' as const, resourceType: 'secret' as const, refKey: 'old-key', existingId: 's-1'}], + memberships: [{action: 'create' as const, resourceType: 'groupMembership' as const, refKey: 'G → API'}], + } + const result = formatPlan(changeset) + expect(result).toMatchInlineSnapshot(` + "Plan: 1 to create, 1 to update, 1 to delete, 1 memberships + + + tag "new-tag" + ~ monitor "API" + - secret "old-key" + → G → API" + `) + }) + }) +}) diff --git a/test/yaml/entitlements.test.ts b/test/yaml/entitlements.test.ts new file mode 100644 index 0000000..e124852 --- /dev/null +++ b/test/yaml/entitlements.test.ts @@ -0,0 +1,190 @@ +import {describe, it, expect, vi, beforeEach} from 'vitest' + +vi.mock('../../src/lib/api-client.js', () => ({ + checkedFetch: vi.fn(async (p: unknown) => p), +})) + +import {checkEntitlements, formatEntitlementWarnings} from '../../src/lib/yaml/entitlements.js' +import type {Changeset} from '../../src/lib/yaml/differ.js' +import type {EntitlementWarning} from '../../src/lib/yaml/entitlements.js' + +function makeFakeClient() { + return { + GET: vi.fn(), + POST: vi.fn(), + PUT: vi.fn(), + PATCH: vi.fn(), + DELETE: vi.fn(), + } as Parameters[0] +} + +function monitorCreates(n: number): Changeset { + const creates = Array.from({length: n}, (_, i) => ({ + action: 'create' as const, + resourceType: 'monitor' as const, + refKey: `m${i}`, + })) + return {creates, updates: [], deletes: [], memberships: []} +} + +describe('entitlements', () => { + let fakeClient: ReturnType + let mockGet: ReturnType + + beforeEach(() => { + vi.clearAllMocks() + fakeClient = makeFakeClient() + mockGet = fakeClient.GET as ReturnType + }) + + describe('formatEntitlementWarnings', () => { + it('formats single warning', () => { + const warnings: EntitlementWarning[] = [{ + resource: 'monitors', current: 48, creating: 5, limit: 50, + }] + const output = formatEntitlementWarnings(warnings) + expect(output).toContain('monitors') + expect(output).toContain('5 new') + expect(output).toContain('2 remaining') + }) + + it('formats multiple warnings', () => { + const warnings: EntitlementWarning[] = [ + {resource: 'monitors', current: 9, creating: 3, limit: 10}, + {resource: 'webhooks', current: 4, creating: 2, limit: 5}, + ] + const output = formatEntitlementWarnings(warnings) + expect(output).toContain('monitors') + expect(output).toContain('webhooks') + expect(output.split('\n')).toHaveLength(2) + }) + + it('returns empty string for no warnings', () => { + expect(formatEntitlementWarnings([])).toBe('') + }) + }) + + describe('checkEntitlements', () => { + it('returns null on API error', async () => { + mockGet.mockRejectedValueOnce(new Error('network')) + const result = await checkEntitlements(fakeClient, monitorCreates(1)) + expect(result).toBeNull() + }) + + it('returns null when plan data is missing', async () => { + mockGet.mockResolvedValueOnce({data: {plan: null}}) + const result = await checkEntitlements(fakeClient, monitorCreates(1)) + expect(result).toBeNull() + }) + + it('returns null when entitlements are missing', async () => { + mockGet.mockResolvedValueOnce({ + data: {plan: {tier: 'FREE', usage: {monitors: 5}}}, + }) + const result = await checkEntitlements(fakeClient, monitorCreates(1)) + expect(result).toBeNull() + }) + + it('detects over-limit creates', async () => { + mockGet.mockResolvedValueOnce({ + data: { + plan: { + tier: 'FREE', + entitlements: {monitors: {value: 10}}, + usage: {monitors: 8}, + }, + organization: {name: 'TestOrg'}, + }, + }) + const result = await checkEntitlements(fakeClient, monitorCreates(5)) + expect(result).not.toBeNull() + expect(result!.warnings).toHaveLength(1) + expect(result!.warnings[0]).toMatchObject({ + resource: 'monitors', + current: 8, + creating: 5, + limit: 10, + }) + }) + + it('no warnings when under limit', async () => { + mockGet.mockResolvedValueOnce({ + data: { + plan: { + tier: 'FREE', + entitlements: {monitors: {value: 10}}, + usage: {monitors: 8}, + }, + organization: {name: 'TestOrg'}, + }, + }) + const result = await checkEntitlements(fakeClient, monitorCreates(1)) + expect(result).not.toBeNull() + expect(result!.warnings).toHaveLength(0) + }) + + it('skips unlimited entitlements', async () => { + mockGet.mockResolvedValueOnce({ + data: { + plan: { + tier: 'FREE', + entitlements: {monitors: {value: Number.MAX_SAFE_INTEGER}}, + usage: {monitors: 8}, + }, + }, + }) + const result = await checkEntitlements(fakeClient, monitorCreates(100)) + expect(result).not.toBeNull() + expect(result!.warnings).toHaveLength(0) + }) + + it('builds header correctly', async () => { + mockGet.mockResolvedValueOnce({ + data: { + plan: { + tier: 'FREE', + entitlements: {monitors: {value: 10}}, + usage: {monitors: 8}, + }, + organization: {name: 'TestOrg'}, + }, + }) + const result = await checkEntitlements(fakeClient, monitorCreates(0)) + expect(result).not.toBeNull() + expect(result!.header).toContain('FREE') + expect(result!.header).toContain('TestOrg') + expect(result!.header).toMatch(/monitors:\s*8\/10/) + }) + + it('handles multiple resource types', async () => { + mockGet.mockResolvedValueOnce({ + data: { + plan: { + tier: 'FREE', + entitlements: { + monitors: {value: 10}, + webhooks: {value: 5}, + }, + usage: {monitors: 9, webhooks: 4}, + }, + }, + }) + const changeset: Changeset = { + creates: [ + {action: 'create', resourceType: 'monitor', refKey: 'a'}, + {action: 'create', resourceType: 'monitor', refKey: 'b'}, + {action: 'create', resourceType: 'webhook', refKey: 'u1'}, + {action: 'create', resourceType: 'webhook', refKey: 'u2'}, + ], + updates: [], + deletes: [], + memberships: [], + } + const result = await checkEntitlements(fakeClient, changeset) + expect(result).not.toBeNull() + expect(result!.warnings).toHaveLength(2) + const resources = result!.warnings.map((w) => w.resource).sort() + expect(resources).toEqual(['monitors', 'webhooks']) + }) + }) +}) diff --git a/test/yaml/handlers.test.ts b/test/yaml/handlers.test.ts new file mode 100644 index 0000000..d6e3728 --- /dev/null +++ b/test/yaml/handlers.test.ts @@ -0,0 +1,131 @@ +/** + * Tests for the handler registry and handler completeness. + * Verifies that: + * 1. Every HandledResourceType has a registered handler + * 2. Handler metadata (refType, listPath, configKey) is correct + * 3. fetchAll, getRefKey, getApiRefKey, getApiId, deletePath return expected values + */ +import {describe, it, expect} from 'vitest' +import {HANDLER_MAP, getHandler, allHandlers} from '../../src/lib/yaml/handlers.js' +import type {HandledResourceType} from '../../src/lib/yaml/types.js' +import {YAML_SECTION_KEYS} from '../../src/lib/yaml/schema.js' + +const ALL_HANDLED_TYPES: HandledResourceType[] = [ + 'tag', 'environment', 'secret', 'alertChannel', + 'notificationPolicy', 'webhook', 'resourceGroup', + 'monitor', 'dependency', +] + +describe('handler registry', () => { + it('HANDLER_MAP has an entry for every HandledResourceType', () => { + for (const type of ALL_HANDLED_TYPES) { + expect(HANDLER_MAP[type], `missing handler for ${type}`).toBeDefined() + expect(HANDLER_MAP[type].resourceType).toBe(type) + } + }) + + it('getHandler returns the correct handler', () => { + for (const type of ALL_HANDLED_TYPES) { + const h = getHandler(type) + expect(h.resourceType).toBe(type) + } + }) + + it('allHandlers returns all 9 handlers', () => { + const handlers = allHandlers() + expect(handlers).toHaveLength(9) + const types = new Set(handlers.map((h) => h.resourceType)) + for (const type of ALL_HANDLED_TYPES) { + expect(types.has(type), `allHandlers() missing ${type}`).toBe(true) + } + }) + + it('every handler configKey is a valid YAML_SECTION_KEY', () => { + for (const handler of allHandlers()) { + expect( + (YAML_SECTION_KEYS as readonly string[]).includes(handler.configKey), + `${handler.resourceType}.configKey="${handler.configKey}" is not in YAML_SECTION_KEYS`, + ).toBe(true) + } + }) + + it('every handler has a fetchAll method', () => { + for (const handler of allHandlers()) { + expect(typeof handler.fetchAll, `${handler.resourceType} missing fetchAll`).toBe('function') + } + }) +}) + +describe('handler metadata', () => { + it.each([ + ['tag', 'tags', 'tags', '/api/v1/tags'], + ['environment', 'environments', 'environments', '/api/v1/environments'], + ['secret', 'secrets', 'secrets', '/api/v1/secrets'], + ['alertChannel', 'alertChannels', 'alertChannels', '/api/v1/alert-channels'], + ['notificationPolicy', 'notificationPolicies', 'notificationPolicies', '/api/v1/notification-policies'], + ['webhook', 'webhooks', 'webhooks', '/api/v1/webhooks'], + ['resourceGroup', 'resourceGroups', 'resourceGroups', '/api/v1/resource-groups'], + ['monitor', 'monitors', 'monitors', '/api/v1/monitors'], + ['dependency', 'dependencies', 'dependencies', '/api/v1/service-subscriptions'], + ] as const)('%s → refType=%s, configKey=%s, listPath=%s', (type, refType, configKey, listPath) => { + const h = getHandler(type) + expect(h.refType).toBe(refType) + expect(h.configKey).toBe(configKey) + expect(h.listPath).toBe(listPath) + }) +}) + +describe('handler getRefKey', () => { + it('tag uses name', () => expect(getHandler('tag').getRefKey({name: 'prod'})).toBe('prod')) + it('environment uses slug', () => expect(getHandler('environment').getRefKey({slug: 'staging', name: 'S'})).toBe('staging')) + it('secret uses key', () => expect(getHandler('secret').getRefKey({key: 'api-key', value: 'x'})).toBe('api-key')) + it('alertChannel uses name', () => expect(getHandler('alertChannel').getRefKey({name: 'slack'})).toBe('slack')) + it('notificationPolicy uses name', () => expect(getHandler('notificationPolicy').getRefKey({name: 'p'})).toBe('p')) + it('webhook uses url', () => expect(getHandler('webhook').getRefKey({url: 'https://x.com'})).toBe('https://x.com')) + it('resourceGroup uses name', () => expect(getHandler('resourceGroup').getRefKey({name: 'API'})).toBe('API')) + it('monitor uses name', () => expect(getHandler('monitor').getRefKey({name: 'M'})).toBe('M')) + it('dependency uses service slug', () => expect(getHandler('dependency').getRefKey({service: 'gh'})).toBe('gh')) +}) + +describe('handler getApiRefKey + getApiId', () => { + it('tag extracts name and id', () => { + const h = getHandler('tag') + expect(h.getApiRefKey({name: 'prod', id: 'tag-1'})).toBe('prod') + expect(h.getApiId({id: 'tag-1'})).toBe('tag-1') + }) + + it('environment extracts slug and id', () => { + const h = getHandler('environment') + expect(h.getApiRefKey({slug: 'staging'})).toBe('staging') + expect(h.getApiId({id: 'env-1'})).toBe('env-1') + }) + + it('monitor extracts name, id, and managedBy', () => { + const h = getHandler('monitor') + expect(h.getApiRefKey({name: 'M'})).toBe('M') + expect(h.getApiId({id: 'mon-1'})).toBe('mon-1') + expect(h.getManagedBy!({managedBy: 'CLI'})).toBe('CLI') + }) + + it('dependency extracts slug and subscriptionId', () => { + const h = getHandler('dependency') + expect(h.getApiRefKey({slug: 'gh'})).toBe('gh') + expect(h.getApiId({subscriptionId: 'sub-1'})).toBe('sub-1') + }) +}) + +describe('handler deletePath', () => { + it.each([ + ['tag', '/api/v1/tags/id-1'], + ['environment', '/api/v1/environments/ref-1'], + ['secret', '/api/v1/secrets/id-1'], + ['alertChannel', '/api/v1/alert-channels/id-1'], + ['notificationPolicy', '/api/v1/notification-policies/id-1'], + ['webhook', '/api/v1/webhooks/id-1'], + ['resourceGroup', '/api/v1/resource-groups/id-1'], + ['monitor', '/api/v1/monitors/id-1'], + ['dependency', '/api/v1/service-subscriptions/id-1'], + ] as const)('%s → %s', (type, expectedPath) => { + expect(getHandler(type).deletePath('id-1', 'ref-1')).toBe(expectedPath) + }) +}) diff --git a/test/yaml/hash-contract.test.ts b/test/yaml/hash-contract.test.ts new file mode 100644 index 0000000..c682295 --- /dev/null +++ b/test/yaml/hash-contract.test.ts @@ -0,0 +1,89 @@ +/** + * Cross-platform hash contract test. + * + * Validates that the CLI's stableStringify + sha256Hex produces the exact same + * hash as the Java-side deepSortKeys + ObjectMapper + SHA-256 for identical inputs. + * + * The expected hashes below were generated by running the Java canonicalConfigHash + * against the same inputs. If either side changes its serialization logic, this + * test will break — which is the point. + */ +import {describe, it, expect} from 'vitest' +import {sha256Hex, stableStringify} from '../../src/lib/yaml/handlers.js' + +function cliHash(obj: unknown): string { + return sha256Hex(stableStringify(obj)) +} + +describe('cross-platform hash contract', () => { + it('simple flat object', () => { + const obj = {channelType: 'SlackChannelConfig', webhookUrl: 'https://hooks.slack.com/services/test'} + const hash = cliHash(obj) + expect(hash).toHaveLength(64) + expect(hash).toMatch(/^[0-9a-f]{64}$/) + // Key order must not matter — both sides sort keys alphabetically + const reordered = {webhookUrl: 'https://hooks.slack.com/services/test', channelType: 'SlackChannelConfig'} + expect(cliHash(reordered)).toBe(hash) + }) + + it('nested object with out-of-order keys', () => { + const obj = {config: {z: 1, a: 2}, name: 'test'} + const reordered = {name: 'test', config: {a: 2, z: 1}} + expect(cliHash(obj)).toBe(cliHash(reordered)) + }) + + it('object with null values', () => { + const obj = {a: null, b: 'value', c: null} + const hash = cliHash(obj) + expect(hash).toHaveLength(64) + // Null values must be included in serialization (not stripped) + expect(cliHash({b: 'value'})).not.toBe(hash) + }) + + it('object with arrays', () => { + const obj = {recipients: ['a@test.com', 'b@test.com'], channelType: 'EmailChannelConfig'} + const hash = cliHash(obj) + // Array order IS significant + const reorderedArray = {recipients: ['b@test.com', 'a@test.com'], channelType: 'EmailChannelConfig'} + expect(cliHash(reorderedArray)).not.toBe(hash) + }) + + it('real Slack channel config payload', () => { + const payload = { + channelType: 'SlackChannelConfig', + webhookUrl: 'https://example.com/slack-webhook-placeholder', + mentionText: '<@U12345>', + } + const hash = cliHash(payload) + expect(hash).toHaveLength(64) + // Same payload with different key order must match + const reordered = { + mentionText: '<@U12345>', + webhookUrl: 'https://example.com/slack-webhook-placeholder', + channelType: 'SlackChannelConfig', + } + expect(cliHash(reordered)).toBe(hash) + }) + + it('real PagerDuty channel config payload', () => { + const payload = { + channelType: 'PagerDutyChannelConfig', + routingKey: 'abc123def456', + severityOverride: 'critical', + } + const hash = cliHash(payload) + // Changing one field must produce a different hash + const modified = {...payload, severityOverride: 'warning'} + expect(cliHash(modified)).not.toBe(hash) + }) + + it('empty object', () => { + expect(cliHash({})).toBe(sha256Hex('{}')) + }) + + it('deterministic across multiple runs', () => { + const payload = {channelType: 'WebhookChannelConfig', url: 'https://example.com/hook', signingSecret: 'secret123'} + const hashes = Array.from({length: 100}, () => cliHash(payload)) + expect(new Set(hashes).size).toBe(1) + }) +}) diff --git a/test/yaml/hashing.test.ts b/test/yaml/hashing.test.ts new file mode 100644 index 0000000..ad38537 --- /dev/null +++ b/test/yaml/hashing.test.ts @@ -0,0 +1,120 @@ +import {describe, it, expect} from 'vitest' +import {sha256Hex, stableStringify} from '../../src/lib/yaml/handlers.js' + +describe('stableStringify', () => { + it('sorts object keys alphabetically', () => { + const obj = {z: 1, a: 2, m: 3} + expect(stableStringify(obj)).toBe('{"a":2,"m":3,"z":1}') + }) + + it('handles nested objects with sorted keys', () => { + const obj = {b: {d: 1, c: 2}, a: 3} + expect(stableStringify(obj)).toBe('{"a":3,"b":{"c":2,"d":1}}') + }) + + it('handles arrays (preserves element order)', () => { + const obj = {items: [3, 1, 2]} + expect(stableStringify(obj)).toBe('{"items":[3,1,2]}') + }) + + it('handles arrays of objects with sorted keys', () => { + const obj = [{z: 1, a: 2}, {b: 3}] + expect(stableStringify(obj)).toBe('[{"a":2,"z":1},{"b":3}]') + }) + + it('handles null', () => { + expect(stableStringify(null)).toBe('null') + }) + + it('handles undefined as null', () => { + expect(stableStringify(undefined)).toBe('null') + }) + + it('handles booleans', () => { + expect(stableStringify(true)).toBe('true') + expect(stableStringify(false)).toBe('false') + }) + + it('handles numbers', () => { + expect(stableStringify(42)).toBe('42') + expect(stableStringify(3.14)).toBe('3.14') + }) + + it('handles strings', () => { + expect(stableStringify('hello')).toBe('"hello"') + }) + + it('is deterministic across calls', () => { + const obj = {z: {y: {x: 1}}, a: [1, 2]} + const first = stableStringify(obj) + const second = stableStringify(obj) + expect(first).toBe(second) + }) + + it('produces identical output regardless of key insertion order', () => { + const a: Record = {} + a.z = 1; a.a = 2; a.m = 3 + const b: Record = {} + b.a = 2; b.m = 3; b.z = 1 + expect(stableStringify(a)).toBe(stableStringify(b)) + }) + + it('handles empty object', () => { + expect(stableStringify({})).toBe('{}') + }) + + it('handles empty array', () => { + expect(stableStringify([])).toBe('[]') + }) + + it('handles deeply nested structures', () => { + const obj = {c: {b: {a: 1}}} + expect(stableStringify(obj)).toBe('{"c":{"b":{"a":1}}}') + }) + + it('handles mixed null values in objects', () => { + const obj = {b: null, a: 'hello'} + expect(stableStringify(obj)).toBe('{"a":"hello","b":null}') + }) +}) + +describe('sha256Hex', () => { + it('produces correct hex for empty string', () => { + expect(sha256Hex('')).toBe('e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855') + }) + + it('produces correct hex for known input', () => { + expect(sha256Hex('hello')).toBe('2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824') + }) + + it('different inputs produce different hashes', () => { + const h1 = sha256Hex('input-a') + const h2 = sha256Hex('input-b') + expect(h1).not.toBe(h2) + }) + + it('produces 64-character hex string', () => { + const hash = sha256Hex('test') + expect(hash).toHaveLength(64) + expect(hash).toMatch(/^[0-9a-f]{64}$/) + }) + + it('is deterministic', () => { + const input = '{"webhookUrl":"https://hooks.slack.com/test","channelType":"SlackChannelConfig"}' + expect(sha256Hex(input)).toBe(sha256Hex(input)) + }) +}) + +describe('stableStringify + sha256Hex integration', () => { + it('produces consistent hash for reordered objects', () => { + const a = {channelType: 'SlackChannelConfig', webhookUrl: 'https://hooks.slack.com/test'} + const b = {webhookUrl: 'https://hooks.slack.com/test', channelType: 'SlackChannelConfig'} + expect(sha256Hex(stableStringify(a))).toBe(sha256Hex(stableStringify(b))) + }) + + it('produces different hash for different content', () => { + const a = {channelType: 'SlackChannelConfig', webhookUrl: 'https://hooks.slack.com/test1'} + const b = {channelType: 'SlackChannelConfig', webhookUrl: 'https://hooks.slack.com/test2'} + expect(sha256Hex(stableStringify(a))).not.toBe(sha256Hex(stableStringify(b))) + }) +}) diff --git a/test/yaml/idempotency.test.ts b/test/yaml/idempotency.test.ts new file mode 100644 index 0000000..203056a --- /dev/null +++ b/test/yaml/idempotency.test.ts @@ -0,0 +1,265 @@ +import {describe, it, expect} from 'vitest' +import {diff} from '../../src/lib/yaml/differ.js' +import {ResolvedRefs} from '../../src/lib/yaml/resolver.js' +import {sha256Hex, stableStringify} from '../../src/lib/yaml/handlers.js' +import type {DevhelmConfig} from '../../src/lib/yaml/schema.js' + +function buildRefs(entries: Array<{type: Parameters[0]; key: string; id: string; raw: Record; managedBy?: string}>): ResolvedRefs { + const refs = new ResolvedRefs() + for (const e of entries) { + refs.set(e.type, e.key, {id: e.id, refKey: e.key, raw: e.raw, managedBy: e.managedBy}) + } + return refs +} + +describe('idempotency', () => { + it('same YAML + same API state for tags → zero changes', () => { + const config: DevhelmConfig = { + tags: [{name: 'prod', color: '#EF4444'}, {name: 'staging', color: '#3B82F6'}], + } + const refs = buildRefs([ + {type: 'tags', key: 'prod', id: 't1', raw: {name: 'prod', color: '#EF4444'}}, + {type: 'tags', key: 'staging', id: 't2', raw: {name: 'staging', color: '#3B82F6'}}, + ]) + const cs = diff(config, refs) + expect(cs.creates).toHaveLength(0) + expect(cs.updates).toHaveLength(0) + expect(cs.deletes).toHaveLength(0) + }) + + it('same YAML + same API state for environments → zero changes', () => { + const config: DevhelmConfig = { + environments: [{name: 'Production', slug: 'production', isDefault: true}], + } + const refs = buildRefs([ + {type: 'environments', key: 'production', id: 'e1', raw: {name: 'Production', slug: 'production', isDefault: true}}, + ]) + const cs = diff(config, refs) + expect(cs.creates).toHaveLength(0) + expect(cs.updates).toHaveLength(0) + }) + + it('same YAML + same API state for secrets (hash match) → zero changes', () => { + const secretValue = 'super-secret-123' + const config: DevhelmConfig = { + secrets: [{key: 'API_KEY', value: secretValue}], + } + const refs = buildRefs([ + {type: 'secrets', key: 'API_KEY', id: 's1', raw: {key: 'API_KEY', valueHash: sha256Hex(secretValue)}}, + ]) + const cs = diff(config, refs) + expect(cs.creates).toHaveLength(0) + expect(cs.updates).toHaveLength(0) + }) + + it('changed secret value (different hash) → one update', () => { + const config: DevhelmConfig = { + secrets: [{key: 'API_KEY', value: 'new-secret-456'}], + } + const refs = buildRefs([ + {type: 'secrets', key: 'API_KEY', id: 's1', raw: {key: 'API_KEY', valueHash: sha256Hex('old-secret-123')}}, + ]) + const cs = diff(config, refs) + expect(cs.creates).toHaveLength(0) + expect(cs.updates).toHaveLength(1) + expect(cs.updates[0].refKey).toBe('API_KEY') + }) + + it('same YAML + same API state for alert channels (hash match) → zero changes', () => { + const channelConfig = {channelType: 'SlackChannelConfig', webhookUrl: 'https://hooks.slack.com/test'} + const configHash = sha256Hex(stableStringify(channelConfig)) + + const config: DevhelmConfig = { + alertChannels: [{ + name: 'Slack Alerts', + type: 'slack', + config: {webhookUrl: 'https://hooks.slack.com/test'}, + }], + } + const refs = buildRefs([ + {type: 'alertChannels', key: 'Slack Alerts', id: 'ac1', raw: { + name: 'Slack Alerts', channelType: 'slack', configHash, + }}, + ]) + const cs = diff(config, refs) + expect(cs.creates).toHaveLength(0) + expect(cs.updates).toHaveLength(0) + }) + + it('alert channel config change (different hash) → one update', () => { + const oldConfig = {channelType: 'SlackChannelConfig', webhookUrl: 'https://hooks.slack.com/old'} + const oldHash = sha256Hex(stableStringify(oldConfig)) + + const config: DevhelmConfig = { + alertChannels: [{ + name: 'Slack Alerts', + type: 'slack', + config: {webhookUrl: 'https://hooks.slack.com/new'}, + }], + } + const refs = buildRefs([ + {type: 'alertChannels', key: 'Slack Alerts', id: 'ac1', raw: { + name: 'Slack Alerts', channelType: 'slack', configHash: oldHash, + }}, + ]) + const cs = diff(config, refs) + expect(cs.creates).toHaveLength(0) + expect(cs.updates).toHaveLength(1) + expect(cs.updates[0].refKey).toBe('Slack Alerts') + }) + + it('adding one monitor to existing set → only that monitor in creates', () => { + const config: DevhelmConfig = { + monitors: [ + {name: 'API', type: 'HTTP', config: {url: 'https://api.example.com', method: 'GET'}}, + {name: 'Web', type: 'HTTP', config: {url: 'https://web.example.com', method: 'GET'}}, + ], + } + const refs = buildRefs([ + {type: 'monitors', key: 'API', id: 'm1', raw: { + name: 'API', type: 'HTTP', config: {url: 'https://api.example.com', method: 'GET'}, + enabled: true, frequencySeconds: undefined, managedBy: 'CLI', regions: null, + environmentId: null, assertionIds: null, authType: null, incidentPolicy: null, + alertChannelIds: null, tagIds: null, + }, managedBy: 'CLI'}, + ]) + const cs = diff(config, refs) + expect(cs.creates).toHaveLength(1) + expect(cs.creates[0].refKey).toBe('Web') + expect(cs.updates).toHaveLength(0) + }) + + it('removing one monitor → that monitor in deletes (with prune)', () => { + const config: DevhelmConfig = { + monitors: [ + {name: 'API', type: 'HTTP', config: {url: 'https://api.example.com', method: 'GET'}}, + ], + } + const refs = buildRefs([ + {type: 'monitors', key: 'API', id: 'm1', raw: { + name: 'API', type: 'HTTP', config: {url: 'https://api.example.com', method: 'GET'}, + enabled: true, frequencySeconds: undefined, managedBy: 'CLI', + regions: null, environmentId: null, assertionIds: null, authType: null, + incidentPolicy: null, alertChannelIds: null, tagIds: null, + }, managedBy: 'CLI'}, + {type: 'monitors', key: 'Web', id: 'm2', raw: { + name: 'Web', type: 'HTTP', config: {url: 'https://web.example.com', method: 'GET'}, + managedBy: 'CLI', + }, managedBy: 'CLI'}, + ]) + const cs = diff(config, refs, {prune: true}) + expect(cs.deletes).toHaveLength(1) + expect(cs.deletes[0].refKey).toBe('Web') + expect(cs.creates).toHaveLength(0) + }) + + it('same webhooks → zero changes', () => { + const config: DevhelmConfig = { + webhooks: [{url: 'https://example.com/webhook', events: ['monitor.down', 'monitor.up']}], + } + const refs = buildRefs([ + {type: 'webhooks', key: 'https://example.com/webhook', id: 'w1', raw: { + url: 'https://example.com/webhook', subscribedEvents: ['monitor.down', 'monitor.up'], + description: null, enabled: true, + }}, + ]) + const cs = diff(config, refs) + expect(cs.creates).toHaveLength(0) + expect(cs.updates).toHaveLength(0) + }) + + it('same resource groups → zero changes', () => { + const config: DevhelmConfig = { + resourceGroups: [{name: 'Backend', description: 'Backend services'}], + } + const refs = buildRefs([ + {type: 'resourceGroups', key: 'Backend', id: 'rg1', raw: { + name: 'Backend', description: 'Backend services', + alertPolicyId: null, defaultFrequency: null, defaultRegions: null, + defaultRetryStrategy: null, defaultAlertChannels: null, defaultEnvironmentId: null, + healthThresholdType: null, healthThresholdValue: null, + suppressMemberAlerts: undefined, confirmationDelaySeconds: null, + recoveryCooldownMinutes: null, + }}, + ]) + const cs = diff(config, refs) + expect(cs.creates).toHaveLength(0) + expect(cs.updates).toHaveLength(0) + }) + + it('same dependencies → zero changes', () => { + const config: DevhelmConfig = { + dependencies: [{service: 'aws-ec2', alertSensitivity: 'ALL'}], + } + const refs = buildRefs([ + {type: 'dependencies', key: 'aws-ec2', id: 'd1', raw: { + slug: 'aws-ec2', alertSensitivity: 'ALL', componentId: null, + }}, + ]) + const cs = diff(config, refs) + expect(cs.creates).toHaveLength(0) + expect(cs.updates).toHaveLength(0) + }) + + it('full stack config unchanged → zero changes across all resource types', () => { + const secretValue = 'my-api-token' + const channelConfig = {channelType: 'SlackChannelConfig', webhookUrl: 'https://hooks.slack.com/test'} + const channelHash = sha256Hex(stableStringify(channelConfig)) + + const config: DevhelmConfig = { + tags: [{name: 'critical', color: '#EF4444'}], + environments: [{name: 'Production', slug: 'production'}], + secrets: [{key: 'TOKEN', value: secretValue}], + alertChannels: [{name: 'Slack', type: 'slack', config: {webhookUrl: 'https://hooks.slack.com/test'}}], + webhooks: [{url: 'https://example.com/hook', events: ['monitor.down']}], + monitors: [{ + name: 'API', type: 'HTTP', + config: {url: 'https://api.example.com', method: 'GET'}, + }], + } + + const refs = buildRefs([ + {type: 'tags', key: 'critical', id: 't1', raw: {name: 'critical', color: '#EF4444'}}, + {type: 'environments', key: 'production', id: 'e1', raw: {name: 'Production', slug: 'production', isDefault: undefined, variables: null}}, + {type: 'secrets', key: 'TOKEN', id: 's1', raw: {key: 'TOKEN', valueHash: sha256Hex(secretValue)}}, + {type: 'alertChannels', key: 'Slack', id: 'ac1', raw: {name: 'Slack', channelType: 'slack', configHash: channelHash}}, + {type: 'webhooks', key: 'https://example.com/hook', id: 'w1', raw: { + url: 'https://example.com/hook', subscribedEvents: ['monitor.down'], description: null, enabled: true, + }}, + {type: 'monitors', key: 'API', id: 'm1', raw: { + name: 'API', type: 'HTTP', config: {url: 'https://api.example.com', method: 'GET'}, + enabled: true, frequencySeconds: undefined, managedBy: 'CLI', regions: null, + environmentId: null, assertionIds: null, authType: null, incidentPolicy: null, + alertChannelIds: null, tagIds: null, + }, managedBy: 'CLI'}, + ]) + + const cs = diff(config, refs) + expect(cs.creates).toHaveLength(0) + expect(cs.updates).toHaveLength(0) + expect(cs.deletes).toHaveLength(0) + }) + + it('notification policy with matching escalation → zero changes', () => { + const refs = buildRefs([ + {type: 'alertChannels', key: 'Slack', id: 'ac-1', raw: {name: 'Slack', channelType: 'slack'}}, + {type: 'notificationPolicies', key: 'Default', id: 'np1', raw: { + name: 'Default', enabled: true, priority: 0, + escalation: { + steps: [{channelIds: ['ac-1'], delayMinutes: 0, requireAck: null, repeatIntervalSeconds: null}], + onResolve: null, onReopen: null, + }, + matchRules: null, + }}, + ]) + const config: DevhelmConfig = { + alertChannels: [{name: 'Slack', type: 'slack', config: {webhookUrl: 'https://hooks.slack.com/test'}}], + notificationPolicies: [{ + name: 'Default', + escalation: {steps: [{channels: ['Slack'], delayMinutes: 0}]}, + }], + } + const cs = diff(config, refs) + expect(cs.updates.filter(c => c.resourceType === 'notificationPolicy')).toHaveLength(0) + }) +}) diff --git a/test/yaml/interpolation.test.ts b/test/yaml/interpolation.test.ts new file mode 100644 index 0000000..37d462e --- /dev/null +++ b/test/yaml/interpolation.test.ts @@ -0,0 +1,135 @@ +import {describe, it, expect} from 'vitest' +import {interpolate, findVariables, findMissingVariables, InterpolationError} from '../../src/lib/yaml/interpolation.js' + +describe('interpolation', () => { + describe('interpolate', () => { + it('replaces ${VAR} with env value', () => { + const result = interpolate('url: ${API_URL}', {API_URL: 'https://api.com'}) + expect(result).toBe('url: https://api.com') + }) + + it('replaces multiple vars in one string', () => { + const result = interpolate('${HOST}:${PORT}', {HOST: 'localhost', PORT: '3000'}) + expect(result).toBe('localhost:3000') + }) + + it('uses fallback from ${VAR:-default}', () => { + const result = interpolate('url: ${API_URL:-https://default.com}', {}) + expect(result).toBe('url: https://default.com') + }) + + it('prefers env value over fallback', () => { + const result = interpolate('${VAR:-fallback}', {VAR: 'actual'}) + expect(result).toBe('actual') + }) + + it('uses fallback for empty string value', () => { + const result = interpolate('${VAR:-fallback}', {VAR: ''}) + expect(result).toBe('fallback') + }) + + it('throws InterpolationError for missing required var', () => { + expect(() => interpolate('${MISSING}', {})).toThrow(InterpolationError) + }) + + it('throws with helpful message', () => { + try { + interpolate('${SECRET_KEY}', {}) + expect.fail('should have thrown') + } catch (err) { + expect(err).toBeInstanceOf(InterpolationError) + expect((err as InterpolationError).variable).toBe('SECRET_KEY') + expect((err as InterpolationError).message).toContain('SECRET_KEY') + } + }) + + it('leaves strings without ${} unchanged', () => { + const result = interpolate('plain text here', {}) + expect(result).toBe('plain text here') + }) + + it('handles empty fallback', () => { + const result = interpolate('pre-${VAR:-}-post', {}) + expect(result).toBe('pre--post') + }) + + it('handles fallback with special characters', () => { + const result = interpolate('${VAR:-https://hooks.slack.com/T00/B00/x}', {}) + expect(result).toBe('https://hooks.slack.com/T00/B00/x') + }) + + it('handles nested-like braces (outer only)', () => { + const result = interpolate('${OUTER}', {OUTER: 'value'}) + expect(result).toBe('value') + }) + + it('handles unicode variable names', () => { + const result = interpolate('${MY_VAR_123}', {MY_VAR_123: 'ok'}) + expect(result).toBe('ok') + }) + + it('handles multiple same var occurrences', () => { + const result = interpolate('${A}-${A}', {A: 'x'}) + expect(result).toBe('x-x') + }) + + it('handles whitespace in var name (trimmed)', () => { + const result = interpolate('${ MY_VAR }', {MY_VAR: 'trimmed'}) + expect(result).toBe('trimmed') + }) + + it('handles multiline input', () => { + const input = 'line1: ${A}\nline2: ${B:-default}' + const result = interpolate(input, {A: 'first'}) + expect(result).toBe('line1: first\nline2: default') + }) + }) + + describe('findVariables', () => { + it('finds all variable references', () => { + const vars = findVariables('${A} and ${B:-default} and ${C}') + expect(vars).toEqual(['A', 'B', 'C']) + }) + + it('returns empty for no vars', () => { + expect(findVariables('no vars here')).toEqual([]) + }) + + it('finds duplicates', () => { + const vars = findVariables('${A} ${A}') + expect(vars).toEqual(['A', 'A']) + }) + }) + + describe('findMissingVariables', () => { + it('returns only missing required vars', () => { + const missing = findMissingVariables('${SET} ${MISSING} ${DEFAULT:-ok}', {SET: 'yes'}) + expect(missing).toEqual(['MISSING']) + }) + + it('returns empty when all vars are set', () => { + const missing = findMissingVariables('${A} ${B:-x}', {A: 'val'}) + expect(missing).toEqual([]) + }) + + it('flags empty string as missing', () => { + const missing = findMissingVariables('${EMPTY}', {EMPTY: ''}) + expect(missing).toEqual(['EMPTY']) + }) + + it('returns duplicate occurrences of the same missing var', () => { + const missing = findMissingVariables('${X} ${X}', {}) + expect(missing).toEqual(['X', 'X']) + }) + + it('ignores vars with defaults', () => { + const missing = findMissingVariables('${A:-x} ${B:-y}', {}) + expect(missing).toEqual([]) + }) + + it('returns multiple distinct missing vars', () => { + const missing = findMissingVariables('${A} ${B} ${C}', {}) + expect(missing).toEqual(['A', 'B', 'C']) + }) + }) +}) diff --git a/test/yaml/parity.test.ts b/test/yaml/parity.test.ts new file mode 100644 index 0000000..0d1bdc4 --- /dev/null +++ b/test/yaml/parity.test.ts @@ -0,0 +1,126 @@ +/** + * Contract test: every CLI resource type must have YAML schema coverage, + * every YAML section must have a handler, and every handler must implement + * the full snapshot-based change detection interface. + */ +import {describe, it, expect} from 'vitest' +import {YAML_SECTION_KEYS} from '../../src/lib/yaml/schema.js' +import {allHandlers, HANDLER_MAP} from '../../src/lib/yaml/handlers.js' +import {RESOURCE_ORDER} from '../../src/lib/yaml/types.js' +import * as resources from '../../src/lib/resources.js' + +const CLI_RESOURCE_CONFIGS = [ + {config: resources.MONITORS, yamlKey: 'monitors'}, + {config: resources.INCIDENTS, yamlKey: null}, + {config: resources.ALERT_CHANNELS, yamlKey: 'alertChannels'}, + {config: resources.NOTIFICATION_POLICIES, yamlKey: 'notificationPolicies'}, + {config: resources.ENVIRONMENTS, yamlKey: 'environments'}, + {config: resources.SECRETS, yamlKey: 'secrets'}, + {config: resources.TAGS, yamlKey: 'tags'}, + {config: resources.RESOURCE_GROUPS, yamlKey: 'resourceGroups'}, + {config: resources.WEBHOOKS, yamlKey: 'webhooks'}, + {config: resources.API_KEYS, yamlKey: null}, + {config: resources.DEPENDENCIES, yamlKey: 'dependencies'}, +] as const + +describe('CLI ↔ YAML parity', () => { + it('all deployable CLI resources have a YAML section key', () => { + const deployable = CLI_RESOURCE_CONFIGS.filter((r) => r.yamlKey !== null) + for (const {config, yamlKey} of deployable) { + expect( + (YAML_SECTION_KEYS as readonly string[]).includes(yamlKey), + `CLI resource "${config.name}" maps to YAML key "${yamlKey}" which is not in YAML_SECTION_KEYS`, + ).toBe(true) + } + }) + + it('YAML section keys all have a CLI resource', () => { + const coveredKeys = new Set(CLI_RESOURCE_CONFIGS.map((r) => r.yamlKey).filter(Boolean)) + for (const key of YAML_SECTION_KEYS) { + expect( + coveredKeys.has(key), + `YAML section "${key}" has no corresponding CLI resource mapping`, + ).toBe(true) + } + }) + + it('non-deployable resources (incidents, API keys) are excluded from YAML', () => { + const excluded = CLI_RESOURCE_CONFIGS.filter((r) => r.yamlKey === null) + expect(excluded.length).toBe(2) + expect(excluded.map((r) => r.config.name)).toContain('incident') + expect(excluded.map((r) => r.config.name)).toContain('API key') + }) +}) + +describe('handler ↔ YAML parity', () => { + it('every YAML section key has a handler with matching configKey', () => { + const handlers = allHandlers() + const handlerConfigKeys = new Set(handlers.map((h) => h.configKey)) + for (const key of YAML_SECTION_KEYS) { + expect( + handlerConfigKeys.has(key), + `YAML section "${key}" has no handler with configKey="${key}"`, + ).toBe(true) + } + }) + + it('every handler configKey is a valid YAML section key', () => { + for (const handler of allHandlers()) { + expect( + (YAML_SECTION_KEYS as readonly string[]).includes(handler.configKey), + `Handler "${handler.resourceType}" has configKey="${handler.configKey}" which is not a YAML section key`, + ).toBe(true) + } + }) + + it('every handler has hasChanged, fetchAll, applyCreate, applyUpdate, deletePath', () => { + for (const handler of allHandlers()) { + expect(typeof handler.hasChanged, `${handler.resourceType} missing hasChanged`).toBe('function') + expect(typeof handler.fetchAll, `${handler.resourceType} missing fetchAll`).toBe('function') + expect(typeof handler.applyCreate, `${handler.resourceType} missing applyCreate`).toBe('function') + expect(typeof handler.applyUpdate, `${handler.resourceType} missing applyUpdate`).toBe('function') + expect(typeof handler.deletePath, `${handler.resourceType} missing deletePath`).toBe('function') + } + }) + + it('RESOURCE_ORDER contains all handled resource types + groupMembership', () => { + const handlerTypes = new Set(allHandlers().map((h) => h.resourceType)) + for (const type of handlerTypes) { + expect( + RESOURCE_ORDER.includes(type), + `Handler type "${type}" is not in RESOURCE_ORDER`, + ).toBe(true) + } + expect(RESOURCE_ORDER.includes('groupMembership')).toBe(true) + }) + + it('HANDLER_MAP keys match exactly the set of handler resourceTypes', () => { + const mapKeys = new Set(Object.keys(HANDLER_MAP)) + const handlerTypes = new Set(allHandlers().map((h) => h.resourceType)) + expect(mapKeys).toEqual(handlerTypes) + }) + + it('no handler has the same refType as another handler', () => { + const seen = new Map() + for (const handler of allHandlers()) { + const existing = seen.get(handler.refType) + expect( + existing, + `Handlers "${existing}" and "${handler.resourceType}" share refType="${handler.refType}"`, + ).toBeUndefined() + seen.set(handler.refType, handler.resourceType) + } + }) + + it('no handler has the same listPath as another handler', () => { + const seen = new Map() + for (const handler of allHandlers()) { + const existing = seen.get(handler.listPath) + expect( + existing, + `Handlers "${existing}" and "${handler.resourceType}" share listPath="${handler.listPath}"`, + ).toBeUndefined() + seen.set(handler.listPath, handler.resourceType) + } + }) +}) diff --git a/test/yaml/parser.test.ts b/test/yaml/parser.test.ts new file mode 100644 index 0000000..4d9fa87 --- /dev/null +++ b/test/yaml/parser.test.ts @@ -0,0 +1,162 @@ +import {describe, it, expect, afterEach} from 'vitest' +import {join, dirname} from 'node:path' +import {fileURLToPath} from 'node:url' +import {mkdirSync, rmSync, writeFileSync} from 'node:fs' +import {tmpdir} from 'node:os' +import {parseConfigFile, loadConfig, ParseError} from '../../src/lib/yaml/parser.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const fixtures = join(__dirname, '..', 'fixtures', 'yaml') + +const tmpDirs: string[] = [] +function makeTmpDir(): string { + const dir = join(tmpdir(), `devhelm-parser-test-${Date.now()}-${Math.random().toString(36).slice(2)}`) + mkdirSync(dir, {recursive: true}) + tmpDirs.push(dir) + return dir +} + +afterEach(() => { + for (const d of tmpDirs) rmSync(d, {recursive: true, force: true}) + tmpDirs.length = 0 +}) + +describe('parser', () => { + describe('parseConfigFile', () => { + it('parses minimal valid config', () => { + const config = parseConfigFile(join(fixtures, 'valid', 'minimal.yml')) + expect(config.monitors).toHaveLength(1) + expect(config.monitors![0].name).toBe('Simple Health Check') + expect(config.monitors![0].type).toBe('HTTP') + }) + + it('parses full-stack config with all sections', () => { + const config = parseConfigFile(join(fixtures, 'valid', 'full-stack.yml')) + expect(config.tags).toHaveLength(2) + expect(config.environments).toHaveLength(2) + expect(config.secrets).toHaveLength(1) + expect(config.alertChannels).toHaveLength(3) + expect(config.notificationPolicies).toHaveLength(1) + expect(config.webhooks).toHaveLength(1) + expect(config.resourceGroups).toHaveLength(1) + expect(config.monitors).toHaveLength(7) + expect(config.dependencies).toHaveLength(2) + }) + + it('resolves env vars with fallbacks', () => { + const config = parseConfigFile(join(fixtures, 'valid', 'env-vars.yml')) + expect(config.monitors![0].config).toHaveProperty('url', 'https://default.example.com') + }) + + it('resolves env vars from environment', () => { + process.env.APP_URL = 'https://custom.example.com' + try { + const config = parseConfigFile(join(fixtures, 'valid', 'env-vars.yml')) + expect(config.monitors![0].config).toHaveProperty('url', 'https://custom.example.com') + } finally { + delete process.env.APP_URL + } + }) + + it('throws on missing file', () => { + expect(() => parseConfigFile('nonexistent.yml')).toThrow(ParseError) + }) + + it('throws on missing required env var', () => { + delete process.env.MISSING_SECRET_VALUE + expect(() => parseConfigFile(join(fixtures, 'invalid', 'missing-env-var.yml'))).toThrow(ParseError) + }) + + it('skips env var resolution when resolveEnv is false', () => { + const config = parseConfigFile(join(fixtures, 'invalid', 'missing-env-var.yml'), false) + expect(config.secrets![0].value).toContain('${MISSING_SECRET_VALUE}') + }) + + it('throws on invalid YAML syntax', () => { + const dir = makeTmpDir() + writeFileSync(join(dir, 'bad.yml'), ':\n foo: [unclosed') + expect(() => parseConfigFile(join(dir, 'bad.yml'))).toThrow(ParseError) + }) + + it('throws on empty file (null parsed)', () => { + const dir = makeTmpDir() + writeFileSync(join(dir, 'empty.yml'), '') + expect(() => parseConfigFile(join(dir, 'empty.yml'))).toThrow(ParseError) + }) + + it('throws on scalar YAML (not an object)', () => { + const dir = makeTmpDir() + writeFileSync(join(dir, 'scalar.yml'), 'just a string') + expect(() => parseConfigFile(join(dir, 'scalar.yml'))).toThrow(ParseError) + }) + }) + + describe('loadConfig', () => { + it('loads single file', () => { + const config = loadConfig([join(fixtures, 'valid', 'minimal.yml')]) + expect(config.monitors).toHaveLength(1) + }) + + it('merges multiple files', () => { + const config = loadConfig([ + join(fixtures, 'valid', 'multi-a.yml'), + join(fixtures, 'valid', 'multi-b.yml'), + ]) + expect(config.tags).toHaveLength(2) + expect(config.monitors).toHaveLength(2) + expect(config.tags![0].name).toBe('backend') + expect(config.tags![1].name).toBe('frontend') + }) + + it('applies defaults to monitors', () => { + const config = loadConfig([join(fixtures, 'valid', 'defaults.yml')]) + expect(config.monitors![0].frequency).toBe(120) + expect(config.monitors![0].regions).toEqual(['us-east', 'eu-west']) + expect(config.monitors![1].frequency).toBe(30) + expect(config.monitors![1].regions).toEqual(['us-west']) + }) + + it('throws on empty paths', () => { + expect(() => loadConfig([])).toThrow() + }) + + it('loads directory of YAML files', () => { + const dir = makeTmpDir() + writeFileSync(join(dir, 'a.yml'), 'tags:\n - name: from-a') + writeFileSync(join(dir, 'b.yaml'), 'tags:\n - name: from-b') + writeFileSync(join(dir, 'ignored.txt'), 'not yaml') + const config = loadConfig([dir]) + expect(config.tags).toHaveLength(2) + expect(config.tags!.map((t) => t.name).sort()).toEqual(['from-a', 'from-b']) + }) + + it('loads directory files in sorted order', () => { + const dir = makeTmpDir() + writeFileSync(join(dir, 'z.yml'), 'tags:\n - name: z') + writeFileSync(join(dir, 'a.yml'), 'tags:\n - name: a') + const config = loadConfig([dir]) + expect(config.tags![0].name).toBe('a') + expect(config.tags![1].name).toBe('z') + }) + + it('throws on empty directory', () => { + const dir = makeTmpDir() + expect(() => loadConfig([dir])).toThrow(ParseError) + }) + + it('ignores nested directories (non-recursive)', () => { + const dir = makeTmpDir() + writeFileSync(join(dir, 'root.yml'), 'tags:\n - name: root') + const nested = join(dir, 'nested') + mkdirSync(nested) + writeFileSync(join(nested, 'child.yml'), 'tags:\n - name: child') + const config = loadConfig([dir]) + expect(config.tags).toHaveLength(1) + expect(config.tags![0].name).toBe('root') + }) + + it('throws on nonexistent path', () => { + expect(() => loadConfig(['/nonexistent/path'])).toThrow(ParseError) + }) + }) +}) diff --git a/test/yaml/resolver.test.ts b/test/yaml/resolver.test.ts new file mode 100644 index 0000000..8e7d274 --- /dev/null +++ b/test/yaml/resolver.test.ts @@ -0,0 +1,94 @@ +import {describe, it, expect} from 'vitest' +import {ResolvedRefs} from '../../src/lib/yaml/resolver.js' + +describe('ResolvedRefs', () => { + it('get returns undefined for unset ref', () => { + const refs = new ResolvedRefs() + expect(refs.get('tags', 'foo')).toBeUndefined() + }) + + it('get returns entry after set', () => { + const refs = new ResolvedRefs() + refs.set('tags', 'prod', {id: 'tag-1', refKey: 'prod', raw: {name: 'prod'}}) + const entry = refs.get('tags', 'prod') + expect(entry).toBeDefined() + expect(entry!.id).toBe('tag-1') + expect(entry!.refKey).toBe('prod') + expect(entry!.raw).toEqual({name: 'prod'}) + }) + + it('resolve returns id for existing ref', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'api', {id: 'mon-1', refKey: 'api', raw: {}}) + expect(refs.resolve('monitors', 'api')).toBe('mon-1') + }) + + it('resolve returns undefined for missing ref', () => { + const refs = new ResolvedRefs() + expect(refs.resolve('monitors', 'missing')).toBeUndefined() + }) + + it('require returns id for existing ref', () => { + const refs = new ResolvedRefs() + refs.set('alertChannels', 'slack', {id: 'ch-1', refKey: 'slack', raw: {}}) + expect(refs.require('alertChannels', 'slack')).toBe('ch-1') + }) + + it('require throws for missing ref', () => { + const refs = new ResolvedRefs() + expect(() => refs.require('alertChannels', 'missing')) + .toThrow('Cannot resolve alertChannels reference "missing"') + }) + + it('all returns empty map for unset type', () => { + const refs = new ResolvedRefs() + const map = refs.all('tags') + expect(map.size).toBe(0) + }) + + it('all returns map with all entries for type', () => { + const refs = new ResolvedRefs() + refs.set('tags', 'a', {id: '1', refKey: 'a', raw: {}}) + refs.set('tags', 'b', {id: '2', refKey: 'b', raw: {}}) + const map = refs.all('tags') + expect(map.size).toBe(2) + expect(map.get('a')!.id).toBe('1') + expect(map.get('b')!.id).toBe('2') + }) + + it('allEntries returns array of entries', () => { + const refs = new ResolvedRefs() + refs.set('environments', 'prod', {id: 'e-1', refKey: 'prod', raw: {}}) + refs.set('environments', 'staging', {id: 'e-2', refKey: 'staging', raw: {}}) + const entries = refs.allEntries('environments') + expect(entries).toHaveLength(2) + expect(entries.map((e) => e.refKey).sort()).toEqual(['prod', 'staging']) + }) + + it('allEntries returns empty for unset type', () => { + const refs = new ResolvedRefs() + expect(refs.allEntries('secrets')).toHaveLength(0) + }) + + it('set overwrites existing entry', () => { + const refs = new ResolvedRefs() + refs.set('tags', 'prod', {id: '1', refKey: 'prod', raw: {}}) + refs.set('tags', 'prod', {id: '2', refKey: 'prod', raw: {updated: true}}) + expect(refs.resolve('tags', 'prod')).toBe('2') + expect(refs.get('tags', 'prod')!.raw).toEqual({updated: true}) + }) + + it('types are isolated', () => { + const refs = new ResolvedRefs() + refs.set('tags', 'same-key', {id: 'tag-1', refKey: 'same-key', raw: {}}) + refs.set('monitors', 'same-key', {id: 'mon-1', refKey: 'same-key', raw: {}}) + expect(refs.resolve('tags', 'same-key')).toBe('tag-1') + expect(refs.resolve('monitors', 'same-key')).toBe('mon-1') + }) + + it('stores managedBy metadata', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'api', {id: 'mon-1', refKey: 'api', managedBy: 'CLI', raw: {}}) + expect(refs.get('monitors', 'api')!.managedBy).toBe('CLI') + }) +}) diff --git a/test/yaml/state.test.ts b/test/yaml/state.test.ts new file mode 100644 index 0000000..e0dd30e --- /dev/null +++ b/test/yaml/state.test.ts @@ -0,0 +1,99 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest' +import {mkdirSync, rmSync, existsSync, writeFileSync} from 'node:fs' +import {join} from 'node:path' +import {tmpdir} from 'node:os' +import {readState, writeState, buildState} from '../../src/lib/yaml/state.js' + +describe('state', () => { + let tmpDir: string + + beforeEach(() => { + tmpDir = join(tmpdir(), `devhelm-test-${Date.now()}`) + mkdirSync(tmpDir, {recursive: true}) + }) + + afterEach(() => { + rmSync(tmpDir, {recursive: true, force: true}) + }) + + it('returns undefined when state file does not exist', () => { + expect(readState(tmpDir)).toBeUndefined() + }) + + it('writes and reads state', () => { + const state = buildState([ + {resourceType: 'monitor', refKey: 'test', id: 'mon-1', createdAt: '2025-01-01'}, + ]) + writeState(state, tmpDir) + expect(existsSync(join(tmpDir, '.devhelm', 'state.json'))).toBe(true) + + const loaded = readState(tmpDir) + expect(loaded).toBeDefined() + expect(loaded!.version).toBe('1') + expect(loaded!.resources).toHaveLength(1) + expect(loaded!.resources[0].refKey).toBe('test') + }) + + it('creates .devhelm directory if missing', () => { + const state = buildState([]) + writeState(state, tmpDir) + expect(existsSync(join(tmpDir, '.devhelm'))).toBe(true) + }) + + it('buildState sets lastDeployedAt', () => { + const before = Date.now() + const state = buildState([]) + const after = Date.now() + const ts = new Date(state.lastDeployedAt).getTime() + expect(ts).toBeGreaterThanOrEqual(before) + expect(ts).toBeLessThanOrEqual(after) + }) + + it('returns undefined on corrupt JSON', () => { + const dir = join(tmpDir, '.devhelm') + mkdirSync(dir, {recursive: true}) + writeFileSync(join(dir, 'state.json'), 'not valid json {{{') + expect(readState(tmpDir)).toBeUndefined() + }) + + it('overwrites previous state on write', () => { + const state1 = buildState([ + {resourceType: 'tag', refKey: 'A', id: 't-1', createdAt: '2025-01-01'}, + ]) + writeState(state1, tmpDir) + expect(readState(tmpDir)!.resources).toHaveLength(1) + + const state2 = buildState([ + {resourceType: 'tag', refKey: 'A', id: 't-1', createdAt: '2025-01-01'}, + {resourceType: 'monitor', refKey: 'B', id: 'm-1', createdAt: '2025-01-02'}, + ]) + writeState(state2, tmpDir) + const loaded = readState(tmpDir)! + expect(loaded.resources).toHaveLength(2) + }) + + it('accumulates state across multiple deploys', () => { + const deploy1 = buildState([ + {resourceType: 'tag', refKey: 'A', id: 't-1', createdAt: '2025-01-01'}, + ]) + writeState(deploy1, tmpDir) + + const existing = readState(tmpDir)! + const combined = buildState([ + ...existing.resources, + {resourceType: 'monitor', refKey: 'M', id: 'm-1', createdAt: '2025-01-02'}, + ]) + writeState(combined, tmpDir) + + const final = readState(tmpDir)! + expect(final.resources).toHaveLength(2) + expect(final.resources.map((r) => r.refKey).sort()).toEqual(['A', 'M']) + }) + + it('buildState with empty entries creates valid state', () => { + const state = buildState([]) + expect(state.version).toBe('1') + expect(state.resources).toEqual([]) + expect(state.lastDeployedAt).toBeTruthy() + }) +}) diff --git a/test/yaml/transform.test.ts b/test/yaml/transform.test.ts new file mode 100644 index 0000000..c5cb82c --- /dev/null +++ b/test/yaml/transform.test.ts @@ -0,0 +1,433 @@ +import {describe, it, expect} from 'vitest' +import { + toCreateTagRequest, toCreateEnvironmentRequest, toCreateSecretRequest, + toCreateAlertChannelRequest, toCreateNotificationPolicyRequest, + toCreateWebhookRequest, toCreateResourceGroupRequest, + toCreateMonitorRequest, toUpdateMonitorRequest, +} from '../../src/lib/yaml/transform.js' +import {ResolvedRefs} from '../../src/lib/yaml/resolver.js' +import type { + YamlTag, YamlEnvironment, YamlAlertChannel, + YamlNotificationPolicy, YamlWebhook, YamlResourceGroup, YamlMonitor, +} from '../../src/lib/yaml/schema.js' + +function emptyRefs(): ResolvedRefs { + return new ResolvedRefs() +} + +function refsWithChannels(): ResolvedRefs { + const refs = new ResolvedRefs() + refs.set('alertChannels', 'ops-slack', {id: 'ch-123', refKey: 'ops-slack', raw: {}}) + refs.set('alertChannels', 'pagerduty', {id: 'ch-456', refKey: 'pagerduty', raw: {}}) + refs.set('tags', 'production', {id: 'tag-1', refKey: 'production', raw: {}}) + refs.set('environments', 'prod', {id: 'env-1', refKey: 'prod', raw: {}}) + refs.set('secrets', 'api-key', {id: 'sec-1', refKey: 'api-key', raw: {}}) + return refs +} + +describe('transforms', () => { + describe('toCreateTagRequest', () => { + it('transforms basic tag', () => { + const tag: YamlTag = {name: 'production', color: '#EF4444'} + const req = toCreateTagRequest(tag) + expect(req.name).toBe('production') + expect(req.color).toBe('#EF4444') + }) + + it('defaults color to null', () => { + const req = toCreateTagRequest({name: 'test'}) + expect(req.color).toBeNull() + }) + }) + + describe('toCreateEnvironmentRequest', () => { + it('transforms environment', () => { + const env: YamlEnvironment = {name: 'Production', slug: 'production', isDefault: true} + const req = toCreateEnvironmentRequest(env) + expect(req.name).toBe('Production') + expect(req.slug).toBe('production') + expect(req.isDefault).toBe(true) + }) + + it('handles variables', () => { + const env: YamlEnvironment = {name: 'Dev', slug: 'dev', variables: {API_URL: 'http://localhost'}} + const req = toCreateEnvironmentRequest(env) + expect(req.variables).toEqual({API_URL: 'http://localhost'}) + }) + + it('defaults variables to null', () => { + const env: YamlEnvironment = {name: 'Staging', slug: 'staging'} + const req = toCreateEnvironmentRequest(env) + expect(req.variables).toBeNull() + }) + + it('defaults isDefault to undefined', () => { + const env: YamlEnvironment = {name: 'CI', slug: 'ci'} + const req = toCreateEnvironmentRequest(env) + expect(req.isDefault).toBeUndefined() + }) + }) + + describe('toCreateSecretRequest', () => { + it('transforms secret', () => { + const req = toCreateSecretRequest({key: 'api-key', value: 'secret-123'}) + expect(req.key).toBe('api-key') + expect(req.value).toBe('secret-123') + }) + }) + + describe('toCreateAlertChannelRequest', () => { + it('transforms slack channel', () => { + const channel: YamlAlertChannel = { + name: 'ops', type: 'slack', + config: {webhookUrl: 'https://hooks.slack.com/test'}, + } + const req = toCreateAlertChannelRequest(channel) + expect(req.name).toBe('ops') + expect(req.config).toHaveProperty('channelType', 'SlackChannelConfig') + expect(req.config).toHaveProperty('webhookUrl', 'https://hooks.slack.com/test') + }) + + it('transforms email channel', () => { + const channel: YamlAlertChannel = { + name: 'eng', type: 'email', + config: {recipients: ['a@test.com']}, + } + const req = toCreateAlertChannelRequest(channel) + expect(req.config).toHaveProperty('channelType', 'EmailChannelConfig') + expect(req.config).toHaveProperty('recipients', ['a@test.com']) + }) + + it('transforms all 7 channel types', () => { + const types = [ + {type: 'slack' as const, config: {webhookUrl: 'url'}, expected: 'SlackChannelConfig'}, + {type: 'discord' as const, config: {webhookUrl: 'url'}, expected: 'DiscordChannelConfig'}, + {type: 'email' as const, config: {recipients: ['a@b.com']}, expected: 'EmailChannelConfig'}, + {type: 'pagerduty' as const, config: {routingKey: 'key'}, expected: 'PagerDutyChannelConfig'}, + {type: 'opsgenie' as const, config: {apiKey: 'key'}, expected: 'OpsGenieChannelConfig'}, + {type: 'teams' as const, config: {webhookUrl: 'url'}, expected: 'TeamsChannelConfig'}, + {type: 'webhook' as const, config: {url: 'url'}, expected: 'WebhookChannelConfig'}, + ] + for (const {type, config, expected} of types) { + const req = toCreateAlertChannelRequest({name: 'test', type, config}) + expect(req.config).toHaveProperty('channelType', expected) + } + }) + + it('preserves extra config fields', () => { + const channel: YamlAlertChannel = { + name: 'pg', type: 'pagerduty', + config: {routingKey: 'r-key', severity: 'critical'}, + } + const req = toCreateAlertChannelRequest(channel) + expect(req.config).toHaveProperty('routingKey', 'r-key') + expect(req.config).toHaveProperty('severity', 'critical') + }) + }) + + describe('toCreateNotificationPolicyRequest', () => { + it('transforms with channel references', () => { + const refs = refsWithChannels() + const policy: YamlNotificationPolicy = { + name: 'test', enabled: true, priority: 1, + escalation: { + steps: [{channels: ['ops-slack'], delayMinutes: 0}], + }, + } + const req = toCreateNotificationPolicyRequest(policy, refs) + expect(req.name).toBe('test') + expect(req.enabled).toBe(true) + expect(req.priority).toBe(1) + expect(req.escalation.steps[0].channelIds).toEqual(['ch-123']) + }) + + it('throws on unresolved channel', () => { + const refs = emptyRefs() + const policy: YamlNotificationPolicy = { + name: 'test', + escalation: {steps: [{channels: ['nonexistent']}]}, + } + expect(() => toCreateNotificationPolicyRequest(policy, refs)).toThrow('Cannot resolve') + }) + + it('transforms match rules with monitor names', () => { + const refs = new ResolvedRefs() + refs.set('monitors', 'api', {id: 'mon-1', refKey: 'api', raw: {}}) + refs.set('alertChannels', 'ch', {id: 'ch-1', refKey: 'ch', raw: {}}) + const policy: YamlNotificationPolicy = { + name: 'test', + matchRules: [{type: 'monitor_id_in', monitorNames: ['api']}], + escalation: {steps: [{channels: ['ch']}]}, + } + const req = toCreateNotificationPolicyRequest(policy, refs) + expect(req.matchRules![0].monitorIds).toEqual(['mon-1']) + }) + + it('defaults enabled to true', () => { + const refs = refsWithChannels() + const policy: YamlNotificationPolicy = { + name: 'p', + escalation: {steps: [{channels: ['ops-slack']}]}, + } + const req = toCreateNotificationPolicyRequest(policy, refs) + expect(req.enabled).toBe(true) + }) + + it('defaults priority to 0', () => { + const refs = refsWithChannels() + const policy: YamlNotificationPolicy = { + name: 'p', + escalation: {steps: [{channels: ['ops-slack']}]}, + } + const req = toCreateNotificationPolicyRequest(policy, refs) + expect(req.priority).toBe(0) + }) + + it('transforms multiple escalation steps with delays', () => { + const refs = refsWithChannels() + const policy: YamlNotificationPolicy = { + name: 'esc', + escalation: { + steps: [ + {channels: ['ops-slack'], delayMinutes: 0}, + {channels: ['pagerduty'], delayMinutes: 15, requireAck: true}, + ], + }, + } + const req = toCreateNotificationPolicyRequest(policy, refs) + expect(req.escalation.steps).toHaveLength(2) + expect(req.escalation.steps[0].delayMinutes).toBe(0) + expect(req.escalation.steps[1].delayMinutes).toBe(15) + expect(req.escalation.steps[1].requireAck).toBe(true) + expect(req.escalation.steps[1].channelIds).toEqual(['ch-456']) + }) + + it('transforms escalation onResolve/onReopen', () => { + const refs = refsWithChannels() + const policy: YamlNotificationPolicy = { + name: 'p', + escalation: { + steps: [{channels: ['ops-slack']}], + onResolve: 'notify_all', + onReopen: 'notify_first', + }, + } + const req = toCreateNotificationPolicyRequest(policy, refs) + expect(req.escalation.onResolve).toBe('notify_all') + expect(req.escalation.onReopen).toBe('notify_first') + }) + }) + + describe('toCreateWebhookRequest', () => { + it('transforms webhook', () => { + const webhook: YamlWebhook = { + url: 'https://hooks.example.com', events: ['monitor.down'], description: 'test', + } + const req = toCreateWebhookRequest(webhook) + expect(req.url).toBe('https://hooks.example.com') + expect(req.subscribedEvents).toEqual(['monitor.down']) + expect(req.description).toBe('test') + }) + + it('transforms webhook without description', () => { + const webhook: YamlWebhook = {url: 'https://x.com', events: ['a', 'b']} + const req = toCreateWebhookRequest(webhook) + expect(req.description).toBeUndefined() + expect(req.subscribedEvents).toEqual(['a', 'b']) + }) + }) + + describe('toCreateResourceGroupRequest', () => { + it('transforms with defaults', () => { + const refs = refsWithChannels() + refs.set('notificationPolicies', 'critical', {id: 'pol-1', refKey: 'critical', raw: {}}) + const group: YamlResourceGroup = { + name: 'API', + description: 'API services', + defaultFrequency: 30, + defaultRegions: ['us-east'], + defaultAlertChannels: ['ops-slack'], + defaultEnvironment: 'prod', + alertPolicy: 'critical', + healthThresholdType: 'PERCENTAGE', + healthThresholdValue: 80, + } + const req = toCreateResourceGroupRequest(group, refs) + expect(req.name).toBe('API') + expect(req.description).toBe('API services') + expect(req.defaultFrequency).toBe(30) + expect(req.defaultRegions).toEqual(['us-east']) + expect(req.alertPolicyId).toBe('pol-1') + expect(req.defaultEnvironmentId).toBe('env-1') + expect(req.healthThresholdType).toBe('PERCENTAGE') + expect(req.healthThresholdValue).toBe(80) + }) + + it('transforms minimal group with null defaults', () => { + const refs = emptyRefs() + const group: YamlResourceGroup = {name: 'Minimal'} + const req = toCreateResourceGroupRequest(group, refs) + expect(req.name).toBe('Minimal') + expect(req.description).toBeNull() + expect(req.alertPolicyId).toBeNull() + expect(req.defaultFrequency).toBeNull() + expect(req.defaultRegions).toBeNull() + }) + + it('transforms retry strategy', () => { + const refs = emptyRefs() + const group: YamlResourceGroup = { + name: 'G', + defaultRetryStrategy: {type: 'LINEAR', maxRetries: 3, interval: 10}, + } + const req = toCreateResourceGroupRequest(group, refs) + expect(req.defaultRetryStrategy).toEqual({type: 'LINEAR', maxRetries: 3, interval: 10}) + }) + }) + + describe('toCreateMonitorRequest', () => { + it('transforms HTTP monitor', () => { + const refs = refsWithChannels() + const monitor: YamlMonitor = { + name: 'Test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + frequency: 60, + regions: ['us-east'], + tags: ['production'], + alertChannels: ['ops-slack'], + environment: 'prod', + } + const req = toCreateMonitorRequest(monitor, refs) + expect(req.name).toBe('Test') + expect(req.type).toBe('HTTP') + expect(req.managedBy).toBe('CLI') + expect(req.frequencySeconds).toBe(60) + expect(req.regions).toEqual(['us-east']) + expect(req.alertChannelIds).toEqual(['ch-123']) + expect(req.environmentId).toBe('env-1') + expect(req.tags!.tagIds).toEqual(['tag-1']) + }) + + it('creates new tags when not resolved', () => { + const refs = emptyRefs() + refs.set('alertChannels', 'ch', {id: 'ch-1', refKey: 'ch', raw: {}}) + const monitor: YamlMonitor = { + name: 'Test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + tags: ['new-tag'], + alertChannels: ['ch'], + } + const req = toCreateMonitorRequest(monitor, refs) + expect(req.tags!.newTags).toEqual([{name: 'new-tag'}]) + }) + + it('mixes existing and new tags', () => { + const refs = new ResolvedRefs() + refs.set('tags', 'existing', {id: 'tag-1', refKey: 'existing', raw: {}}) + const monitor: YamlMonitor = { + name: 'Test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + tags: ['existing', 'brand-new'], + } + const req = toCreateMonitorRequest(monitor, refs) + expect(req.tags!.tagIds).toEqual(['tag-1']) + expect(req.tags!.newTags).toEqual([{name: 'brand-new'}]) + }) + + it('transforms all 4 auth types', () => { + const refs = refsWithChannels() + const authTypes = [ + {type: 'BearerAuthConfig' as const, expectedType: 'BearerAuthConfig'}, + {type: 'BasicAuthConfig' as const, expectedType: 'BasicAuthConfig'}, + {type: 'ApiKeyAuthConfig' as const, expectedType: 'ApiKeyAuthConfig', headerName: 'X-Key'}, + {type: 'HeaderAuthConfig' as const, expectedType: 'HeaderAuthConfig', headerName: 'Authorization'}, + ] + for (const at of authTypes) { + const monitor: YamlMonitor = { + name: 'Test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + auth: {type: at.type, secret: 'api-key', headerName: at.headerName}, + } + const req = toCreateMonitorRequest(monitor, refs) + expect(req.auth).toHaveProperty('type', at.expectedType) + expect(req.auth).toHaveProperty('vaultSecretId', 'sec-1') + } + }) + + it('transforms incident policy', () => { + const refs = emptyRefs() + const monitor: YamlMonitor = { + name: 'Test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + incidentPolicy: { + triggerRules: [{type: 'consecutive_failures', count: 3, scope: 'per_region', severity: 'down'}], + confirmation: {type: 'multi_region', minRegionsFailing: 2}, + recovery: {consecutiveSuccesses: 2}, + }, + } + const req = toCreateMonitorRequest(monitor, refs) + expect(req.incidentPolicy!.triggerRules[0].type).toBe('consecutive_failures') + expect(req.incidentPolicy!.triggerRules[0].count).toBe(3) + expect(req.incidentPolicy!.confirmation.minRegionsFailing).toBe(2) + expect(req.incidentPolicy!.recovery.consecutiveSuccesses).toBe(2) + }) + + it('transforms monitor without frequency (undefined)', () => { + const refs = emptyRefs() + const monitor: YamlMonitor = { + name: 'NoFreq', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + } + const req = toCreateMonitorRequest(monitor, refs) + expect(req.frequencySeconds).toBeUndefined() + }) + + it('transforms monitor without tags (undefined)', () => { + const refs = emptyRefs() + const monitor: YamlMonitor = { + name: 'NoTags', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + } + const req = toCreateMonitorRequest(monitor, refs) + expect(req.tags).toBeUndefined() + }) + + it('transforms monitor without alertChannels', () => { + const refs = emptyRefs() + const monitor: YamlMonitor = { + name: 'Bare', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + } + const req = toCreateMonitorRequest(monitor, refs) + expect(req.alertChannelIds).toBeNull() + }) + }) + + describe('toUpdateMonitorRequest', () => { + it('includes managedBy CLI', () => { + const refs = emptyRefs() + const monitor: YamlMonitor = { + name: 'Test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + } + const req = toUpdateMonitorRequest(monitor, refs) + expect(req.managedBy).toBe('CLI') + }) + + it('preserves all fields same as create', () => { + const refs = refsWithChannels() + const monitor: YamlMonitor = { + name: 'Test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + frequency: 30, regions: ['eu-west'], + tags: ['production'], alertChannels: ['ops-slack'], + } + const req = toUpdateMonitorRequest(monitor, refs) + expect(req.name).toBe('Test') + expect(req.frequencySeconds).toBe(30) + expect(req.regions).toEqual(['eu-west']) + expect(req.tags!.tagIds).toEqual(['tag-1']) + expect(req.alertChannelIds).toEqual(['ch-123']) + }) + }) +}) diff --git a/test/yaml/validator.test.ts b/test/yaml/validator.test.ts new file mode 100644 index 0000000..5cae17b --- /dev/null +++ b/test/yaml/validator.test.ts @@ -0,0 +1,772 @@ +import {describe, it, expect} from 'vitest' +import {join, dirname} from 'node:path' +import {fileURLToPath} from 'node:url' +import {parseConfigFile} from '../../src/lib/yaml/parser.js' +import {validate} from '../../src/lib/yaml/validator.js' +import type {DevhelmConfig} from '../../src/lib/yaml/schema.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const fixtures = join(__dirname, '..', 'fixtures', 'yaml') + +describe('validator', () => { + describe('valid configs', () => { + it('passes minimal config', () => { + const config = parseConfigFile(join(fixtures, 'valid', 'minimal.yml')) + const result = validate(config) + expect(result.errors).toHaveLength(0) + }) + + it('passes full-stack config', () => { + const config = parseConfigFile(join(fixtures, 'valid', 'full-stack.yml')) + const result = validate(config) + expect(result.errors).toHaveLength(0) + }) + + it('passes all monitor types', () => { + const config = parseConfigFile(join(fixtures, 'edge', 'all-monitor-types.yml')) + const result = validate(config) + expect(result.errors).toHaveLength(0) + }) + + it('passes all channel types', () => { + const config = parseConfigFile(join(fixtures, 'edge', 'all-channel-types.yml')) + const result = validate(config) + expect(result.errors).toHaveLength(0) + }) + }) + + describe('invalid configs', () => { + it('errors on missing monitor name', () => { + const config = parseConfigFile(join(fixtures, 'invalid', 'missing-name.yml')) + const result = validate(config) + expect(result.errors.length).toBeGreaterThan(0) + expect(result.errors.some((e) => e.path.includes('name'))).toBe(true) + }) + + it('errors on bad frequency', () => { + const config = parseConfigFile(join(fixtures, 'invalid', 'bad-frequency.yml')) + const result = validate(config) + expect(result.errors.length).toBeGreaterThan(0) + expect(result.errors.some((e) => e.message.includes('Frequency'))).toBe(true) + }) + + it('errors on invalid monitor type', () => { + const config = parseConfigFile(join(fixtures, 'invalid', 'bad-type.yml')) + const result = validate(config) + expect(result.errors.length).toBeGreaterThan(0) + expect(result.errors.some((e) => e.message.includes('Invalid type'))).toBe(true) + }) + + it('errors on invalid channel type', () => { + const config = parseConfigFile(join(fixtures, 'invalid', 'bad-channel-type.yml')) + const result = validate(config) + expect(result.errors.length).toBeGreaterThan(0) + expect(result.errors.some((e) => e.message.includes('Invalid channel type'))).toBe(true) + }) + + it('errors on duplicate names', () => { + const config = parseConfigFile(join(fixtures, 'invalid', 'duplicate-names.yml')) + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('Duplicate'))).toBe(true) + }) + + it('errors on empty config', () => { + const config = parseConfigFile(join(fixtures, 'invalid', 'empty.yml')) + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('no resource'))).toBe(true) + }) + + it('errors on empty escalation steps', () => { + const config = parseConfigFile(join(fixtures, 'invalid', 'bad-escalation.yml')) + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('at least one step'))).toBe(true) + }) + }) + + describe('monitor config validation', () => { + it('errors when HTTP monitor missing url', () => { + const config: DevhelmConfig = { + monitors: [{name: 'test', type: 'HTTP', config: {method: 'GET'}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('url'))).toBe(true) + }) + + it('errors when DNS monitor missing hostname', () => { + const config: DevhelmConfig = { + monitors: [{name: 'test', type: 'DNS', config: {}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('hostname'))).toBe(true) + }) + + it('errors when TCP monitor missing host', () => { + const config: DevhelmConfig = { + monitors: [{name: 'test', type: 'TCP', config: {}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('host'))).toBe(true) + }) + + it('errors when ICMP monitor missing host', () => { + const config: DevhelmConfig = { + monitors: [{name: 'test', type: 'ICMP', config: {}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('host'))).toBe(true) + }) + + it('errors when HEARTBEAT monitor missing expectedInterval', () => { + const config: DevhelmConfig = { + monitors: [{name: 'test', type: 'HEARTBEAT', config: {gracePeriod: 60}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('expectedInterval'))).toBe(true) + }) + + it('errors when MCP_SERVER monitor missing command', () => { + const config: DevhelmConfig = { + monitors: [{name: 'test', type: 'MCP_SERVER', config: {}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('command'))).toBe(true) + }) + + it('errors on invalid HTTP method', () => { + const config: DevhelmConfig = { + monitors: [{name: 'test', type: 'HTTP', config: {url: 'https://x.com', method: 'INVALID'}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('HTTP method'))).toBe(true) + }) + + it('errors on invalid TCP port', () => { + const config: DevhelmConfig = { + monitors: [{name: 'test', type: 'TCP', config: {host: 'x.com', port: 99999}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('port'))).toBe(true) + }) + + it('errors when TCP port is 0', () => { + const config: DevhelmConfig = { + monitors: [{name: 'test', type: 'TCP', config: {host: 'x.com', port: 0}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('port'))).toBe(true) + }) + + it('errors when HEARTBEAT gracePeriod missing', () => { + const config: DevhelmConfig = { + monitors: [{name: 'test', type: 'HEARTBEAT', config: {expectedInterval: 60}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('gracePeriod'))).toBe(true) + }) + + it('errors on frequency above MAX_FREQUENCY (86400)', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 't', + type: 'HTTP', + config: {url: 'u', method: 'GET'}, + frequency: 100_000, + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('Frequency'))).toBe(true) + }) + + it('errors on invalid DNS recordType', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 't', + type: 'DNS', + config: {hostname: 'x', recordTypes: ['INVALID']}, + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('DNS record type'))).toBe(true) + }) + + it('errors on missing monitor type', () => { + const config: DevhelmConfig = { + monitors: [{name: 't', config: {url: 'x'}} as DevhelmConfig['monitors'] extends (infer M)[] | undefined ? M : never], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('type'))).toBe(true) + }) + + it('errors when monitor regions is not an array', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 't', + type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + regions: 'us-east' as unknown as string[], + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('array'))).toBe(true) + }) + }) + + describe('channel config validation', () => { + it('errors when slack missing webhookUrl', () => { + const config: DevhelmConfig = { + alertChannels: [{name: 'test', type: 'slack', config: {}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('webhookUrl'))).toBe(true) + }) + + it('errors when email missing recipients', () => { + const config: DevhelmConfig = { + alertChannels: [{name: 'test', type: 'email', config: {}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('recipients'))).toBe(true) + }) + + it('errors when pagerduty missing routingKey', () => { + const config: DevhelmConfig = { + alertChannels: [{name: 'test', type: 'pagerduty', config: {}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('routingKey'))).toBe(true) + }) + + it('errors when opsgenie missing apiKey', () => { + const config: DevhelmConfig = { + alertChannels: [{name: 'test', type: 'opsgenie', config: {}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('apiKey'))).toBe(true) + }) + + it('errors when webhook missing url', () => { + const config: DevhelmConfig = { + alertChannels: [{name: 'test', type: 'webhook', config: {}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('url'))).toBe(true) + }) + + it('errors when discord missing webhookUrl', () => { + const config: DevhelmConfig = { + alertChannels: [{name: 'test', type: 'discord', config: {}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('webhookUrl'))).toBe(true) + }) + + it('errors when teams missing webhookUrl', () => { + const config: DevhelmConfig = { + alertChannels: [{name: 'test', type: 'teams', config: {}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('webhookUrl'))).toBe(true) + }) + + it('errors when email recipients is empty array', () => { + const config: DevhelmConfig = { + alertChannels: [{name: 'test', type: 'email', config: {recipients: []}}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('recipients'))).toBe(true) + }) + }) + + describe('webhook definition validation', () => { + it('errors when events is empty', () => { + const config: DevhelmConfig = { + webhooks: [{url: 'https://x.com', events: []}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('events'))).toBe(true) + }) + + it('errors when url is missing', () => { + const config: DevhelmConfig = { + webhooks: [{url: '', events: ['monitor.down']}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('url'))).toBe(true) + }) + }) + + describe('resource group validation', () => { + it('errors on invalid healthThresholdType', () => { + const config: DevhelmConfig = { + resourceGroups: [{name: 'test', healthThresholdType: 'INVALID' as string}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('Must be one of'))).toBe(true) + }) + + it('validates defaultFrequency bounds', () => { + const config: DevhelmConfig = { + resourceGroups: [{name: 'test', defaultFrequency: 1}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('Frequency'))).toBe(true) + }) + + it('warns on unresolved alertPolicy reference', () => { + const config: DevhelmConfig = { + resourceGroups: [{name: 'test', alertPolicy: 'nonexistent'}], + } + const result = validate(config) + expect(result.warnings.some((w) => w.message.includes('nonexistent'))).toBe(true) + }) + + it('warns on unresolved defaultEnvironment reference', () => { + const config: DevhelmConfig = { + resourceGroups: [{name: 'test', defaultEnvironment: 'missing-env'}], + } + const result = validate(config) + expect(result.warnings.some((w) => w.message.includes('missing-env'))).toBe(true) + }) + + it('warns on unresolved defaultAlertChannels reference', () => { + const config: DevhelmConfig = { + resourceGroups: [{name: 'test', defaultAlertChannels: ['missing-chan']}], + } + const result = validate(config) + expect(result.warnings.some((w) => w.message.includes('missing-chan'))).toBe(true) + }) + }) + + describe('dependency validation', () => { + it('errors on invalid alertSensitivity', () => { + const config: DevhelmConfig = { + dependencies: [{service: 'github', alertSensitivity: 'WRONG' as string}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('Must be one of'))).toBe(true) + }) + + it('errors on missing service name', () => { + const config: DevhelmConfig = { + dependencies: [{service: ''}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('service'))).toBe(true) + }) + }) + + describe('auth validation', () => { + it('errors on invalid auth type', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + auth: {type: 'InvalidAuth' as string, secret: 'key'}, + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('Auth type'))).toBe(true) + }) + + it('errors when auth missing secret', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + auth: {type: 'BearerAuthConfig', secret: ''}, + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('secret'))).toBe(true) + }) + + it('errors when ApiKeyAuthConfig missing headerName', () => { + const config: DevhelmConfig = { + secrets: [{key: 'my-key', value: 'val'}], + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + auth: {type: 'ApiKeyAuthConfig', secret: 'my-key'}, + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('headerName'))).toBe(true) + }) + + it('errors when HeaderAuthConfig missing headerName', () => { + const config: DevhelmConfig = { + secrets: [{key: 'my-key', value: 'val'}], + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + auth: {type: 'HeaderAuthConfig', secret: 'my-key'}, + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('headerName'))).toBe(true) + }) + + it('passes when ApiKeyAuthConfig has headerName', () => { + const config: DevhelmConfig = { + secrets: [{key: 'my-key', value: 'val'}], + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + auth: {type: 'ApiKeyAuthConfig', secret: 'my-key', headerName: 'X-API-Key'}, + }], + } + const result = validate(config) + const authErrors = result.errors.filter((e) => e.path.includes('auth')) + expect(authErrors).toHaveLength(0) + }) + + it('warns when auth secret not declared in YAML', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + auth: {type: 'BearerAuthConfig', secret: 'undeclared-secret'}, + }], + } + const result = validate(config) + expect(result.warnings.some((w) => w.message.includes('undeclared-secret'))).toBe(true) + }) + }) + + describe('cross-reference warnings', () => { + it('warns on unresolved tag reference', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + tags: ['nonexistent-tag'], + }], + } + const result = validate(config) + expect(result.warnings.some((w) => w.message.includes('nonexistent-tag'))).toBe(true) + }) + + it('warns on unresolved environment reference', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + environment: 'unknown-env', + }], + } + const result = validate(config) + expect(result.warnings.some((w) => w.message.includes('unknown-env'))).toBe(true) + }) + + it('no warning when tag is declared in same config', () => { + const config: DevhelmConfig = { + tags: [{name: 'production'}], + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + tags: ['production'], + }], + } + const result = validate(config) + const tagWarnings = result.warnings.filter((w) => w.message.includes('production')) + expect(tagWarnings).toHaveLength(0) + }) + + it('warns on unresolved resource group monitor reference', () => { + const config: DevhelmConfig = { + resourceGroups: [{name: 'group1', monitors: ['missing-monitor']}], + } + const result = validate(config) + expect(result.warnings.some((w) => w.message.includes('missing-monitor'))).toBe(true) + }) + + it('warns on unresolved alertChannel reference in monitor', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + alertChannels: ['missing-channel'], + }], + } + const result = validate(config) + expect(result.warnings.some((w) => w.message.includes('missing-channel'))).toBe(true) + }) + + it('warns on unresolved service ref in resource group', () => { + const config: DevhelmConfig = { + resourceGroups: [{name: 'test', services: ['unknown-service']}], + } + const result = validate(config) + expect(result.warnings.some((w) => w.message.includes('unknown-service'))).toBe(true) + }) + }) + + describe('incident policy validation', () => { + it('errors on missing trigger rules', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + incidentPolicy: {triggerRules: [], confirmation: {type: 'multi_region'}, recovery: {}}, + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('trigger rule'))).toBe(true) + }) + + it('errors on invalid trigger type', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + incidentPolicy: { + triggerRules: [{type: 'invalid' as string, scope: 'per_region', severity: 'down'}], + confirmation: {type: 'multi_region'}, + recovery: {}, + }, + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('trigger type'))).toBe(true) + }) + + it('errors on invalid trigger scope', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + incidentPolicy: { + triggerRules: [{type: 'consecutive_failures', scope: 'bad_scope' as string, severity: 'down'}], + confirmation: {type: 'multi_region'}, + recovery: {}, + }, + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('scope'))).toBe(true) + }) + + it('errors on invalid trigger severity', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + incidentPolicy: { + triggerRules: [{type: 'consecutive_failures', scope: 'per_region', severity: 'bad' as string}], + confirmation: {type: 'multi_region'}, + recovery: {}, + }, + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('Must be one of'))).toBe(true) + }) + + it('errors on invalid aggregationType', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + incidentPolicy: { + triggerRules: [{ + type: 'consecutive_failures', + scope: 'per_region', + severity: 'down', + aggregationType: 'bad' as string, + }], + confirmation: {type: 'multi_region'}, + recovery: {}, + }, + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('Must be one of'))).toBe(true) + }) + + it('errors on missing confirmation in incident policy', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + incidentPolicy: { + triggerRules: [{type: 'consecutive_failures', scope: 'per_region', severity: 'down'}], + recovery: {}, + } as DevhelmConfig['monitors'] extends (infer M)[] | undefined ? NonNullable['incidentPolicy'] : never, + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('confirmation'))).toBe(true) + }) + + it('errors on wrong confirmation type', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + incidentPolicy: { + triggerRules: [{type: 'consecutive_failures', scope: 'per_region', severity: 'down'}], + confirmation: {type: 'wrong_type' as 'multi_region'}, + recovery: {}, + }, + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('Confirmation type'))).toBe(true) + }) + + it('errors on missing recovery in incident policy', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + incidentPolicy: { + triggerRules: [{type: 'consecutive_failures', scope: 'per_region', severity: 'down'}], + confirmation: {type: 'multi_region'}, + } as DevhelmConfig['monitors'] extends (infer M)[] | undefined ? NonNullable['incidentPolicy'] : never, + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('recovery'))).toBe(true) + }) + }) + + describe('notification policy validation', () => { + it('errors on missing escalation', () => { + const config: DevhelmConfig = { + notificationPolicies: [{name: 'test'} as DevhelmConfig['notificationPolicies'] extends (infer T)[] | undefined ? T : never], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('escalation'))).toBe(true) + }) + + it('errors on negative priority', () => { + const config: DevhelmConfig = { + notificationPolicies: [{ + name: 'test', priority: -1, + escalation: {steps: [{channels: ['chan'], delayMinutes: 0}]}, + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('non-negative'))).toBe(true) + }) + + it('warns on unresolved channel in escalation step', () => { + const config: DevhelmConfig = { + notificationPolicies: [{ + name: 'test', + escalation: {steps: [{channels: ['nonexistent-channel']}]}, + }], + } + const result = validate(config) + expect(result.warnings.some((w) => w.message.includes('nonexistent-channel'))).toBe(true) + }) + + it('errors on negative delayMinutes', () => { + const config: DevhelmConfig = { + alertChannels: [{name: 'ch', type: 'slack', config: {webhookUrl: 'url'}}], + notificationPolicies: [{ + name: 'test', + escalation: {steps: [{channels: ['ch'], delayMinutes: -5}]}, + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('delayMinutes'))).toBe(true) + }) + }) + + describe('environment validation', () => { + it('errors on invalid slug', () => { + const config: DevhelmConfig = { + environments: [{name: 'Prod', slug: 'Bad Slug!'}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('lowercase'))).toBe(true) + }) + + it('errors on missing environment name', () => { + const config: DevhelmConfig = { + environments: [{name: '', slug: 'prod'}], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('name'))).toBe(true) + }) + }) + + describe('tag validation', () => { + it('warns on bad color hex', () => { + const config: DevhelmConfig = { + tags: [{name: 'test', color: 'red'}], + } + const result = validate(config) + expect(result.warnings.some((w) => w.message.includes('hex'))).toBe(true) + }) + + it('no warning on valid hex color', () => { + const config: DevhelmConfig = { + tags: [{name: 'test', color: '#FF0000'}], + } + const result = validate(config) + const colorWarnings = result.warnings.filter((w) => w.path.includes('color')) + expect(colorWarnings).toHaveLength(0) + }) + }) + + describe('assertion validation', () => { + it('errors on invalid assertion type', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + assertions: [{type: 'InvalidAssertion', severity: 'error'}], + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('assertion type'))).toBe(true) + }) + + it('errors on invalid assertion severity', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + assertions: [{type: 'StatusCodeAssertion', severity: 'bad' as string}], + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('severity'))).toBe(true) + }) + + it('errors on invalid operator in assertion config', () => { + const config: DevhelmConfig = { + monitors: [{ + name: 'test', type: 'HTTP', + config: {url: 'https://x.com', method: 'GET'}, + assertions: [{type: 'StatusCodeAssertion', severity: 'error', config: {operator: 'INVALID_OP'}}], + }], + } + const result = validate(config) + expect(result.errors.some((e) => e.message.includes('operator'))).toBe(true) + }) + }) + + describe('version validation', () => { + it('warns on unknown config version', () => { + const config: DevhelmConfig = { + version: '99', + monitors: [{name: 'test', type: 'HTTP', config: {url: 'https://x.com', method: 'GET'}}], + } + const result = validate(config) + expect(result.warnings.some((w) => w.message.includes('Unknown config version'))).toBe(true) + }) + + it('no warning on version 1', () => { + const config: DevhelmConfig = { + version: '1', + monitors: [{name: 'test', type: 'HTTP', config: {url: 'https://x.com', method: 'GET'}}], + } + const result = validate(config) + const versionWarnings = result.warnings.filter((w) => w.path === 'version') + expect(versionWarnings).toHaveLength(0) + }) + }) +})